diff --git a/herodb/src/db/model_methods.rs b/herodb/src/db/model_methods.rs index a284529..297b01a 100644 --- a/herodb/src/db/model_methods.rs +++ b/herodb/src/db/model_methods.rs @@ -1,7 +1,7 @@ use crate::db::db::DB; use crate::db::base::{SledDBResult, SledModel}; use crate::impl_model_methods; -use crate::models::biz::{Product, Sale, Currency, ExchangeRate}; +use crate::models::biz::{Product, Sale, Currency, ExchangeRate, Service, Customer, Contract, Invoice}; // Implement model-specific methods for Product impl_model_methods!(Product, product, products); @@ -13,4 +13,16 @@ impl_model_methods!(Sale, sale, sales); impl_model_methods!(Currency, currency, currencies); // Implement model-specific methods for ExchangeRate -impl_model_methods!(ExchangeRate, exchange_rate, exchange_rates); \ No newline at end of file +impl_model_methods!(ExchangeRate, exchange_rate, exchange_rates); + +// Implement model-specific methods for Service +impl_model_methods!(Service, service, services); + +// Implement model-specific methods for Customer +impl_model_methods!(Customer, customer, customers); + +// Implement model-specific methods for Contract +impl_model_methods!(Contract, contract, contracts); + +// Implement model-specific methods for Invoice +impl_model_methods!(Invoice, invoice, invoices); \ No newline at end of file diff --git a/herodb/src/models/biz/business_models_plan.md b/herodb/src/models/biz/business_models_plan.md new file mode 100644 index 0000000..d68ec76 --- /dev/null +++ b/herodb/src/models/biz/business_models_plan.md @@ -0,0 +1,371 @@ +# Business Models Implementation Plan + +## Overview + +This document outlines the plan for implementing new business models in the codebase: + +1. **Service**: For tracking recurring payments (similar to Sale) +2. **Customer**: For storing customer information +3. **Contract**: For linking services or sales to customers +4. **Invoice**: For invoicing customers + +## Model Diagrams + +### Core Models and Relationships + +```mermaid +classDiagram + class Service { + +id: u32 + +customer_id: u32 + +total_amount: Currency + +status: ServiceStatus + +billing_frequency: BillingFrequency + +service_date: DateTime~Utc~ + +created_at: DateTime~Utc~ + +updated_at: DateTime~Utc~ + +items: Vec~ServiceItem~ + +calculate_total() + } + + class ServiceItem { + +id: u32 + +service_id: u32 + +name: String + +quantity: i32 + +unit_price: Currency + +subtotal: Currency + +tax_rate: f64 + +tax_amount: Currency + +is_taxable: bool + +active_till: DateTime~Utc~ + } + + class Customer { + +id: u32 + +name: String + +description: String + +pubkey: String + +contact_ids: Vec~u32~ + +created_at: DateTime~Utc~ + +updated_at: DateTime~Utc~ + } + + class Contract { + +id: u32 + +customer_id: u32 + +service_id: Option~u32~ + +sale_id: Option~u32~ + +terms: String + +start_date: DateTime~Utc~ + +end_date: DateTime~Utc~ + +auto_renewal: bool + +renewal_terms: String + +status: ContractStatus + +created_at: DateTime~Utc~ + +updated_at: DateTime~Utc~ + } + + class Invoice { + +id: u32 + +customer_id: u32 + +total_amount: Currency + +balance_due: Currency + +status: InvoiceStatus + +payment_status: PaymentStatus + +issue_date: DateTime~Utc~ + +due_date: DateTime~Utc~ + +created_at: DateTime~Utc~ + +updated_at: DateTime~Utc~ + +items: Vec~InvoiceItem~ + +payments: Vec~Payment~ + } + + class InvoiceItem { + +id: u32 + +invoice_id: u32 + +description: String + +amount: Currency + +service_id: Option~u32~ + +sale_id: Option~u32~ + } + + class Payment { + +amount: Currency + +date: DateTime~Utc~ + +method: String + } + + Service "1" -- "many" ServiceItem : contains + Customer "1" -- "many" Service : has + Customer "1" -- "many" Contract : has + Contract "1" -- "0..1" Service : references + Contract "1" -- "0..1" Sale : references + Invoice "1" -- "many" InvoiceItem : contains + Invoice "1" -- "many" Payment : contains + Customer "1" -- "many" Invoice : has + InvoiceItem "1" -- "0..1" Service : references + InvoiceItem "1" -- "0..1" Sale : references +``` + +### Enums and Supporting Types + +```mermaid +classDiagram + class BillingFrequency { + <> + Hourly + Daily + Weekly + Monthly + Yearly + } + + class ServiceStatus { + <> + Active + Paused + Cancelled + Completed + } + + class ContractStatus { + <> + Active + Expired + Terminated + } + + class InvoiceStatus { + <> + Draft + Sent + Paid + Overdue + Cancelled + } + + class PaymentStatus { + <> + Unpaid + PartiallyPaid + Paid + } + + Service -- ServiceStatus : has + Service -- BillingFrequency : has + Contract -- ContractStatus : has + Invoice -- InvoiceStatus : has + Invoice -- PaymentStatus : has +``` + +## Detailed Implementation Plan + +### 1. Service and ServiceItem (service.rs) + +The Service model will be similar to Sale but designed for recurring payments: + +- **Service**: Main struct for tracking recurring services + - Fields: + - id: u32 + - customer_id: u32 + - total_amount: Currency + - status: ServiceStatus + - billing_frequency: BillingFrequency + - service_date: DateTime + - created_at: DateTime + - updated_at: DateTime + - items: Vec + - Methods: + - calculate_total(): Updates the total_amount based on all items + - add_item(item: ServiceItem): Adds an item and updates the total + - update_status(status: ServiceStatus): Updates the status and timestamp + +- **ServiceItem**: Items within a service (similar to SaleItem) + - Fields: + - id: u32 + - service_id: u32 + - name: String + - quantity: i32 + - unit_price: Currency + - subtotal: Currency + - tax_rate: f64 + - tax_amount: Currency + - is_taxable: bool + - active_till: DateTime + - Methods: + - calculate_subtotal(): Calculates subtotal based on quantity and unit_price + - calculate_tax(): Calculates tax amount based on subtotal and tax_rate + +- **BillingFrequency**: Enum for different billing periods + - Variants: Hourly, Daily, Weekly, Monthly, Yearly + +- **ServiceStatus**: Enum for service status + - Variants: Active, Paused, Cancelled, Completed + +### 2. Customer (customer.rs) + +The Customer model will store customer information: + +- **Customer**: Main struct for customer data + - Fields: + - id: u32 + - name: String + - description: String + - pubkey: String + - contact_ids: Vec + - created_at: DateTime + - updated_at: DateTime + - Methods: + - add_contact(contact_id: u32): Adds a contact ID to the list + - remove_contact(contact_id: u32): Removes a contact ID from the list + +### 3. Contract (contract.rs) + +The Contract model will link services or sales to customers: + +- **Contract**: Main struct for contract data + - Fields: + - id: u32 + - customer_id: u32 + - service_id: Option + - sale_id: Option + - terms: String + - start_date: DateTime + - end_date: DateTime + - auto_renewal: bool + - renewal_terms: String + - status: ContractStatus + - created_at: DateTime + - updated_at: DateTime + - Methods: + - is_active(): bool - Checks if the contract is currently active + - is_expired(): bool - Checks if the contract has expired + - renew(): Updates the contract dates based on renewal terms + +- **ContractStatus**: Enum for contract status + - Variants: Active, Expired, Terminated + +### 4. Invoice (invoice.rs) + +The Invoice model will handle billing: + +- **Invoice**: Main struct for invoice data + - Fields: + - id: u32 + - customer_id: u32 + - total_amount: Currency + - balance_due: Currency + - status: InvoiceStatus + - payment_status: PaymentStatus + - issue_date: DateTime + - due_date: DateTime + - created_at: DateTime + - updated_at: DateTime + - items: Vec + - payments: Vec + - Methods: + - calculate_total(): Updates the total_amount based on all items + - add_item(item: InvoiceItem): Adds an item and updates the total + - add_payment(payment: Payment): Adds a payment and updates balance_due and payment_status + - update_status(status: InvoiceStatus): Updates the status and timestamp + - calculate_balance(): Updates the balance_due based on total_amount and payments + +- **InvoiceItem**: Items within an invoice + - Fields: + - id: u32 + - invoice_id: u32 + - description: String + - amount: Currency + - service_id: Option + - sale_id: Option + +- **Payment**: Struct for tracking payments + - Fields: + - amount: Currency + - date: DateTime + - method: String + +- **InvoiceStatus**: Enum for invoice status + - Variants: Draft, Sent, Paid, Overdue, Cancelled + +- **PaymentStatus**: Enum for payment status + - Variants: Unpaid, PartiallyPaid, Paid + +### 5. Updates to mod.rs + +We'll need to update the mod.rs file to include the new modules and re-export the types: + +```rust +pub mod currency; +pub mod product; +pub mod sale; +pub mod exchange_rate; +pub mod service; +pub mod customer; +pub mod contract; +pub mod invoice; + +// Re-export all model types for convenience +pub use product::{Product, ProductComponent, ProductType, ProductStatus}; +pub use sale::{Sale, SaleItem, SaleStatus}; +pub use currency::Currency; +pub use exchange_rate::{ExchangeRate, ExchangeRateService, EXCHANGE_RATE_SERVICE}; +pub use service::{Service, ServiceItem, ServiceStatus, BillingFrequency}; +pub use customer::Customer; +pub use contract::{Contract, ContractStatus}; +pub use invoice::{Invoice, InvoiceItem, InvoiceStatus, PaymentStatus, Payment}; + +// Re-export builder types +pub use product::{ProductBuilder, ProductComponentBuilder}; +pub use sale::{SaleBuilder, SaleItemBuilder}; +pub use currency::CurrencyBuilder; +pub use exchange_rate::ExchangeRateBuilder; +pub use service::{ServiceBuilder, ServiceItemBuilder}; +pub use customer::CustomerBuilder; +pub use contract::ContractBuilder; +pub use invoice::{InvoiceBuilder, InvoiceItemBuilder}; +``` + +### 6. Updates to model_methods.rs + +We'll need to update the model_methods.rs file to implement the model methods for the new models: + +```rust +use crate::db::db::DB; +use crate::db::base::{SledDBResult, SledModel}; +use crate::impl_model_methods; +use crate::models::biz::{Product, Sale, Currency, ExchangeRate, Service, Customer, Contract, Invoice}; + +// Implement model-specific methods for Product +impl_model_methods!(Product, product, products); + +// Implement model-specific methods for Sale +impl_model_methods!(Sale, sale, sales); + +// Implement model-specific methods for Currency +impl_model_methods!(Currency, currency, currencies); + +// Implement model-specific methods for ExchangeRate +impl_model_methods!(ExchangeRate, exchange_rate, exchange_rates); + +// Implement model-specific methods for Service +impl_model_methods!(Service, service, services); + +// Implement model-specific methods for Customer +impl_model_methods!(Customer, customer, customers); + +// Implement model-specific methods for Contract +impl_model_methods!(Contract, contract, contracts); + +// Implement model-specific methods for Invoice +impl_model_methods!(Invoice, invoice, invoices); +``` + +## Implementation Approach + +1. Create the new model files (service.rs, customer.rs, contract.rs, invoice.rs) +2. Implement the structs, enums, and methods for each model +3. Update mod.rs to include the new modules and re-export the types +4. Update model_methods.rs to implement the model methods for the new models +5. Test the new models with example code \ No newline at end of file diff --git a/herodb/src/models/biz/contract.rs b/herodb/src/models/biz/contract.rs new file mode 100644 index 0000000..e4c4dcc --- /dev/null +++ b/herodb/src/models/biz/contract.rs @@ -0,0 +1,250 @@ +use crate::db::base::{SledModel, Storable}; // Import Sled traits from db module +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// ContractStatus represents the status of a contract +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ContractStatus { + Active, + Expired, + Terminated, +} + +/// Contract represents a legal agreement between a customer and the business +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Contract { + pub id: u32, + pub customer_id: u32, + pub service_id: Option, + pub sale_id: Option, + pub terms: String, + pub start_date: DateTime, + pub end_date: DateTime, + pub auto_renewal: bool, + pub renewal_terms: String, + pub status: ContractStatus, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Contract { + /// Create a new contract with default timestamps + pub fn new( + id: u32, + customer_id: u32, + terms: String, + start_date: DateTime, + end_date: DateTime, + auto_renewal: bool, + renewal_terms: String, + ) -> Self { + let now = Utc::now(); + Self { + id, + customer_id, + service_id: None, + sale_id: None, + terms, + start_date, + end_date, + auto_renewal, + renewal_terms, + status: ContractStatus::Active, + created_at: now, + updated_at: now, + } + } + + /// Link the contract to a service + pub fn link_to_service(&mut self, service_id: u32) { + self.service_id = Some(service_id); + self.sale_id = None; // A contract can only be linked to either a service or a sale + self.updated_at = Utc::now(); + } + + /// Link the contract to a sale + pub fn link_to_sale(&mut self, sale_id: u32) { + self.sale_id = Some(sale_id); + self.service_id = None; // A contract can only be linked to either a service or a sale + self.updated_at = Utc::now(); + } + + /// Check if the contract is currently active + pub fn is_active(&self) -> bool { + let now = Utc::now(); + self.status == ContractStatus::Active && + now >= self.start_date && + now <= self.end_date + } + + /// Check if the contract has expired + pub fn is_expired(&self) -> bool { + let now = Utc::now(); + now > self.end_date + } + + /// Update the contract status + pub fn update_status(&mut self, status: ContractStatus) { + self.status = status; + self.updated_at = Utc::now(); + } + + /// Renew the contract based on renewal terms + pub fn renew(&mut self) -> Result<(), &'static str> { + if !self.auto_renewal { + return Err("Contract is not set for auto-renewal"); + } + + if self.status != ContractStatus::Active { + return Err("Cannot renew a non-active contract"); + } + + // Calculate new dates based on the current end date + let duration = self.end_date - self.start_date; + self.start_date = self.end_date; + self.end_date = self.end_date + duration; + + self.updated_at = Utc::now(); + Ok(()) + } +} + +/// Builder for Contract +pub struct ContractBuilder { + id: Option, + customer_id: Option, + service_id: Option, + sale_id: Option, + terms: Option, + start_date: Option>, + end_date: Option>, + auto_renewal: Option, + renewal_terms: Option, + status: Option, + created_at: Option>, + updated_at: Option>, +} + +impl ContractBuilder { + /// Create a new ContractBuilder with all fields set to None + pub fn new() -> Self { + Self { + id: None, + customer_id: None, + service_id: None, + sale_id: None, + terms: None, + start_date: None, + end_date: None, + auto_renewal: None, + renewal_terms: None, + status: None, + created_at: None, + updated_at: None, + } + } + + /// Set the id + pub fn id(mut self, id: u32) -> Self { + self.id = Some(id); + self + } + + /// Set the customer_id + pub fn customer_id(mut self, customer_id: u32) -> Self { + self.customer_id = Some(customer_id); + self + } + + /// Set the service_id + pub fn service_id(mut self, service_id: u32) -> Self { + self.service_id = Some(service_id); + self.sale_id = None; // A contract can only be linked to either a service or a sale + self + } + + /// Set the sale_id + pub fn sale_id(mut self, sale_id: u32) -> Self { + self.sale_id = Some(sale_id); + self.service_id = None; // A contract can only be linked to either a service or a sale + self + } + + /// Set the terms + pub fn terms>(mut self, terms: S) -> Self { + self.terms = Some(terms.into()); + self + } + + /// Set the start_date + pub fn start_date(mut self, start_date: DateTime) -> Self { + self.start_date = Some(start_date); + self + } + + /// Set the end_date + pub fn end_date(mut self, end_date: DateTime) -> Self { + self.end_date = Some(end_date); + self + } + + /// Set auto_renewal + pub fn auto_renewal(mut self, auto_renewal: bool) -> Self { + self.auto_renewal = Some(auto_renewal); + self + } + + /// Set the renewal_terms + pub fn renewal_terms>(mut self, renewal_terms: S) -> Self { + self.renewal_terms = Some(renewal_terms.into()); + self + } + + /// Set the status + pub fn status(mut self, status: ContractStatus) -> Self { + self.status = Some(status); + self + } + + /// Build the Contract object + pub fn build(self) -> Result { + let now = Utc::now(); + + // Validate that start_date is before end_date + let start_date = self.start_date.ok_or("start_date is required")?; + let end_date = self.end_date.ok_or("end_date is required")?; + + if start_date >= end_date { + return Err("start_date must be before end_date"); + } + + Ok(Contract { + id: self.id.ok_or("id is required")?, + customer_id: self.customer_id.ok_or("customer_id is required")?, + service_id: self.service_id, + sale_id: self.sale_id, + terms: self.terms.ok_or("terms is required")?, + start_date, + end_date, + auto_renewal: self.auto_renewal.unwrap_or(false), + renewal_terms: self.renewal_terms.ok_or("renewal_terms is required")?, + status: self.status.unwrap_or(ContractStatus::Active), + created_at: self.created_at.unwrap_or(now), + updated_at: self.updated_at.unwrap_or(now), + }) + } +} + +// Implement Storable trait (provides default dump/load) +impl Storable for Contract {} + +// Implement SledModel trait +impl SledModel for Contract { + fn get_id(&self) -> String { + self.id.to_string() + } + + fn db_prefix() -> &'static str { + "contract" + } +} \ No newline at end of file diff --git a/herodb/src/models/biz/customer.rs b/herodb/src/models/biz/customer.rs new file mode 100644 index 0000000..757b763 --- /dev/null +++ b/herodb/src/models/biz/customer.rs @@ -0,0 +1,148 @@ +use crate::db::base::{SledModel, Storable}; // Import Sled traits from db module +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Customer represents a customer who can purchase products or services +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Customer { + pub id: u32, + pub name: String, + pub description: String, + pub pubkey: String, + pub contact_ids: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Customer { + /// Create a new customer with default timestamps + pub fn new( + id: u32, + name: String, + description: String, + pubkey: String, + ) -> Self { + let now = Utc::now(); + Self { + id, + name, + description, + pubkey, + contact_ids: Vec::new(), + created_at: now, + updated_at: now, + } + } + + /// Add a contact ID to the customer + pub fn add_contact(&mut self, contact_id: u32) { + if !self.contact_ids.contains(&contact_id) { + self.contact_ids.push(contact_id); + self.updated_at = Utc::now(); + } + } + + /// Remove a contact ID from the customer + pub fn remove_contact(&mut self, contact_id: u32) -> bool { + let len = self.contact_ids.len(); + self.contact_ids.retain(|&id| id != contact_id); + + if self.contact_ids.len() < len { + self.updated_at = Utc::now(); + true + } else { + false + } + } +} + +/// Builder for Customer +pub struct CustomerBuilder { + id: Option, + name: Option, + description: Option, + pubkey: Option, + contact_ids: Vec, + created_at: Option>, + updated_at: Option>, +} + +impl CustomerBuilder { + /// Create a new CustomerBuilder with all fields set to None + pub fn new() -> Self { + Self { + id: None, + name: None, + description: None, + pubkey: None, + contact_ids: Vec::new(), + created_at: None, + updated_at: None, + } + } + + /// Set the id + pub fn id(mut self, id: u32) -> Self { + self.id = Some(id); + self + } + + /// Set the name + pub fn name>(mut self, name: S) -> Self { + self.name = Some(name.into()); + self + } + + /// Set the description + pub fn description>(mut self, description: S) -> Self { + self.description = Some(description.into()); + self + } + + /// Set the pubkey + pub fn pubkey>(mut self, pubkey: S) -> Self { + self.pubkey = Some(pubkey.into()); + self + } + + /// Add a contact ID + pub fn add_contact(mut self, contact_id: u32) -> Self { + self.contact_ids.push(contact_id); + self + } + + /// Set multiple contact IDs + pub fn contact_ids(mut self, contact_ids: Vec) -> Self { + self.contact_ids = contact_ids; + self + } + + /// Build the Customer object + pub fn build(self) -> Result { + let now = Utc::now(); + + Ok(Customer { + id: self.id.ok_or("id is required")?, + name: self.name.ok_or("name is required")?, + description: self.description.ok_or("description is required")?, + pubkey: self.pubkey.ok_or("pubkey is required")?, + contact_ids: self.contact_ids, + created_at: self.created_at.unwrap_or(now), + updated_at: self.updated_at.unwrap_or(now), + }) + } +} + +// Implement Storable trait (provides default dump/load) +impl Storable for Customer {} + +// Implement SledModel trait +impl SledModel for Customer { + fn get_id(&self) -> String { + self.id.to_string() + } + + fn db_prefix() -> &'static str { + "customer" + } +} \ No newline at end of file diff --git a/herodb/src/models/biz/invoice.rs b/herodb/src/models/biz/invoice.rs new file mode 100644 index 0000000..e4030c1 --- /dev/null +++ b/herodb/src/models/biz/invoice.rs @@ -0,0 +1,507 @@ +use crate::models::biz::Currency; // Use crate:: for importing from the module +use crate::db::base::{SledModel, Storable}; // Import Sled traits from db module +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// InvoiceStatus represents the status of an invoice +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum InvoiceStatus { + Draft, + Sent, + Paid, + Overdue, + Cancelled, +} + +/// PaymentStatus represents the payment status of an invoice +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PaymentStatus { + Unpaid, + PartiallyPaid, + Paid, +} + +/// Payment represents a payment made against an invoice +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Payment { + pub amount: Currency, + pub date: DateTime, + pub method: String, +} + +impl Payment { + /// Create a new payment + pub fn new(amount: Currency, method: String) -> Self { + Self { + amount, + date: Utc::now(), + method, + } + } +} + +/// InvoiceItem represents an item in an invoice +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InvoiceItem { + pub id: u32, + pub invoice_id: u32, + pub description: String, + pub amount: Currency, + pub service_id: Option, + pub sale_id: Option, +} + +impl InvoiceItem { + /// Create a new invoice item + pub fn new( + id: u32, + invoice_id: u32, + description: String, + amount: Currency, + ) -> Self { + Self { + id, + invoice_id, + description, + amount, + service_id: None, + sale_id: None, + } + } + + /// Link the invoice item to a service + pub fn link_to_service(&mut self, service_id: u32) { + self.service_id = Some(service_id); + self.sale_id = None; // An invoice item can only be linked to either a service or a sale + } + + /// Link the invoice item to a sale + pub fn link_to_sale(&mut self, sale_id: u32) { + self.sale_id = Some(sale_id); + self.service_id = None; // An invoice item can only be linked to either a service or a sale + } +} + +/// Builder for InvoiceItem +pub struct InvoiceItemBuilder { + id: Option, + invoice_id: Option, + description: Option, + amount: Option, + service_id: Option, + sale_id: Option, +} + +impl InvoiceItemBuilder { + /// Create a new InvoiceItemBuilder with all fields set to None + pub fn new() -> Self { + Self { + id: None, + invoice_id: None, + description: None, + amount: None, + service_id: None, + sale_id: None, + } + } + + /// Set the id + pub fn id(mut self, id: u32) -> Self { + self.id = Some(id); + self + } + + /// Set the invoice_id + pub fn invoice_id(mut self, invoice_id: u32) -> Self { + self.invoice_id = Some(invoice_id); + self + } + + /// Set the description + pub fn description>(mut self, description: S) -> Self { + self.description = Some(description.into()); + self + } + + /// Set the amount + pub fn amount(mut self, amount: Currency) -> Self { + self.amount = Some(amount); + self + } + + /// Set the service_id + pub fn service_id(mut self, service_id: u32) -> Self { + self.service_id = Some(service_id); + self.sale_id = None; // An invoice item can only be linked to either a service or a sale + self + } + + /// Set the sale_id + pub fn sale_id(mut self, sale_id: u32) -> Self { + self.sale_id = Some(sale_id); + self.service_id = None; // An invoice item can only be linked to either a service or a sale + self + } + + /// Build the InvoiceItem object + pub fn build(self) -> Result { + Ok(InvoiceItem { + id: self.id.ok_or("id is required")?, + invoice_id: self.invoice_id.ok_or("invoice_id is required")?, + description: self.description.ok_or("description is required")?, + amount: self.amount.ok_or("amount is required")?, + service_id: self.service_id, + sale_id: self.sale_id, + }) + } +} + +/// Invoice represents an invoice sent to a customer +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Invoice { + pub id: u32, + pub customer_id: u32, + pub total_amount: Currency, + pub balance_due: Currency, + pub status: InvoiceStatus, + pub payment_status: PaymentStatus, + pub issue_date: DateTime, + pub due_date: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, + pub items: Vec, + pub payments: Vec, +} + +impl Invoice { + /// Create a new invoice with default timestamps + pub fn new( + id: u32, + customer_id: u32, + currency_code: String, + issue_date: DateTime, + due_date: DateTime, + ) -> Self { + let now = Utc::now(); + let zero_amount = Currency { + amount: 0.0, + currency_code: currency_code.clone(), + }; + + Self { + id, + customer_id, + total_amount: zero_amount.clone(), + balance_due: zero_amount, + status: InvoiceStatus::Draft, + payment_status: PaymentStatus::Unpaid, + issue_date, + due_date, + created_at: now, + updated_at: now, + items: Vec::new(), + payments: Vec::new(), + } + } + + /// Add an item to the invoice and update the total amount + pub fn add_item(&mut self, item: InvoiceItem) { + // Make sure the item's invoice_id matches this invoice + assert_eq!(self.id, item.invoice_id, "Item invoice_id must match invoice id"); + + // Update the total amount + if self.items.is_empty() { + // First item, initialize the total amount with the same currency + self.total_amount = Currency { + amount: item.amount.amount, + currency_code: item.amount.currency_code.clone(), + }; + self.balance_due = Currency { + amount: item.amount.amount, + currency_code: item.amount.currency_code.clone(), + }; + } else { + // Add to the existing total + // (Assumes all items have the same currency) + self.total_amount.amount += item.amount.amount; + self.balance_due.amount += item.amount.amount; + } + + // Add the item to the list + self.items.push(item); + + // Update the invoice timestamp + self.updated_at = Utc::now(); + } + + /// Calculate the total amount based on all items + pub fn calculate_total(&mut self) { + if self.items.is_empty() { + return; + } + + // Get the currency code from the first item + let currency_code = self.items[0].amount.currency_code.clone(); + + // Calculate the total amount + let mut total = 0.0; + for item in &self.items { + total += item.amount.amount; + } + + // Update the total amount + self.total_amount = Currency { + amount: total, + currency_code: currency_code.clone(), + }; + + // Recalculate the balance due + self.calculate_balance(); + + // Update the invoice timestamp + self.updated_at = Utc::now(); + } + + /// Add a payment to the invoice and update the balance due and payment status + pub fn add_payment(&mut self, payment: Payment) { + // Update the balance due + self.balance_due.amount -= payment.amount.amount; + + // Add the payment to the list + self.payments.push(payment); + + // Update the payment status + self.update_payment_status(); + + // Update the invoice timestamp + self.updated_at = Utc::now(); + } + + /// Calculate the balance due based on total amount and payments + pub fn calculate_balance(&mut self) { + // Start with the total amount + let mut balance = self.total_amount.amount; + + // Subtract all payments + for payment in &self.payments { + balance -= payment.amount.amount; + } + + // Update the balance due + self.balance_due = Currency { + amount: balance, + currency_code: self.total_amount.currency_code.clone(), + }; + + // Update the payment status + self.update_payment_status(); + } + + /// Update the payment status based on the balance due + fn update_payment_status(&mut self) { + if self.balance_due.amount <= 0.0 { + self.payment_status = PaymentStatus::Paid; + // If fully paid, also update the invoice status + if self.status != InvoiceStatus::Cancelled { + self.status = InvoiceStatus::Paid; + } + } else if self.payments.is_empty() { + self.payment_status = PaymentStatus::Unpaid; + } else { + self.payment_status = PaymentStatus::PartiallyPaid; + } + } + + /// Update the status of the invoice + pub fn update_status(&mut self, status: InvoiceStatus) { + self.status = status; + self.updated_at = Utc::now(); + + // If the invoice is cancelled, don't change the payment status + if status != InvoiceStatus::Cancelled { + // Re-evaluate the payment status + self.update_payment_status(); + } + } + + /// Check if the invoice is overdue + pub fn is_overdue(&self) -> bool { + let now = Utc::now(); + self.payment_status != PaymentStatus::Paid && + now > self.due_date && + self.status != InvoiceStatus::Cancelled + } + + /// Mark the invoice as overdue if it's past the due date + pub fn check_if_overdue(&mut self) -> bool { + if self.is_overdue() && self.status != InvoiceStatus::Overdue { + self.status = InvoiceStatus::Overdue; + self.updated_at = Utc::now(); + true + } else { + false + } + } +} + +/// Builder for Invoice +pub struct InvoiceBuilder { + id: Option, + customer_id: Option, + total_amount: Option, + balance_due: Option, + status: Option, + payment_status: Option, + issue_date: Option>, + due_date: Option>, + created_at: Option>, + updated_at: Option>, + items: Vec, + payments: Vec, + currency_code: Option, +} + +impl InvoiceBuilder { + /// Create a new InvoiceBuilder with all fields set to None + pub fn new() -> Self { + Self { + id: None, + customer_id: None, + total_amount: None, + balance_due: None, + status: None, + payment_status: None, + issue_date: None, + due_date: None, + created_at: None, + updated_at: None, + items: Vec::new(), + payments: Vec::new(), + currency_code: None, + } + } + + /// Set the id + pub fn id(mut self, id: u32) -> Self { + self.id = Some(id); + self + } + + /// Set the customer_id + pub fn customer_id(mut self, customer_id: u32) -> Self { + self.customer_id = Some(customer_id); + self + } + + /// Set the currency_code + pub fn currency_code>(mut self, currency_code: S) -> Self { + self.currency_code = Some(currency_code.into()); + self + } + + /// Set the status + pub fn status(mut self, status: InvoiceStatus) -> Self { + self.status = Some(status); + self + } + + /// Set the issue_date + pub fn issue_date(mut self, issue_date: DateTime) -> Self { + self.issue_date = Some(issue_date); + self + } + + /// Set the due_date + pub fn due_date(mut self, due_date: DateTime) -> Self { + self.due_date = Some(due_date); + self + } + + /// Add an item to the invoice + pub fn add_item(mut self, item: InvoiceItem) -> Self { + self.items.push(item); + self + } + + /// Add a payment to the invoice + pub fn add_payment(mut self, payment: Payment) -> Self { + self.payments.push(payment); + self + } + + /// Build the Invoice object + pub fn build(self) -> Result { + let now = Utc::now(); + let id = self.id.ok_or("id is required")?; + let currency_code = self.currency_code.ok_or("currency_code is required")?; + + // Initialize with empty total amount and balance due + let mut total_amount = Currency { + amount: 0.0, + currency_code: currency_code.clone(), + }; + + // Calculate total amount from items + for item in &self.items { + // Make sure the item's invoice_id matches this invoice + if item.invoice_id != id { + return Err("Item invoice_id must match invoice id"); + } + + total_amount.amount += item.amount.amount; + } + + // Calculate balance due (total minus payments) + let mut balance_due = total_amount.clone(); + for payment in &self.payments { + balance_due.amount -= payment.amount.amount; + } + + // Determine payment status + let payment_status = if balance_due.amount <= 0.0 { + PaymentStatus::Paid + } else if self.payments.is_empty() { + PaymentStatus::Unpaid + } else { + PaymentStatus::PartiallyPaid + }; + + // Determine invoice status if not provided + let status = if let Some(status) = self.status { + status + } else if payment_status == PaymentStatus::Paid { + InvoiceStatus::Paid + } else { + InvoiceStatus::Draft + }; + + Ok(Invoice { + id, + customer_id: self.customer_id.ok_or("customer_id is required")?, + total_amount: self.total_amount.unwrap_or(total_amount), + balance_due: self.balance_due.unwrap_or(balance_due), + status, + payment_status, + issue_date: self.issue_date.ok_or("issue_date is required")?, + due_date: self.due_date.ok_or("due_date is required")?, + created_at: self.created_at.unwrap_or(now), + updated_at: self.updated_at.unwrap_or(now), + items: self.items, + payments: self.payments, + }) + } +} + +// Implement Storable trait (provides default dump/load) +impl Storable for Invoice {} + +// Implement SledModel trait +impl SledModel for Invoice { + fn get_id(&self) -> String { + self.id.to_string() + } + + fn db_prefix() -> &'static str { + "invoice" + } +} \ No newline at end of file diff --git a/herodb/src/models/biz/mod.rs b/herodb/src/models/biz/mod.rs index edf6306..219a1c4 100644 --- a/herodb/src/models/biz/mod.rs +++ b/herodb/src/models/biz/mod.rs @@ -2,15 +2,27 @@ pub mod currency; pub mod product; pub mod sale; pub mod exchange_rate; +pub mod service; +pub mod customer; +pub mod contract; +pub mod invoice; // Re-export all model types for convenience pub use product::{Product, ProductComponent, ProductType, ProductStatus}; pub use sale::{Sale, SaleItem, SaleStatus}; pub use currency::Currency; pub use exchange_rate::{ExchangeRate, ExchangeRateService, EXCHANGE_RATE_SERVICE}; +pub use service::{Service, ServiceItem, ServiceStatus, BillingFrequency}; +pub use customer::Customer; +pub use contract::{Contract, ContractStatus}; +pub use invoice::{Invoice, InvoiceItem, InvoiceStatus, PaymentStatus, Payment}; // Re-export builder types pub use product::{ProductBuilder, ProductComponentBuilder}; pub use sale::{SaleBuilder, SaleItemBuilder}; pub use currency::CurrencyBuilder; -pub use exchange_rate::ExchangeRateBuilder; \ No newline at end of file +pub use exchange_rate::ExchangeRateBuilder; +pub use service::{ServiceBuilder, ServiceItemBuilder}; +pub use customer::CustomerBuilder; +pub use contract::ContractBuilder; +pub use invoice::{InvoiceBuilder, InvoiceItemBuilder}; \ No newline at end of file diff --git a/herodb/src/models/biz/service.rs b/herodb/src/models/biz/service.rs new file mode 100644 index 0000000..2c1eb05 --- /dev/null +++ b/herodb/src/models/biz/service.rs @@ -0,0 +1,469 @@ +use crate::models::biz::Currency; // Use crate:: for importing from the module +use crate::db::base::{SledModel, Storable}; // Import Sled traits from db module +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// BillingFrequency represents the frequency of billing for a service +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BillingFrequency { + Hourly, + Daily, + Weekly, + Monthly, + Yearly, +} + +/// ServiceStatus represents the status of a service +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ServiceStatus { + Active, + Paused, + Cancelled, + Completed, +} + +/// ServiceItem represents an item in a service +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceItem { + pub id: u32, + pub service_id: u32, + pub product_id: u32, + pub name: String, + 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, +} + +impl ServiceItem { + /// Create a new service item + pub fn new( + id: u32, + service_id: u32, + product_id: u32, + name: String, + quantity: i32, + unit_price: Currency, + tax_rate: f64, + is_taxable: bool, + active_till: DateTime, + ) -> Self { + // Calculate subtotal + let amount = unit_price.amount * quantity as f64; + let subtotal = Currency { + amount, + currency_code: unit_price.currency_code.clone(), + }; + + // Calculate tax amount if taxable + let tax_amount = if is_taxable { + Currency { + amount: subtotal.amount * tax_rate, + currency_code: unit_price.currency_code.clone(), + } + } else { + Currency { + amount: 0.0, + currency_code: unit_price.currency_code.clone(), + } + }; + + Self { + id, + service_id, + product_id, + name, + quantity, + unit_price, + subtotal, + tax_rate, + tax_amount, + is_taxable, + active_till, + } + } + + /// Calculate the subtotal based on quantity and unit price + pub fn calculate_subtotal(&mut self) { + let amount = self.unit_price.amount * self.quantity as f64; + self.subtotal = Currency { + amount, + currency_code: self.unit_price.currency_code.clone(), + }; + } + + /// Calculate the tax amount based on subtotal and tax rate + pub fn calculate_tax(&mut self) { + if self.is_taxable { + self.tax_amount = Currency { + amount: self.subtotal.amount * self.tax_rate, + currency_code: self.subtotal.currency_code.clone(), + }; + } else { + self.tax_amount = Currency { + amount: 0.0, + currency_code: self.subtotal.currency_code.clone(), + }; + } + } +} + +/// Builder for ServiceItem +pub struct ServiceItemBuilder { + id: Option, + service_id: Option, + product_id: Option, + name: Option, + quantity: Option, + unit_price: Option, + subtotal: Option, + tax_rate: Option, + tax_amount: Option, + is_taxable: Option, + active_till: Option>, +} + +impl ServiceItemBuilder { + /// Create a new ServiceItemBuilder with all fields set to None + pub fn new() -> Self { + Self { + id: None, + service_id: None, + product_id: None, + name: None, + quantity: None, + unit_price: None, + subtotal: None, + tax_rate: None, + tax_amount: None, + is_taxable: None, + active_till: None, + } + } + + /// Set the id + pub fn id(mut self, id: u32) -> Self { + self.id = Some(id); + self + } + + /// Set the service_id + pub fn service_id(mut self, service_id: u32) -> Self { + self.service_id = Some(service_id); + self + } + + /// Set the product_id + pub fn product_id(mut self, product_id: u32) -> Self { + self.product_id = Some(product_id); + self + } + + /// Set the name + pub fn name>(mut self, name: S) -> Self { + self.name = Some(name.into()); + self + } + + /// Set the quantity + pub fn quantity(mut self, quantity: i32) -> Self { + self.quantity = Some(quantity); + self + } + + /// Set the unit_price + pub fn unit_price(mut self, unit_price: Currency) -> Self { + self.unit_price = Some(unit_price); + self + } + + /// Set the tax_rate + pub fn tax_rate(mut self, tax_rate: f64) -> Self { + self.tax_rate = Some(tax_rate); + self + } + + /// Set is_taxable + pub fn is_taxable(mut self, is_taxable: bool) -> Self { + self.is_taxable = Some(is_taxable); + self + } + + /// Set the active_till + pub fn active_till(mut self, active_till: DateTime) -> Self { + self.active_till = Some(active_till); + self + } + + /// Build the ServiceItem object + pub fn build(self) -> Result { + let unit_price = self.unit_price.ok_or("unit_price is required")?; + let quantity = self.quantity.ok_or("quantity is required")?; + let tax_rate = self.tax_rate.unwrap_or(0.0); + let is_taxable = self.is_taxable.unwrap_or(false); + + // Calculate subtotal + let amount = unit_price.amount * quantity as f64; + let subtotal = Currency { + amount, + currency_code: unit_price.currency_code.clone(), + }; + + // Calculate tax amount if taxable + let tax_amount = if is_taxable { + Currency { + amount: subtotal.amount * tax_rate, + currency_code: unit_price.currency_code.clone(), + } + } else { + Currency { + amount: 0.0, + currency_code: unit_price.currency_code.clone(), + } + }; + + Ok(ServiceItem { + id: self.id.ok_or("id is required")?, + service_id: self.service_id.ok_or("service_id is required")?, + product_id: self.product_id.ok_or("product_id is required")?, + name: self.name.ok_or("name is required")?, + quantity, + unit_price, + subtotal, + tax_rate, + tax_amount, + is_taxable, + active_till: self.active_till.ok_or("active_till is required")?, + }) + } +} + +/// Service represents a recurring service with billing frequency +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Service { + pub id: u32, + pub customer_id: u32, + pub total_amount: Currency, + pub status: ServiceStatus, + pub billing_frequency: BillingFrequency, + pub service_date: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, + pub items: Vec, +} + +impl Service { + /// Create a new service with default timestamps + pub fn new( + id: u32, + customer_id: u32, + currency_code: String, + status: ServiceStatus, + billing_frequency: BillingFrequency, + ) -> Self { + let now = Utc::now(); + Self { + id, + customer_id, + total_amount: Currency { amount: 0.0, currency_code }, + status, + billing_frequency, + service_date: now, + created_at: now, + updated_at: now, + items: Vec::new(), + } + } + + /// Add an item to the service and update the total amount + pub fn add_item(&mut self, item: ServiceItem) { + // Make sure the item's service_id matches this service + assert_eq!(self.id, item.service_id, "Item service_id must match service id"); + + // Update the total amount + if self.items.is_empty() { + // First item, initialize the total amount with the same currency + self.total_amount = Currency { + amount: item.subtotal.amount + item.tax_amount.amount, + currency_code: 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; + } + + // Add the item to the list + self.items.push(item); + + // Update the service timestamp + self.updated_at = Utc::now(); + } + + /// Calculate the total amount based on all items + pub fn calculate_total(&mut self) { + if self.items.is_empty() { + return; + } + + // Get the currency code from the first item + let currency_code = self.items[0].subtotal.currency_code.clone(); + + // Calculate the total amount + let mut total = 0.0; + for item in &self.items { + total += item.subtotal.amount + item.tax_amount.amount; + } + + // Update the total amount + self.total_amount = Currency { + amount: total, + currency_code, + }; + + // Update the service timestamp + self.updated_at = Utc::now(); + } + + /// Update the status of the service + pub fn update_status(&mut self, status: ServiceStatus) { + self.status = status; + self.updated_at = Utc::now(); + } +} + +/// Builder for Service +pub struct ServiceBuilder { + id: Option, + customer_id: Option, + total_amount: Option, + status: Option, + billing_frequency: Option, + service_date: Option>, + created_at: Option>, + updated_at: Option>, + items: Vec, + currency_code: Option, +} + +impl ServiceBuilder { + /// Create a new ServiceBuilder with all fields set to None + pub fn new() -> Self { + Self { + id: None, + customer_id: None, + total_amount: None, + status: None, + billing_frequency: None, + service_date: None, + created_at: None, + updated_at: None, + items: Vec::new(), + currency_code: None, + } + } + + /// Set the id + pub fn id(mut self, id: u32) -> Self { + self.id = Some(id); + self + } + + /// Set the customer_id + pub fn customer_id(mut self, customer_id: u32) -> Self { + self.customer_id = Some(customer_id); + self + } + + /// Set the currency_code + pub fn currency_code>(mut self, currency_code: S) -> Self { + self.currency_code = Some(currency_code.into()); + self + } + + /// Set the status + pub fn status(mut self, status: ServiceStatus) -> Self { + self.status = Some(status); + self + } + + /// Set the billing_frequency + pub fn billing_frequency(mut self, billing_frequency: BillingFrequency) -> Self { + self.billing_frequency = Some(billing_frequency); + self + } + + /// Set the service_date + pub fn service_date(mut self, service_date: DateTime) -> Self { + self.service_date = Some(service_date); + self + } + + /// Add an item to the service + pub fn add_item(mut self, item: ServiceItem) -> Self { + self.items.push(item); + self + } + + /// Build the Service object + pub fn build(self) -> Result { + let now = Utc::now(); + let id = self.id.ok_or("id is required")?; + let currency_code = self.currency_code.ok_or("currency_code is required")?; + + // Initialize with empty total amount + let mut total_amount = Currency { + amount: 0.0, + currency_code: currency_code.clone(), + }; + + // Calculate total amount from items + for item in &self.items { + // Make sure the item's service_id matches this service + if item.service_id != id { + return Err("Item service_id must match service id"); + } + + if total_amount.amount == 0.0 { + // First item, initialize the total amount with the same currency + total_amount = Currency { + amount: item.subtotal.amount + item.tax_amount.amount, + currency_code: 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; + } + } + + Ok(Service { + id, + customer_id: self.customer_id.ok_or("customer_id is required")?, + total_amount: self.total_amount.unwrap_or(total_amount), + status: self.status.ok_or("status is required")?, + billing_frequency: self.billing_frequency.ok_or("billing_frequency is required")?, + service_date: self.service_date.unwrap_or(now), + created_at: self.created_at.unwrap_or(now), + updated_at: self.updated_at.unwrap_or(now), + items: self.items, + }) + } +} + +// Implement Storable trait (provides default dump/load) +impl Storable for Service {} + +// Implement SledModel trait +impl SledModel for Service { + fn get_id(&self) -> String { + self.id.to_string() + } + + fn db_prefix() -> &'static str { + "service" + } +} \ No newline at end of file