This commit is contained in:
despiegk 2025-04-21 10:51:04 +02:00
parent 6757a62fec
commit 537cf58b6f
7 changed files with 315 additions and 142 deletions

View File

@ -9,26 +9,26 @@ The business models are implemented as Rust structs and enums with serialization
## Model Relationships
```
┌─────────────┐
│ Customer │
└──────┬──────┘
┌─────────────┐
│ Customer │
└──────┬──────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Currency │◄────┤ Product │◄────┤ SaleItem │◄────┤ Sale
└─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘
│ │
┌─────┴──────────┐ │
│ProductComponent│
└────────────────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Currency │◄────┤ Service │◄────┤ ServiceItem │◄───────────┘
└─────────────┘ └─────────────┘ └─────────────┘
│ Currency │◄────┤ Product │◄────┤ │ │
└─────────────┘ └─────────────┘ │ │ │ │
│ SaleItem │◄────┤ Sale
┌─────┴──────────┐
│ProductComponent│ └─────────────┘ └──────┬──────┘
└────────────────┘
/
┌─────────────┐ ┌─────────────┐ /
│ Currency │◄────┤ Service │◄────────/ │
└─────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐
│ InvoiceItem │◄────┤ Invoice │
└─────────────┘ └─────────────┘
@ -40,7 +40,9 @@ The business models are implemented as Rust structs and enums with serialization
- **Product/Service**: Defines what is being sold, including its base price
- Can be marked as a template (`is_template=true`) to create copies for actual sales
- **Sale**: Represents the transaction of selling products/services to customers, including tax calculations
- Can be linked to a Service when the sale creates an ongoing service
- Contains SaleItems that can be linked to either Products or Services
- **SaleItem**: Represents an item within a sale
- Can be linked to either a Product or a Service (via product_id or service_id)
- **Service**: Represents an ongoing service provided to a customer
- Created from a Product template when the product type is Service
- **Invoice**: Represents the billing document for a sale, with payment tracking
@ -217,8 +219,9 @@ Represents an item within a sale.
**Properties:**
- `id`: u32 - Unique identifier
- `sale_id`: u32 - Parent sale ID
- `product_id`: u32 - ID of the product sold
- `name`: String - Product name at time of sale
- `product_id`: Option<u32> - ID of the product sold (if this is a product sale)
- `service_id`: Option<u32> - ID of the service sold (if this is a service sale)
- `name`: String - Product/service name at time of sale
- `description`: String - Detailed description of the item
- `comments`: String - Additional notes or comments about the item
- `quantity`: i32 - Number of items purchased
@ -481,15 +484,22 @@ Products and Services can be marked as templates (`is_template=true`). When a cu
#### Sale to Service Relationship
When a product of type `Service` is sold, a Service instance can be created from the Sale:
A SaleItem can be directly linked to a Service via the `service_id` field. This allows for selling existing services or creating new services as part of a sale:
```rust
// Create a service from a sale
let service = sale.create_service(
service_id,
ServiceStatus::Active,
BillingFrequency::Monthly
);
// Create a SaleItem linked to a service
let sale_item = SaleItemBuilder::new()
.id(1)
.sale_id(1)
.service_id(Some(42)) // Link to service with ID 42
.product_id(None) // No product link since this is a service
.name("Premium Support")
.quantity(1)
.unit_price(unit_price)
.tax_rate(20.0)
.active_till(now + Duration::days(30))
.build()
.expect("Failed to build sale item");
```
#### Sale to Invoice Relationship

View File

@ -1,4 +1,4 @@
use crate::db::{Model, Storable}; // Import Model trait from db module
use crate::db::{Model, Storable, IndexKey}; // Import Model trait and IndexKey from db module
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@ -248,4 +248,46 @@ impl Model for Contract {
fn db_prefix() -> &'static str {
"contract"
}
fn db_keys(&self) -> Vec<IndexKey> {
let mut keys = Vec::new();
// Add an index for customer_id
keys.push(IndexKey {
name: "customer_id",
value: self.customer_id.to_string(),
});
// Add an index for service_id if present
if let Some(service_id) = self.service_id {
keys.push(IndexKey {
name: "service_id",
value: service_id.to_string(),
});
}
// Add an index for sale_id if present
if let Some(sale_id) = self.sale_id {
keys.push(IndexKey {
name: "sale_id",
value: sale_id.to_string(),
});
}
// Add an index for status
keys.push(IndexKey {
name: "status",
value: format!("{:?}", self.status),
});
// Add an index for active contracts
if self.is_active() {
keys.push(IndexKey {
name: "active",
value: "true".to_string(),
});
}
keys
}
}

View File

@ -1,4 +1,4 @@
use crate::db::model::Model;
use crate::db::model::{Model, IndexKey};
use crate::db::{Storable, DbError, DbResult};
use chrono::{DateTime, Duration, Utc};
use rhai::{CustomType, EvalAltResult, TypeBuilder};
@ -85,4 +85,30 @@ impl Model for Currency {
fn db_prefix() -> &'static str {
"currency"
}
fn db_keys(&self) -> Vec<IndexKey> {
let mut keys = Vec::new();
// Add an index for currency_code
keys.push(IndexKey {
name: "currency_code",
value: self.currency_code.clone(),
});
// Add an index for amount range
// This allows finding currencies within specific ranges
let amount_range = match self.amount {
a if a < 100.0 => "0-100",
a if a < 1000.0 => "100-1000",
a if a < 10000.0 => "1000-10000",
_ => "10000+",
};
keys.push(IndexKey {
name: "amount_range",
value: amount_range.to_string(),
});
keys
}
}

View File

@ -1,5 +1,5 @@
use crate::models::biz::Currency; // Use crate:: for importing from the module
use crate::db::{Model, Storable}; // Import Model trait from db module
use crate::db::{Model, Storable, IndexKey}; // Import Model trait and IndexKey from db module
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@ -511,4 +511,67 @@ impl Model for Invoice {
fn db_prefix() -> &'static str {
"invoice"
}
fn db_keys(&self) -> Vec<IndexKey> {
let mut keys = Vec::new();
// Add an index for customer_id
keys.push(IndexKey {
name: "customer_id",
value: self.customer_id.to_string(),
});
// Add an index for status
keys.push(IndexKey {
name: "status",
value: format!("{:?}", self.status),
});
// Add an index for payment_status
keys.push(IndexKey {
name: "payment_status",
value: format!("{:?}", self.payment_status),
});
// Add an index for currency code
keys.push(IndexKey {
name: "currency",
value: self.total_amount.currency_code.clone(),
});
// Add an index for amount range
let amount_range = match self.total_amount.amount {
a if a < 100.0 => "0-100",
a if a < 1000.0 => "100-1000",
a if a < 10000.0 => "1000-10000",
_ => "10000+",
};
keys.push(IndexKey {
name: "amount_range",
value: amount_range.to_string(),
});
// Add an index for issue date (year-month)
keys.push(IndexKey {
name: "issue_date",
value: format!("{}-{:02}", self.issue_date.year(), self.issue_date.month()),
});
// Add an index for due date (year-month)
keys.push(IndexKey {
name: "due_date",
value: format!("{}-{:02}", self.due_date.year(), self.due_date.month()),
});
// Add an index for overdue invoices
if self.is_overdue() {
keys.push(IndexKey {
name: "overdue",
value: "true".to_string(),
});
}
keys
}
}

View File

@ -1,8 +1,8 @@
use crate::db::model::Model;
use crate::db::model::{Model, IndexKey};
use crate::db::Storable;
use chrono::{DateTime, Duration, Utc};
use rhai::{CustomType, EvalAltResult, TypeBuilder, export_module};
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Serialize};
/// ProductType represents the type of a product
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -355,6 +355,70 @@ impl Model for Product {
fn db_prefix() -> &'static str {
"product"
}
fn db_keys(&self) -> Vec<IndexKey> {
let mut keys = Vec::new();
// Add an index for name
keys.push(IndexKey {
name: "name",
value: self.name.clone(),
});
// Add an index for category
keys.push(IndexKey {
name: "category",
value: self.category.clone(),
});
// Add an index for product type
keys.push(IndexKey {
name: "type",
value: format!("{:?}", self.type_),
});
// Add an index for status
keys.push(IndexKey {
name: "status",
value: format!("{:?}", self.status),
});
// Add an index for price range
let price_range = match self.price.amount {
a if a < 100.0 => "0-100",
a if a < 1000.0 => "100-1000",
a if a < 10000.0 => "1000-10000",
_ => "10000+",
};
keys.push(IndexKey {
name: "price_range",
value: price_range.to_string(),
});
// Add an index for currency code
keys.push(IndexKey {
name: "currency",
value: self.price.currency_code.clone(),
});
// Add indexes for purchasable and active products
if self.is_purchasable() {
keys.push(IndexKey {
name: "purchasable",
value: "true".to_string(),
});
}
if self.is_active() {
keys.push(IndexKey {
name: "active",
value: "true".to_string(),
});
}
keys
}
}
// Import Currency from the currency module

View File

@ -1,4 +1,4 @@
use crate::db::{Model, Storable, DbError, DbResult};
use crate::db::{Model, Storable, DbError, DbResult, IndexKey};
use crate::models::biz::Currency; // Use crate:: for importing from the module
// use super::db::Model; // Removed old Model trait import
use chrono::{DateTime, Utc};
@ -19,7 +19,8 @@ pub enum SaleStatus {
pub struct SaleItem {
pub id: u32,
pub sale_id: u32,
pub product_id: u32,
pub product_id: Option<u32>, // ID of the product sold (if this is a product sale)
pub service_id: Option<u32>, // ID of the service sold (if this is a service sale)
pub name: String,
pub description: String, // Description of the item
pub comments: String, // Additional comments about the item
@ -36,7 +37,8 @@ impl SaleItem {
pub fn new(
id: u32,
sale_id: u32,
product_id: u32,
product_id: Option<u32>,
service_id: Option<u32>,
name: String,
description: String,
comments: String,
@ -45,6 +47,12 @@ impl SaleItem {
tax_rate: f64,
active_till: DateTime<Utc>,
) -> Self {
// Validate that either product_id or service_id is provided, but not both
assert!(
(product_id.is_some() && service_id.is_none()) ||
(product_id.is_none() && service_id.is_some()),
"Either product_id or service_id must be provided, but not both"
);
// Calculate subtotal (before tax)
let amount = unit_price.amount * quantity as f64;
let subtotal = Currency::new(
@ -65,6 +73,7 @@ impl SaleItem {
id,
sale_id,
product_id,
service_id,
name,
description,
comments,
@ -93,6 +102,7 @@ pub struct SaleItemBuilder {
id: Option<u32>,
sale_id: Option<u32>,
product_id: Option<u32>,
service_id: Option<u32>,
name: Option<String>,
description: Option<String>,
comments: Option<String>,
@ -111,6 +121,7 @@ impl SaleItemBuilder {
id: None,
sale_id: None,
product_id: None,
service_id: None,
name: None,
description: None,
comments: None,
@ -136,8 +147,22 @@ impl SaleItemBuilder {
}
/// Set the product_id
pub fn product_id(mut self, product_id: u32) -> Self {
self.product_id = Some(product_id);
pub fn product_id(mut self, product_id: Option<u32>) -> Self {
// If setting product_id, ensure service_id is None
if product_id.is_some() {
self.service_id = None;
}
self.product_id = product_id;
self
}
/// Set the service_id
pub fn service_id(mut self, service_id: Option<u32>) -> Self {
// If setting service_id, ensure product_id is None
if service_id.is_some() {
self.product_id = None;
}
self.service_id = service_id;
self
}
@ -189,6 +214,14 @@ impl SaleItemBuilder {
let quantity = self.quantity.ok_or("quantity is required")?;
let tax_rate = self.tax_rate.unwrap_or(0.0); // Default to 0% tax if not specified
// Validate that either product_id or service_id is provided, but not both
if self.product_id.is_none() && self.service_id.is_none() {
return Err("Either product_id or service_id must be provided");
}
if self.product_id.is_some() && self.service_id.is_some() {
return Err("Only one of product_id or service_id can be provided");
}
// Calculate subtotal
let amount = unit_price.amount * quantity as f64;
let subtotal = Currency::new(
@ -208,7 +241,8 @@ impl SaleItemBuilder {
Ok(SaleItem {
id: self.id.ok_or("id is required")?,
sale_id: self.sale_id.ok_or("sale_id is required")?,
product_id: self.product_id.ok_or("product_id is required")?,
product_id: self.product_id,
service_id: self.service_id,
name: self.name.ok_or("name is required")?,
description: self.description.unwrap_or_default(),
comments: self.comments.unwrap_or_default(),
@ -226,10 +260,7 @@ impl SaleItemBuilder {
#[derive(Debug, Clone, Serialize, Deserialize, CustomType)]
pub struct Sale {
pub id: u32,
pub company_id: u32,
pub customer_id: u32, // ID of the customer making the purchase
pub buyer_name: String,
pub buyer_email: String,
pub subtotal_amount: Currency, // Total before tax
pub tax_amount: Currency, // Total tax
pub total_amount: Currency, // Total including tax
@ -247,10 +278,7 @@ impl Sale {
/// Create a new sale with default timestamps
pub fn new(
id: u32,
company_id: u32,
customer_id: u32,
buyer_name: String,
buyer_email: String,
currency_code: String,
status: SaleStatus,
) -> Self {
@ -263,10 +291,7 @@ impl Sale {
Self {
id,
company_id,
customer_id,
buyer_name,
buyer_email,
subtotal_amount: zero_currency.clone(),
tax_amount: zero_currency.clone(),
total_amount: zero_currency,
@ -362,49 +387,10 @@ impl Sale {
self.updated_at = Utc::now();
}
/// Create a service from this sale
/// This method should be called when a product of type Service is sold
pub fn create_service(&mut self, service_id: u32, status: crate::models::biz::ServiceStatus, billing_frequency: crate::models::biz::BillingFrequency) -> Result<crate::models::biz::Service, &'static str> {
use crate::models::biz::{Service, ServiceItem, ServiceStatus, BillingFrequency};
// Create a new service
let mut service = Service::new(
service_id,
self.customer_id,
self.total_amount.currency_code.clone(),
status,
billing_frequency,
);
// Convert sale items to service items
for sale_item in &self.items {
// Check if the product is a service type
// In a real implementation, you would check the product type from the database
// Create a service item from the sale item
let service_item = ServiceItem::new(
sale_item.id,
service_id,
sale_item.product_id,
sale_item.name.clone(),
sale_item.description.clone(), // Copy description from sale item
sale_item.comments.clone(), // Copy comments from sale item
sale_item.quantity,
sale_item.unit_price.clone(),
sale_item.tax_rate,
true, // is_taxable
sale_item.active_till,
);
// Add the service item to the service
service.add_item(service_item);
}
// Link this sale to the service
/// Link this sale to an existing service
pub fn link_to_service(&mut self, service_id: u32) {
self.service_id = Some(service_id);
self.updated_at = Utc::now();
Ok(service)
}
}
@ -549,10 +535,7 @@ impl SaleBuilder {
Ok(Sale {
id,
company_id: self.company_id.ok_or("company_id is required")?,
customer_id: self.customer_id.ok_or("customer_id is required")?,
buyer_name: self.buyer_name.ok_or("buyer_name is required")?,
buyer_email: self.buyer_email.ok_or("buyer_email is required")?,
subtotal_amount: self.subtotal_amount.unwrap_or(subtotal_amount),
tax_amount: self.tax_amount.unwrap_or(tax_amount),
total_amount: self.total_amount.unwrap_or(total_amount),
@ -578,5 +561,19 @@ impl Model for Sale {
fn db_prefix() -> &'static str {
"sale"
}
fn db_keys(&self) -> Vec<IndexKey> {
let mut keys = Vec::new();
// Add an index for customer_id
keys.push(IndexKey {
name: "customer_id",
value: self.customer_id.to_string(),
});
keys
}
}

View File

@ -1,5 +1,5 @@
use crate::models::biz::Currency; // Use crate:: for importing from the module
use crate::db::{Model, Storable, DbError, DbResult}; // Import Model trait from db module
use crate::db::{Model, Storable, DbError, DbResult, IndexKey}; // Import Model trait and IndexKey from db module
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@ -34,9 +34,6 @@ pub struct ServiceItem {
pub quantity: i32,
pub unit_price: Currency,
pub subtotal: Currency,
pub tax_rate: f64,
pub tax_amount: Currency,
pub is_taxable: bool,
pub active_till: DateTime<Utc>,
}
@ -51,8 +48,6 @@ impl ServiceItem {
comments: String,
quantity: i32,
unit_price: Currency,
tax_rate: f64,
is_taxable: bool,
active_till: DateTime<Utc>,
) -> Self {
// Calculate subtotal
@ -62,22 +57,7 @@ impl ServiceItem {
amount,
unit_price.currency_code.clone()
);
// Calculate tax amount if taxable
let tax_amount = if is_taxable {
Currency::new(
0, // Use 0 as a temporary ID
subtotal.amount * tax_rate,
unit_price.currency_code.clone()
)
} else {
Currency::new(
0, // Use 0 as a temporary ID
0.0,
unit_price.currency_code.clone()
)
};
Self {
id,
service_id,
@ -88,9 +68,6 @@ impl ServiceItem {
quantity,
unit_price,
subtotal,
tax_rate,
tax_amount,
is_taxable,
active_till,
}
}
@ -105,22 +82,6 @@ impl ServiceItem {
);
}
/// Calculate the tax amount based on subtotal and tax rate
pub fn calculate_tax(&mut self) {
if self.is_taxable {
self.tax_amount = Currency::new(
0, // Use 0 as a temporary ID
self.subtotal.amount * self.tax_rate,
self.subtotal.currency_code.clone()
);
} else {
self.tax_amount = Currency::new(
0, // Use 0 as a temporary ID
0.0,
self.subtotal.currency_code.clone()
);
}
}
}
/// Builder for ServiceItem
@ -266,9 +227,6 @@ impl ServiceItemBuilder {
quantity,
unit_price,
subtotal,
tax_rate,
tax_amount,
is_taxable,
active_till: self.active_till.ok_or("active_till is required")?,
})
}
@ -321,13 +279,13 @@ impl Service {
// First item, initialize the total amount with the same currency
self.total_amount = Currency::new(
0, // Use 0 as a temporary ID
item.subtotal.amount + item.tax_amount.amount,
item.subtotal.amount ,
item.subtotal.currency_code.clone()
);
} else {
// Add to the existing total
// (Assumes all items have the same currency)
self.total_amount.amount += item.subtotal.amount + item.tax_amount.amount;
self.total_amount.amount += item.subtotal.amount;
}
// Add the item to the list
@ -349,7 +307,7 @@ impl Service {
// Calculate the total amount
let mut total = 0.0;
for item in &self.items {
total += item.subtotal.amount + item.tax_amount.amount;
total += item.subtotal.amount;
}
// Update the total amount
@ -467,13 +425,13 @@ impl ServiceBuilder {
// First item, initialize the total amount with the same currency
total_amount = Currency::new(
0, // Use 0 as a temporary ID
item.subtotal.amount + item.tax_amount.amount,
item.subtotal.amount,
item.subtotal.currency_code.clone()
);
} else {
// Add to the existing total
// (Assumes all items have the same currency)
total_amount.amount += item.subtotal.amount + item.tax_amount.amount;
total_amount.amount += item.subtotal.amount ;
}
}
@ -504,4 +462,17 @@ impl Model for Service {
fn db_prefix() -> &'static str {
"service"
}
fn db_keys(&self) -> Vec<IndexKey> {
let mut keys = Vec::new();
// Add an index for customer_id
keys.push(IndexKey {
name: "customer_id",
value: self.customer_id.to_string(),
});
keys
}
}