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

@ -15,17 +15,17 @@ The business models are implemented as Rust structs and enums with serialization
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Currency │◄────┤ Product │◄────┤ SaleItem │◄────┤ Sale │ Currency │◄────┤ Product │◄────┤ │ │
└─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘ └─────────────┘ └─────────────┘ │ │ │ │
│ SaleItem │◄────┤ Sale
│ │
┌─────┴──────────┐ │ ┌─────┴──────────┐
│ProductComponent│ │ProductComponent│ └─────────────┘ └──────┬──────┘
└────────────────┘ └────────────────┘
/
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ /
│ Currency │◄────┤ Service │◄────┤ ServiceItem │◄───────────┘ │ Currency │◄────┤ Service │◄────────/ │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
@ -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 - **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 - 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 - **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 - **Service**: Represents an ongoing service provided to a customer
- Created from a Product template when the product type is Service - Created from a Product template when the product type is Service
- **Invoice**: Represents the billing document for a sale, with payment tracking - **Invoice**: Represents the billing document for a sale, with payment tracking
@ -217,8 +219,9 @@ Represents an item within a sale.
**Properties:** **Properties:**
- `id`: u32 - Unique identifier - `id`: u32 - Unique identifier
- `sale_id`: u32 - Parent sale ID - `sale_id`: u32 - Parent sale ID
- `product_id`: u32 - ID of the product sold - `product_id`: Option<u32> - ID of the product sold (if this is a product sale)
- `name`: String - Product name at time of 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 - `description`: String - Detailed description of the item
- `comments`: String - Additional notes or comments about the item - `comments`: String - Additional notes or comments about the item
- `quantity`: i32 - Number of items purchased - `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 #### 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 ```rust
// Create a service from a sale // Create a SaleItem linked to a service
let service = sale.create_service( let sale_item = SaleItemBuilder::new()
service_id, .id(1)
ServiceStatus::Active, .sale_id(1)
BillingFrequency::Monthly .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 #### 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 chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -248,4 +248,46 @@ impl Model for Contract {
fn db_prefix() -> &'static str { fn db_prefix() -> &'static str {
"contract" "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 crate::db::{Storable, DbError, DbResult};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use rhai::{CustomType, EvalAltResult, TypeBuilder}; use rhai::{CustomType, EvalAltResult, TypeBuilder};
@ -85,4 +85,30 @@ impl Model for Currency {
fn db_prefix() -> &'static str { fn db_prefix() -> &'static str {
"currency" "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::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 chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -511,4 +511,67 @@ impl Model for Invoice {
fn db_prefix() -> &'static str { fn db_prefix() -> &'static str {
"invoice" "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,4 +1,4 @@
use crate::db::model::Model; use crate::db::model::{Model, IndexKey};
use crate::db::Storable; use crate::db::Storable;
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use rhai::{CustomType, EvalAltResult, TypeBuilder, export_module}; use rhai::{CustomType, EvalAltResult, TypeBuilder, export_module};
@ -355,6 +355,70 @@ impl Model for Product {
fn db_prefix() -> &'static str { fn db_prefix() -> &'static str {
"product" "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 // 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 crate::models::biz::Currency; // Use crate:: for importing from the module
// use super::db::Model; // Removed old Model trait import // use super::db::Model; // Removed old Model trait import
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
@ -19,7 +19,8 @@ pub enum SaleStatus {
pub struct SaleItem { pub struct SaleItem {
pub id: u32, pub id: u32,
pub sale_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 name: String,
pub description: String, // Description of the item pub description: String, // Description of the item
pub comments: String, // Additional comments about the item pub comments: String, // Additional comments about the item
@ -36,7 +37,8 @@ impl SaleItem {
pub fn new( pub fn new(
id: u32, id: u32,
sale_id: u32, sale_id: u32,
product_id: u32, product_id: Option<u32>,
service_id: Option<u32>,
name: String, name: String,
description: String, description: String,
comments: String, comments: String,
@ -45,6 +47,12 @@ impl SaleItem {
tax_rate: f64, tax_rate: f64,
active_till: DateTime<Utc>, active_till: DateTime<Utc>,
) -> Self { ) -> 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) // Calculate subtotal (before tax)
let amount = unit_price.amount * quantity as f64; let amount = unit_price.amount * quantity as f64;
let subtotal = Currency::new( let subtotal = Currency::new(
@ -65,6 +73,7 @@ impl SaleItem {
id, id,
sale_id, sale_id,
product_id, product_id,
service_id,
name, name,
description, description,
comments, comments,
@ -93,6 +102,7 @@ pub struct SaleItemBuilder {
id: Option<u32>, id: Option<u32>,
sale_id: Option<u32>, sale_id: Option<u32>,
product_id: Option<u32>, product_id: Option<u32>,
service_id: Option<u32>,
name: Option<String>, name: Option<String>,
description: Option<String>, description: Option<String>,
comments: Option<String>, comments: Option<String>,
@ -111,6 +121,7 @@ impl SaleItemBuilder {
id: None, id: None,
sale_id: None, sale_id: None,
product_id: None, product_id: None,
service_id: None,
name: None, name: None,
description: None, description: None,
comments: None, comments: None,
@ -136,8 +147,22 @@ impl SaleItemBuilder {
} }
/// Set the product_id /// Set the product_id
pub fn product_id(mut self, product_id: u32) -> Self { pub fn product_id(mut self, product_id: Option<u32>) -> Self {
self.product_id = Some(product_id); // 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 self
} }
@ -189,6 +214,14 @@ impl SaleItemBuilder {
let quantity = self.quantity.ok_or("quantity is required")?; 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 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 // Calculate subtotal
let amount = unit_price.amount * quantity as f64; let amount = unit_price.amount * quantity as f64;
let subtotal = Currency::new( let subtotal = Currency::new(
@ -208,7 +241,8 @@ impl SaleItemBuilder {
Ok(SaleItem { Ok(SaleItem {
id: self.id.ok_or("id is required")?, id: self.id.ok_or("id is required")?,
sale_id: self.sale_id.ok_or("sale_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")?, name: self.name.ok_or("name is required")?,
description: self.description.unwrap_or_default(), description: self.description.unwrap_or_default(),
comments: self.comments.unwrap_or_default(), comments: self.comments.unwrap_or_default(),
@ -226,10 +260,7 @@ impl SaleItemBuilder {
#[derive(Debug, Clone, Serialize, Deserialize, CustomType)] #[derive(Debug, Clone, Serialize, Deserialize, CustomType)]
pub struct Sale { pub struct Sale {
pub id: u32, pub id: u32,
pub company_id: u32,
pub customer_id: u32, // ID of the customer making the purchase 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 subtotal_amount: Currency, // Total before tax
pub tax_amount: Currency, // Total tax pub tax_amount: Currency, // Total tax
pub total_amount: Currency, // Total including tax pub total_amount: Currency, // Total including tax
@ -247,10 +278,7 @@ impl Sale {
/// Create a new sale with default timestamps /// Create a new sale with default timestamps
pub fn new( pub fn new(
id: u32, id: u32,
company_id: u32,
customer_id: u32, customer_id: u32,
buyer_name: String,
buyer_email: String,
currency_code: String, currency_code: String,
status: SaleStatus, status: SaleStatus,
) -> Self { ) -> Self {
@ -263,10 +291,7 @@ impl Sale {
Self { Self {
id, id,
company_id,
customer_id, customer_id,
buyer_name,
buyer_email,
subtotal_amount: zero_currency.clone(), subtotal_amount: zero_currency.clone(),
tax_amount: zero_currency.clone(), tax_amount: zero_currency.clone(),
total_amount: zero_currency, total_amount: zero_currency,
@ -362,49 +387,10 @@ impl Sale {
self.updated_at = Utc::now(); self.updated_at = Utc::now();
} }
/// Create a service from this sale /// Link this sale to an existing service
/// This method should be called when a product of type Service is sold pub fn link_to_service(&mut self, service_id: u32) {
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
self.service_id = Some(service_id); self.service_id = Some(service_id);
self.updated_at = Utc::now(); self.updated_at = Utc::now();
Ok(service)
} }
} }
@ -549,10 +535,7 @@ impl SaleBuilder {
Ok(Sale { Ok(Sale {
id, id,
company_id: self.company_id.ok_or("company_id is required")?,
customer_id: self.customer_id.ok_or("customer_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), subtotal_amount: self.subtotal_amount.unwrap_or(subtotal_amount),
tax_amount: self.tax_amount.unwrap_or(tax_amount), tax_amount: self.tax_amount.unwrap_or(tax_amount),
total_amount: self.total_amount.unwrap_or(total_amount), total_amount: self.total_amount.unwrap_or(total_amount),
@ -578,5 +561,19 @@ impl Model for Sale {
fn db_prefix() -> &'static str { fn db_prefix() -> &'static str {
"sale" "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::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 chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -34,9 +34,6 @@ pub struct ServiceItem {
pub quantity: i32, pub quantity: i32,
pub unit_price: Currency, pub unit_price: Currency,
pub subtotal: Currency, pub subtotal: Currency,
pub tax_rate: f64,
pub tax_amount: Currency,
pub is_taxable: bool,
pub active_till: DateTime<Utc>, pub active_till: DateTime<Utc>,
} }
@ -51,8 +48,6 @@ impl ServiceItem {
comments: String, comments: String,
quantity: i32, quantity: i32,
unit_price: Currency, unit_price: Currency,
tax_rate: f64,
is_taxable: bool,
active_till: DateTime<Utc>, active_till: DateTime<Utc>,
) -> Self { ) -> Self {
// Calculate subtotal // Calculate subtotal
@ -63,21 +58,6 @@ impl ServiceItem {
unit_price.currency_code.clone() 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 { Self {
id, id,
service_id, service_id,
@ -88,9 +68,6 @@ impl ServiceItem {
quantity, quantity,
unit_price, unit_price,
subtotal, subtotal,
tax_rate,
tax_amount,
is_taxable,
active_till, 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 /// Builder for ServiceItem
@ -266,9 +227,6 @@ impl ServiceItemBuilder {
quantity, quantity,
unit_price, unit_price,
subtotal, subtotal,
tax_rate,
tax_amount,
is_taxable,
active_till: self.active_till.ok_or("active_till is required")?, 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 // First item, initialize the total amount with the same currency
self.total_amount = Currency::new( self.total_amount = Currency::new(
0, // Use 0 as a temporary ID 0, // Use 0 as a temporary ID
item.subtotal.amount + item.tax_amount.amount, item.subtotal.amount ,
item.subtotal.currency_code.clone() item.subtotal.currency_code.clone()
); );
} else { } else {
// Add to the existing total // Add to the existing total
// (Assumes all items have the same currency) // (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 // Add the item to the list
@ -349,7 +307,7 @@ impl Service {
// Calculate the total amount // Calculate the total amount
let mut total = 0.0; let mut total = 0.0;
for item in &self.items { for item in &self.items {
total += item.subtotal.amount + item.tax_amount.amount; total += item.subtotal.amount;
} }
// Update the total amount // Update the total amount
@ -467,13 +425,13 @@ impl ServiceBuilder {
// First item, initialize the total amount with the same currency // First item, initialize the total amount with the same currency
total_amount = Currency::new( total_amount = Currency::new(
0, // Use 0 as a temporary ID 0, // Use 0 as a temporary ID
item.subtotal.amount + item.tax_amount.amount, item.subtotal.amount,
item.subtotal.currency_code.clone() item.subtotal.currency_code.clone()
); );
} else { } else {
// Add to the existing total // Add to the existing total
// (Assumes all items have the same currency) // (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 { fn db_prefix() -> &'static str {
"service" "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
}
} }