diff --git a/Cargo.toml b/Cargo.toml index e027719..10592a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/synor-privacy", "crates/synor-sharding", "crates/synor-verifier", + "crates/synor-economics", "crates/synor-sdk", "crates/synor-contract-test", "crates/synor-compiler", diff --git a/crates/synor-economics/Cargo.toml b/crates/synor-economics/Cargo.toml new file mode 100644 index 0000000..b5f56b4 --- /dev/null +++ b/crates/synor-economics/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "synor-economics" +version = "0.1.0" +edition = "2021" +description = "Economics, pricing, metering, and billing for Synor L2 services" +license = "MIT OR Apache-2.0" +repository = "https://github.com/synor/blockchain" + +[dependencies] +# Internal crates +synor-types = { path = "../synor-types" } + +# Async runtime +tokio = { version = "1.40", features = ["full"] } +futures = "0.3" +async-trait = "0.1" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Time handling +chrono = { version = "0.4", features = ["serde"] } + +# Decimal math for financial precision +rust_decimal = { version = "1.33", features = ["serde"] } +rust_decimal_macros = "1.33" + +# Cryptographic hashing +sha2 = "0.10" +blake3 = "1.5" + +# Error handling +thiserror = "1.0" +anyhow = "1.0" + +# Logging +tracing = "0.1" + +# Database/storage +rocksdb = { version = "0.22", optional = true } + +# HTTP client for external price feeds +reqwest = { version = "0.11", features = ["json"], optional = true } + +# Event streaming +async-channel = "2.1" + +[dev-dependencies] +tokio-test = "0.4" +criterion = "0.5" +tempfile = "3.10" + +[features] +default = ["http-feeds"] +http-feeds = ["reqwest"] +persistent = ["rocksdb"] + diff --git a/crates/synor-economics/src/billing/credit.rs b/crates/synor-economics/src/billing/credit.rs new file mode 100644 index 0000000..e555afb --- /dev/null +++ b/crates/synor-economics/src/billing/credit.rs @@ -0,0 +1,338 @@ +//! Credit Management +//! +//! Promotional credits, SLA credits, and referral rewards. + +use crate::{AccountId, SynorDecimal}; +use chrono::{DateTime, Duration, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Credit type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CreditType { + /// Welcome bonus for new accounts + Welcome, + /// Promotional campaign + Promotional, + /// Referral reward + Referral, + /// SLA violation compensation + SlaCompensation, + /// Support ticket resolution + Support, + /// Bug bounty reward + BugBounty, + /// Manual adjustment + Manual, +} + +impl std::fmt::Display for CreditType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CreditType::Welcome => write!(f, "Welcome Bonus"), + CreditType::Promotional => write!(f, "Promotional"), + CreditType::Referral => write!(f, "Referral Reward"), + CreditType::SlaCompensation => write!(f, "SLA Compensation"), + CreditType::Support => write!(f, "Support Credit"), + CreditType::BugBounty => write!(f, "Bug Bounty"), + CreditType::Manual => write!(f, "Manual Adjustment"), + } + } +} + +/// Account credit record +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Credit { + /// Unique credit ID + pub id: String, + /// Account receiving the credit + pub account_id: AccountId, + /// Credit amount in SYNOR + pub amount: SynorDecimal, + /// Original amount (before any usage) + pub original_amount: SynorDecimal, + /// Amount used + pub used_amount: SynorDecimal, + /// Credit type + pub credit_type: CreditType, + /// Reason/description + pub reason: String, + /// Date created + pub created_at: DateTime, + /// Expiration date (optional) + pub expires_at: Option>, + /// Whether the credit has been fully used or expired + pub is_active: bool, + /// Reference ID (e.g., referral code, ticket number) + pub reference_id: Option, + /// Approved by (admin user) + pub approved_by: Option, +} + +impl Credit { + /// Create a new credit + pub fn new( + account_id: impl Into, + amount: SynorDecimal, + reason: impl Into, + ) -> Self { + Self { + id: generate_credit_id(), + account_id: account_id.into(), + amount, + original_amount: amount, + used_amount: Decimal::ZERO, + credit_type: CreditType::Manual, + reason: reason.into(), + created_at: Utc::now(), + expires_at: None, + is_active: true, + reference_id: None, + approved_by: None, + } + } + + /// Create welcome credit + pub fn welcome(account_id: impl Into, amount: SynorDecimal) -> Self { + Self::new(account_id, amount, "Welcome to Synor!") + .with_type(CreditType::Welcome) + .with_expiry_days(90) // 90 day expiry + } + + /// Create referral credit + pub fn referral( + account_id: impl Into, + amount: SynorDecimal, + referrer_id: impl Into, + ) -> Self { + Self::new(account_id, amount, "Referral reward") + .with_type(CreditType::Referral) + .with_reference(referrer_id) + } + + /// Create SLA compensation credit + pub fn sla_compensation( + account_id: impl Into, + amount: SynorDecimal, + incident_id: impl Into, + ) -> Self { + Self::new(account_id, amount, "SLA violation compensation") + .with_type(CreditType::SlaCompensation) + .with_reference(incident_id) + } + + /// Set credit type + pub fn with_type(mut self, credit_type: CreditType) -> Self { + self.credit_type = credit_type; + self + } + + /// Set expiration date + pub fn with_expiry(mut self, expires_at: DateTime) -> Self { + self.expires_at = Some(expires_at); + self + } + + /// Set expiration days from now + pub fn with_expiry_days(mut self, days: i64) -> Self { + self.expires_at = Some(Utc::now() + Duration::days(days)); + self + } + + /// Set reference ID + pub fn with_reference(mut self, reference_id: impl Into) -> Self { + self.reference_id = Some(reference_id.into()); + self + } + + /// Set approver + pub fn with_approver(mut self, approver: impl Into) -> Self { + self.approved_by = Some(approver.into()); + self + } + + /// Use some of the credit + pub fn use_credit(&mut self, amount: SynorDecimal) -> Result { + if !self.is_active { + return Err(CreditError::CreditInactive); + } + + if self.is_expired() { + self.is_active = false; + return Err(CreditError::CreditExpired); + } + + let available = self.amount; + if amount > available { + return Err(CreditError::InsufficientCredit { + requested: amount, + available, + }); + } + + self.amount -= amount; + self.used_amount += amount; + + if self.amount == Decimal::ZERO { + self.is_active = false; + } + + Ok(amount) + } + + /// Check if credit is expired + pub fn is_expired(&self) -> bool { + self.expires_at + .map(|exp| Utc::now() > exp) + .unwrap_or(false) + } + + /// Get remaining amount + pub fn remaining(&self) -> SynorDecimal { + if self.is_expired() || !self.is_active { + Decimal::ZERO + } else { + self.amount + } + } + + /// Get days until expiration + pub fn days_until_expiry(&self) -> Option { + self.expires_at.map(|exp| { + let diff = exp - Utc::now(); + diff.num_days() + }) + } +} + +/// Generate unique credit ID +fn generate_credit_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("crd_{:x}", nanos) +} + +/// Credit errors +#[derive(Debug, Clone, PartialEq)] +pub enum CreditError { + /// Credit is no longer active + CreditInactive, + /// Credit has expired + CreditExpired, + /// Insufficient credit balance + InsufficientCredit { + requested: SynorDecimal, + available: SynorDecimal, + }, +} + +impl std::fmt::Display for CreditError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CreditError::CreditInactive => write!(f, "Credit is no longer active"), + CreditError::CreditExpired => write!(f, "Credit has expired"), + CreditError::InsufficientCredit { requested, available } => { + write!( + f, + "Insufficient credit: requested {}, available {}", + requested, available + ) + } + } + } +} + +impl std::error::Error for CreditError {} + +/// Credit policy configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreditPolicy { + /// Welcome credit amount + pub welcome_amount: SynorDecimal, + /// Referral credit for referrer + pub referral_referrer_amount: SynorDecimal, + /// Referral credit for referee + pub referral_referee_amount: SynorDecimal, + /// Maximum credit per account + pub max_credit_per_account: SynorDecimal, + /// Default expiry days + pub default_expiry_days: i64, +} + +impl Default for CreditPolicy { + fn default() -> Self { + Self { + welcome_amount: Decimal::new(10, 0), // 10 SYNOR + referral_referrer_amount: Decimal::new(25, 0), // 25 SYNOR + referral_referee_amount: Decimal::new(10, 0), // 10 SYNOR + max_credit_per_account: Decimal::new(1000, 0), // 1000 SYNOR + default_expiry_days: 365, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_credit_creation() { + let credit = Credit::new("account_123", dec!(50), "Test credit"); + + assert_eq!(credit.account_id, "account_123"); + assert_eq!(credit.amount, dec!(50)); + assert_eq!(credit.original_amount, dec!(50)); + assert!(credit.is_active); + } + + #[test] + fn test_welcome_credit() { + let credit = Credit::welcome("new_user", dec!(10)); + + assert_eq!(credit.credit_type, CreditType::Welcome); + assert!(credit.expires_at.is_some()); + assert!(credit.days_until_expiry().unwrap() <= 90); + } + + #[test] + fn test_use_credit() { + let mut credit = Credit::new("test", dec!(100), "Test"); + + // Use partial + let used = credit.use_credit(dec!(30)).unwrap(); + assert_eq!(used, dec!(30)); + assert_eq!(credit.amount, dec!(70)); + assert_eq!(credit.used_amount, dec!(30)); + assert!(credit.is_active); + + // Use remaining + let used = credit.use_credit(dec!(70)).unwrap(); + assert_eq!(used, dec!(70)); + assert_eq!(credit.amount, dec!(0)); + assert!(!credit.is_active); + } + + #[test] + fn test_insufficient_credit() { + let mut credit = Credit::new("test", dec!(50), "Test"); + + let result = credit.use_credit(dec!(100)); + assert!(matches!( + result, + Err(CreditError::InsufficientCredit { .. }) + )); + } + + #[test] + fn test_referral_credit() { + let credit = Credit::referral("new_user", dec!(25), "referrer_123"); + + assert_eq!(credit.credit_type, CreditType::Referral); + assert_eq!(credit.reference_id, Some("referrer_123".to_string())); + } +} diff --git a/crates/synor-economics/src/billing/invoice.rs b/crates/synor-economics/src/billing/invoice.rs new file mode 100644 index 0000000..a6d73f1 --- /dev/null +++ b/crates/synor-economics/src/billing/invoice.rs @@ -0,0 +1,344 @@ +//! Invoice Management + +use crate::{AccountId, ServiceType, SynorDecimal}; +use chrono::{DateTime, Datelike, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Invoice status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvoiceStatus { + /// Invoice created, awaiting payment + Draft, + /// Invoice finalized and sent + Pending, + /// Payment received + Paid, + /// Payment failed or declined + Failed, + /// Invoice voided/cancelled + Voided, + /// Payment overdue + Overdue, +} + +impl InvoiceStatus { + /// Check if this status represents a completed invoice + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Paid | Self::Voided) + } +} + +impl std::fmt::Display for InvoiceStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InvoiceStatus::Draft => write!(f, "draft"), + InvoiceStatus::Pending => write!(f, "pending"), + InvoiceStatus::Paid => write!(f, "paid"), + InvoiceStatus::Failed => write!(f, "failed"), + InvoiceStatus::Voided => write!(f, "voided"), + InvoiceStatus::Overdue => write!(f, "overdue"), + } + } +} + +/// Invoice line item +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InvoiceLineItem { + /// Description of the service/charge + pub description: String, + /// Service type + pub service_type: ServiceType, + /// Quantity + pub quantity: SynorDecimal, + /// Unit price in SYNOR + pub unit_price: SynorDecimal, + /// Line item total (quantity * unit_price) + pub amount: SynorDecimal, +} + +impl InvoiceLineItem { + /// Create a new line item + pub fn new( + description: impl Into, + service_type: ServiceType, + quantity: SynorDecimal, + unit_price: SynorDecimal, + ) -> Self { + Self { + description: description.into(), + service_type, + quantity, + unit_price, + amount: quantity * unit_price, + } + } +} + +/// Invoice document +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Invoice { + /// Unique invoice ID + pub id: String, + /// Invoice number (human-readable) + pub number: String, + /// Account being billed + pub account_id: AccountId, + /// Invoice status + pub status: InvoiceStatus, + /// Billing period start + pub period_start: DateTime, + /// Billing period end + pub period_end: DateTime, + /// Line items + pub line_items: Vec, + /// Subtotal before discounts + pub subtotal: SynorDecimal, + /// Discount amount + pub discount: SynorDecimal, + /// Discount description + pub discount_description: Option, + /// Tax amount (if applicable) + pub tax: SynorDecimal, + /// Total amount due + pub total: SynorDecimal, + /// Currency (always SYNOR) + pub currency: String, + /// Due date + pub due_date: DateTime, + /// Date created + pub created_at: DateTime, + /// Date paid (if paid) + pub paid_at: Option>, + /// Payment ID (if paid) + pub payment_id: Option, + /// Notes/memo + pub notes: Option, +} + +impl Invoice { + /// Create a new invoice + pub fn new(account_id: impl Into) -> Self { + let id = generate_invoice_id(); + let number = generate_invoice_number(); + + Self { + id, + number, + account_id: account_id.into(), + status: InvoiceStatus::Draft, + period_start: Utc::now(), + period_end: Utc::now(), + line_items: Vec::new(), + subtotal: Decimal::ZERO, + discount: Decimal::ZERO, + discount_description: None, + tax: Decimal::ZERO, + total: Decimal::ZERO, + currency: "SYNOR".to_string(), + due_date: Utc::now(), + created_at: Utc::now(), + paid_at: None, + payment_id: None, + notes: None, + } + } + + /// Set billing period + pub fn with_period(mut self, start: DateTime, end: DateTime) -> Self { + self.period_start = start; + self.period_end = end; + self + } + + /// Add line items + pub fn with_line_items(mut self, items: Vec) -> Self { + self.line_items = items; + self + } + + /// Add a single line item + pub fn add_line_item(mut self, item: InvoiceLineItem) -> Self { + self.line_items.push(item); + self + } + + /// Set subtotal + pub fn with_subtotal(mut self, subtotal: SynorDecimal) -> Self { + self.subtotal = subtotal; + self + } + + /// Set discount + pub fn with_discount(mut self, discount: SynorDecimal) -> Self { + self.discount = discount; + self + } + + /// Set discount with description + pub fn with_discount_desc( + mut self, + discount: SynorDecimal, + description: impl Into, + ) -> Self { + self.discount = discount; + self.discount_description = Some(description.into()); + self + } + + /// Set total + pub fn with_total(mut self, total: SynorDecimal) -> Self { + self.total = total; + self + } + + /// Set due date + pub fn with_due_date(mut self, due_date: DateTime) -> Self { + self.due_date = due_date; + self + } + + /// Set notes + pub fn with_notes(mut self, notes: impl Into) -> Self { + self.notes = Some(notes.into()); + self + } + + /// Finalize invoice (move from draft to pending) + pub fn finalize(&mut self) { + if self.status == InvoiceStatus::Draft { + self.status = InvoiceStatus::Pending; + } + } + + /// Mark invoice as paid + pub fn mark_paid(&mut self, payment_id: String) { + self.status = InvoiceStatus::Paid; + self.paid_at = Some(Utc::now()); + self.payment_id = Some(payment_id); + } + + /// Mark invoice as void + pub fn void(&mut self) { + self.status = InvoiceStatus::Voided; + } + + /// Check if overdue and update status + pub fn check_overdue(&mut self) { + if self.status == InvoiceStatus::Pending && Utc::now() > self.due_date { + self.status = InvoiceStatus::Overdue; + } + } + + /// Check if invoice is paid + pub fn is_paid(&self) -> bool { + self.status == InvoiceStatus::Paid + } + + /// Check if invoice is overdue + pub fn is_overdue(&self) -> bool { + self.status == InvoiceStatus::Overdue + || (self.status == InvoiceStatus::Pending && Utc::now() > self.due_date) + } + + /// Calculate invoice from line items + pub fn calculate(&mut self) { + self.subtotal = self.line_items.iter().map(|item| item.amount).sum(); + self.total = self.subtotal - self.discount + self.tax; + } + + /// Get amount remaining to be paid + pub fn amount_due(&self) -> SynorDecimal { + if self.is_paid() { + Decimal::ZERO + } else { + self.total + } + } +} + +/// Generate unique invoice ID +fn generate_invoice_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("inv_{:x}", nanos) +} + +/// Generate human-readable invoice number +fn generate_invoice_number() -> String { + let now = Utc::now(); + let seq = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .subsec_micros(); + format!("SYN-{:04}{:02}-{:06}", now.year(), now.month(), seq) +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_invoice_creation() { + let invoice = Invoice::new("account_123") + .with_subtotal(dec!(100)) + .with_discount(dec!(10)) + .with_total(dec!(90)); + + assert_eq!(invoice.account_id, "account_123"); + assert_eq!(invoice.subtotal, dec!(100)); + assert_eq!(invoice.discount, dec!(10)); + assert_eq!(invoice.total, dec!(90)); + assert_eq!(invoice.status, InvoiceStatus::Draft); + } + + #[test] + fn test_invoice_finalization() { + let mut invoice = Invoice::new("account_123"); + assert_eq!(invoice.status, InvoiceStatus::Draft); + + invoice.finalize(); + assert_eq!(invoice.status, InvoiceStatus::Pending); + } + + #[test] + fn test_invoice_payment() { + let mut invoice = Invoice::new("account_123").with_total(dec!(50)); + invoice.finalize(); + + invoice.mark_paid("pay_123".to_string()); + assert!(invoice.is_paid()); + assert_eq!(invoice.payment_id, Some("pay_123".to_string())); + assert!(invoice.paid_at.is_some()); + } + + #[test] + fn test_line_item() { + let item = InvoiceLineItem::new( + "Storage L2", + ServiceType::Storage, + dec!(10), + dec!(0.02), + ); + + assert_eq!(item.amount, dec!(0.20)); + } + + #[test] + fn test_invoice_calculate() { + let mut invoice = Invoice::new("test") + .add_line_item(InvoiceLineItem::new("Storage", ServiceType::Storage, dec!(100), dec!(0.02))) + .add_line_item(InvoiceLineItem::new("Compute", ServiceType::Compute, dec!(10), dec!(0.50))); + + invoice.discount = dec!(1); + invoice.calculate(); + + assert_eq!(invoice.subtotal, dec!(7)); // 2 + 5 + assert_eq!(invoice.total, dec!(6)); // 7 - 1 + } +} diff --git a/crates/synor-economics/src/billing/mod.rs b/crates/synor-economics/src/billing/mod.rs new file mode 100644 index 0000000..16686f5 --- /dev/null +++ b/crates/synor-economics/src/billing/mod.rs @@ -0,0 +1,576 @@ +//! Billing Engine +//! +//! Invoice generation, payment processing, and credit management for Synor services. + +mod credit; +mod invoice; +mod payment; + +pub use credit::Credit; +pub use invoice::{Invoice, InvoiceLineItem, InvoiceStatus}; +pub use payment::{Payment, PaymentMethod, PaymentStatus}; + +use crate::error::{EconomicsError, Result}; +use crate::metering::MeteringService; +use crate::pricing::PricingEngine; +use crate::{AccountId, FeeDistribution, ServiceType, SynorDecimal}; +use chrono::{DateTime, Duration, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Billing engine configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BillingConfig { + /// Billing cycle in days (default: 30) + pub billing_cycle_days: u32, + /// Grace period for late payments in days + pub grace_period_days: u32, + /// Minimum invoice amount (skip if below) + pub minimum_invoice_amount: SynorDecimal, + /// Auto-pay threshold (if prepaid balance allows) + pub auto_pay_enabled: bool, + /// Send invoice reminders + pub reminders_enabled: bool, + /// Days before due date to send reminder + pub reminder_days_before: u32, +} + +impl Default for BillingConfig { + fn default() -> Self { + Self { + billing_cycle_days: 30, + grace_period_days: 7, + minimum_invoice_amount: Decimal::new(1, 2), // 0.01 SYNOR + auto_pay_enabled: true, + reminders_enabled: true, + reminder_days_before: 3, + } + } +} + +/// Account billing information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountBillingInfo { + pub account_id: AccountId, + pub balance: SynorDecimal, + pub credit_balance: SynorDecimal, + pub prepaid_balance: SynorDecimal, + pub tier_name: String, + pub last_payment: Option>, + pub next_invoice: Option>, + pub outstanding_invoices: Vec, +} + +/// Billing engine for managing invoices and payments +pub struct BillingEngine { + config: BillingConfig, + metering: Arc, + pricing: Arc, + /// Account billing data + accounts: RwLock>, + /// Invoices by ID + invoices: RwLock>, + /// Payments by ID + payments: RwLock>, + /// Credits by ID + credits: RwLock>, +} + +/// Internal account data +#[derive(Debug, Clone)] +struct AccountData { + account_id: AccountId, + prepaid_balance: SynorDecimal, + credit_balance: SynorDecimal, + tier: String, + billing_cycle_start: DateTime, + created_at: DateTime, + last_payment: Option>, + invoice_ids: Vec, +} + +impl BillingEngine { + /// Create a new billing engine + pub fn new(metering: Arc, pricing: Arc) -> Self { + Self { + config: BillingConfig::default(), + metering, + pricing, + accounts: RwLock::new(HashMap::new()), + invoices: RwLock::new(HashMap::new()), + payments: RwLock::new(HashMap::new()), + credits: RwLock::new(HashMap::new()), + } + } + + /// Create with custom configuration + pub fn with_config( + config: BillingConfig, + metering: Arc, + pricing: Arc, + ) -> Self { + Self { + config, + metering, + pricing, + accounts: RwLock::new(HashMap::new()), + invoices: RwLock::new(HashMap::new()), + payments: RwLock::new(HashMap::new()), + credits: RwLock::new(HashMap::new()), + } + } + + /// Register a new account + pub async fn register_account(&self, account_id: &str, tier: &str) -> Result<()> { + let mut accounts = self.accounts.write().await; + + if accounts.contains_key(account_id) { + return Err(EconomicsError::Internal(format!( + "Account {} already registered", + account_id + ))); + } + + let now = Utc::now(); + accounts.insert( + account_id.to_string(), + AccountData { + account_id: account_id.to_string(), + prepaid_balance: Decimal::ZERO, + credit_balance: Decimal::ZERO, + tier: tier.to_string(), + billing_cycle_start: now, + created_at: now, + last_payment: None, + invoice_ids: Vec::new(), + }, + ); + + Ok(()) + } + + /// Get account billing information + pub async fn get_account_info(&self, account_id: &str) -> Result { + let accounts = self.accounts.read().await; + let invoices = self.invoices.read().await; + + let account = accounts + .get(account_id) + .ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?; + + let outstanding: Vec<_> = account + .invoice_ids + .iter() + .filter(|id| { + invoices + .get(*id) + .map(|inv| !inv.is_paid()) + .unwrap_or(false) + }) + .cloned() + .collect(); + + let next_invoice = account.billing_cycle_start + + Duration::days(self.config.billing_cycle_days as i64); + + Ok(AccountBillingInfo { + account_id: account_id.to_string(), + balance: account.prepaid_balance + account.credit_balance, + credit_balance: account.credit_balance, + prepaid_balance: account.prepaid_balance, + tier_name: account.tier.clone(), + last_payment: account.last_payment, + next_invoice: Some(next_invoice), + outstanding_invoices: outstanding, + }) + } + + /// Add prepaid balance to account + pub async fn add_prepaid(&self, account_id: &str, amount: SynorDecimal) -> Result<()> { + let mut accounts = self.accounts.write().await; + + let account = accounts + .get_mut(account_id) + .ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?; + + account.prepaid_balance += amount; + + tracing::info!( + "Added {} SYNOR prepaid to account {}", + amount, + account_id + ); + + Ok(()) + } + + /// Add credit to account + pub async fn add_credit(&self, account_id: &str, credit: Credit) -> Result<()> { + let mut accounts = self.accounts.write().await; + let mut credits = self.credits.write().await; + + let account = accounts + .get_mut(account_id) + .ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?; + + account.credit_balance += credit.amount; + credits.insert(credit.id.clone(), credit.clone()); + + tracing::info!( + "Added {} SYNOR credit to account {}: {}", + credit.amount, + account_id, + credit.reason + ); + + Ok(()) + } + + /// Generate invoice for an account + pub async fn generate_invoice(&self, account_id: &str) -> Result { + let mut accounts = self.accounts.write().await; + let mut invoices = self.invoices.write().await; + + let account = accounts + .get_mut(account_id) + .ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?; + + let period_start = account.billing_cycle_start; + let period_end = Utc::now(); + + // Get usage for the period + let usage = self + .metering + .get_period_usage(account_id, period_start, period_end) + .await?; + + // Generate line items + let mut line_items = Vec::new(); + let mut subtotal = Decimal::ZERO; + + for (service_type, cost) in usage.by_service.iter() { + let description = match service_type { + ServiceType::Storage => "Storage L2 - Data storage and retrieval", + ServiceType::Hosting => "Hosting - Web hosting and bandwidth", + ServiceType::Database => "Database L2 - Queries and storage", + ServiceType::Compute => "Compute L2 - CPU/GPU processing", + ServiceType::Network => "Network - Bandwidth usage", + ServiceType::Contract => "Smart Contracts - Gas fees", + }; + + line_items.push(InvoiceLineItem { + description: description.to_string(), + service_type: *service_type, + quantity: Decimal::ONE, + unit_price: *cost, + amount: *cost, + }); + + subtotal += *cost; + } + + // Apply discounts from tier + let discount = self + .pricing + .calculate_tier_discount(&account.tier, subtotal)?; + let discounted_total = subtotal - discount; + + // Calculate fee distribution + let distribution = FeeDistribution::l2_service_fees(); + let _fees = distribution.distribute(discounted_total); + + // Skip if below minimum + if discounted_total < self.config.minimum_invoice_amount { + return Err(EconomicsError::Internal(format!( + "Invoice amount {} below minimum {}", + discounted_total, self.config.minimum_invoice_amount + ))); + } + + // Create invoice + let due_date = Utc::now() + Duration::days(self.config.billing_cycle_days as i64); + let invoice = Invoice::new(account_id) + .with_period(period_start, period_end) + .with_line_items(line_items) + .with_subtotal(subtotal) + .with_discount(discount) + .with_total(discounted_total) + .with_due_date(due_date); + + // Store invoice + invoices.insert(invoice.id.clone(), invoice.clone()); + account.invoice_ids.push(invoice.id.clone()); + + // Reset billing cycle + account.billing_cycle_start = Utc::now(); + + // Reset metering for new period + self.metering.reset_period(account_id).await?; + + tracing::info!( + "Generated invoice {} for account {}: {} SYNOR", + invoice.id, + account_id, + discounted_total + ); + + Ok(invoice) + } + + /// Process a payment + pub async fn process_payment(&self, payment: Payment) -> Result<()> { + let mut accounts = self.accounts.write().await; + let mut invoices = self.invoices.write().await; + let mut payments = self.payments.write().await; + + let account = accounts + .get_mut(&payment.account_id) + .ok_or_else(|| EconomicsError::AccountNotFound(payment.account_id.clone()))?; + + // Validate invoice if specified + if let Some(ref invoice_id) = payment.invoice_id { + let invoice = invoices + .get_mut(invoice_id) + .ok_or_else(|| EconomicsError::InvoiceNotFound(invoice_id.clone()))?; + + if invoice.is_paid() { + return Err(EconomicsError::InvoiceAlreadyPaid(invoice_id.clone())); + } + + if payment.amount < invoice.total { + return Err(EconomicsError::PaymentFailed(format!( + "Payment amount {} less than invoice total {}", + payment.amount, invoice.total + ))); + } + + // Mark invoice as paid + invoice.mark_paid(payment.id.clone()); + } + + // Update account + account.last_payment = Some(Utc::now()); + + // Store payment + payments.insert(payment.id.clone(), payment.clone()); + + tracing::info!( + "Processed payment {} for account {}: {} SYNOR", + payment.id, + payment.account_id, + payment.amount + ); + + Ok(()) + } + + /// Process payment from prepaid balance + pub async fn pay_from_prepaid(&self, account_id: &str, invoice_id: &str) -> Result<()> { + // Get invoice total first + let invoice_total = { + let invoices = self.invoices.read().await; + let invoice = invoices + .get(invoice_id) + .ok_or_else(|| EconomicsError::InvoiceNotFound(invoice_id.to_string()))?; + invoice.total + }; + + // Update account balances + { + let mut accounts = self.accounts.write().await; + let account = accounts + .get_mut(account_id) + .ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?; + + let total_balance = account.prepaid_balance + account.credit_balance; + + if total_balance < invoice_total { + return Err(EconomicsError::InsufficientBalance { + required: invoice_total.to_string(), + available: total_balance.to_string(), + }); + } + + // Use credits first, then prepaid + let mut remaining = invoice_total; + + if account.credit_balance > Decimal::ZERO { + let credit_used = remaining.min(account.credit_balance); + account.credit_balance -= credit_used; + remaining -= credit_used; + } + + if remaining > Decimal::ZERO { + account.prepaid_balance -= remaining; + } + } + + // Create and process payment + let payment = Payment::new(account_id, invoice_total, PaymentMethod::PrepaidBalance) + .with_invoice(invoice_id); + + self.process_payment(payment).await + } + + /// Get invoice by ID + pub async fn get_invoice(&self, invoice_id: &str) -> Result { + let invoices = self.invoices.read().await; + invoices + .get(invoice_id) + .cloned() + .ok_or_else(|| EconomicsError::InvoiceNotFound(invoice_id.to_string())) + } + + /// Get all invoices for an account + pub async fn get_account_invoices(&self, account_id: &str) -> Result> { + let accounts = self.accounts.read().await; + let invoices = self.invoices.read().await; + + let account = accounts + .get(account_id) + .ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?; + + let result: Vec<_> = account + .invoice_ids + .iter() + .filter_map(|id| invoices.get(id).cloned()) + .collect(); + + Ok(result) + } + + /// Get unpaid invoices for an account + pub async fn get_unpaid_invoices(&self, account_id: &str) -> Result> { + let all_invoices = self.get_account_invoices(account_id).await?; + Ok(all_invoices.into_iter().filter(|inv| !inv.is_paid()).collect()) + } + + /// Generate all pending invoices (for batch processing) + pub async fn generate_all_pending_invoices(&self) -> Result> { + let account_ids: Vec<_> = { + let accounts = self.accounts.read().await; + accounts.keys().cloned().collect() + }; + + let mut generated = Vec::new(); + let now = Utc::now(); + + for account_id in account_ids { + // Check if billing cycle has ended + let should_invoice = { + let accounts = self.accounts.read().await; + if let Some(account) = accounts.get(&account_id) { + let cycle_end = account.billing_cycle_start + + Duration::days(self.config.billing_cycle_days as i64); + now >= cycle_end + } else { + false + } + }; + + if should_invoice { + match self.generate_invoice(&account_id).await { + Ok(invoice) => generated.push(invoice), + Err(e) => { + tracing::warn!("Failed to generate invoice for {}: {}", account_id, e); + } + } + } + } + + Ok(generated) + } + + /// Get billing statistics + pub async fn stats(&self) -> BillingStats { + let accounts = self.accounts.read().await; + let invoices = self.invoices.read().await; + let payments = self.payments.read().await; + + let total_prepaid: Decimal = accounts.values().map(|a| a.prepaid_balance).sum(); + let total_credits: Decimal = accounts.values().map(|a| a.credit_balance).sum(); + + let total_invoiced: Decimal = invoices.values().map(|i| i.total).sum(); + let total_paid: Decimal = payments.values().map(|p| p.amount).sum(); + + let unpaid_count = invoices.values().filter(|i| !i.is_paid()).count(); + + BillingStats { + total_accounts: accounts.len(), + total_invoices: invoices.len(), + unpaid_invoices: unpaid_count, + total_prepaid_balance: total_prepaid, + total_credit_balance: total_credits, + total_invoiced, + total_paid, + outstanding_amount: total_invoiced - total_paid, + } + } +} + +/// Billing statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BillingStats { + pub total_accounts: usize, + pub total_invoices: usize, + pub unpaid_invoices: usize, + pub total_prepaid_balance: SynorDecimal, + pub total_credit_balance: SynorDecimal, + pub total_invoiced: SynorDecimal, + pub total_paid: SynorDecimal, + pub outstanding_amount: SynorDecimal, +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + async fn setup_engine() -> BillingEngine { + let pricing = Arc::new(PricingEngine::new()); + let metering = Arc::new(MeteringService::new(pricing.clone())); + BillingEngine::new(metering, pricing) + } + + #[tokio::test] + async fn test_register_account() { + let engine = setup_engine().await; + engine.register_account("test_account", "standard").await.unwrap(); + + let info = engine.get_account_info("test_account").await.unwrap(); + assert_eq!(info.account_id, "test_account"); + assert_eq!(info.tier_name, "standard"); + } + + #[tokio::test] + async fn test_add_prepaid() { + let engine = setup_engine().await; + engine.register_account("prepaid_test", "standard").await.unwrap(); + engine.add_prepaid("prepaid_test", dec!(100)).await.unwrap(); + + let info = engine.get_account_info("prepaid_test").await.unwrap(); + assert_eq!(info.prepaid_balance, dec!(100)); + } + + #[tokio::test] + async fn test_add_credit() { + let engine = setup_engine().await; + engine.register_account("credit_test", "standard").await.unwrap(); + + let credit = Credit::new("credit_test", dec!(50), "Welcome bonus"); + engine.add_credit("credit_test", credit).await.unwrap(); + + let info = engine.get_account_info("credit_test").await.unwrap(); + assert_eq!(info.credit_balance, dec!(50)); + } + + #[tokio::test] + async fn test_account_not_found() { + let engine = setup_engine().await; + let result = engine.get_account_info("nonexistent").await; + assert!(matches!(result, Err(EconomicsError::AccountNotFound(_)))); + } +} diff --git a/crates/synor-economics/src/billing/payment.rs b/crates/synor-economics/src/billing/payment.rs new file mode 100644 index 0000000..f5e4874 --- /dev/null +++ b/crates/synor-economics/src/billing/payment.rs @@ -0,0 +1,345 @@ +//! Payment Processing + +use crate::{AccountId, SynorDecimal}; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Payment method +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaymentMethod { + /// Direct SYNOR transfer + SynorTransfer, + /// Prepaid account balance + PrepaidBalance, + /// Credit/promotional balance + CreditBalance, + /// Smart contract escrow + SmartContract, + /// Cross-chain bridge payment + CrossChain, + /// Fiat on-ramp (converted to SYNOR) + FiatOnRamp, +} + +impl std::fmt::Display for PaymentMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PaymentMethod::SynorTransfer => write!(f, "SYNOR Transfer"), + PaymentMethod::PrepaidBalance => write!(f, "Prepaid Balance"), + PaymentMethod::CreditBalance => write!(f, "Credit Balance"), + PaymentMethod::SmartContract => write!(f, "Smart Contract"), + PaymentMethod::CrossChain => write!(f, "Cross-Chain"), + PaymentMethod::FiatOnRamp => write!(f, "Fiat On-Ramp"), + } + } +} + +/// Payment status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaymentStatus { + /// Payment initiated + Pending, + /// Payment being processed + Processing, + /// Payment confirmed + Confirmed, + /// Payment completed successfully + Completed, + /// Payment failed + Failed, + /// Payment refunded + Refunded, +} + +impl PaymentStatus { + /// Check if this is a terminal status + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Refunded) + } + + /// Check if payment was successful + pub fn is_success(&self) -> bool { + matches!(self, Self::Completed) + } +} + +/// Payment record +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Payment { + /// Unique payment ID + pub id: String, + /// Account making the payment + pub account_id: AccountId, + /// Invoice being paid (optional) + pub invoice_id: Option, + /// Payment amount in SYNOR + pub amount: SynorDecimal, + /// Payment method used + pub method: PaymentMethod, + /// Payment status + pub status: PaymentStatus, + /// Transaction hash (for on-chain payments) + pub transaction_hash: Option, + /// Block number (for on-chain payments) + pub block_number: Option, + /// Date created + pub created_at: DateTime, + /// Date completed + pub completed_at: Option>, + /// Failure reason (if failed) + pub failure_reason: Option, + /// Additional metadata + pub metadata: std::collections::HashMap, +} + +impl Payment { + /// Create a new payment + pub fn new(account_id: impl Into, amount: SynorDecimal, method: PaymentMethod) -> Self { + Self { + id: generate_payment_id(), + account_id: account_id.into(), + invoice_id: None, + amount, + method, + status: PaymentStatus::Pending, + transaction_hash: None, + block_number: None, + created_at: Utc::now(), + completed_at: None, + failure_reason: None, + metadata: std::collections::HashMap::new(), + } + } + + /// Associate with invoice + pub fn with_invoice(mut self, invoice_id: impl Into) -> Self { + self.invoice_id = Some(invoice_id.into()); + self + } + + /// Add transaction hash + pub fn with_transaction(mut self, hash: impl Into) -> Self { + self.transaction_hash = Some(hash.into()); + self + } + + /// Add block number + pub fn with_block(mut self, block: u64) -> Self { + self.block_number = Some(block); + self + } + + /// Add metadata + pub fn with_metadata(mut self, key: impl Into, value: impl Into) -> Self { + self.metadata.insert(key.into(), value.into()); + self + } + + /// Mark payment as processing + pub fn mark_processing(&mut self) { + self.status = PaymentStatus::Processing; + } + + /// Mark payment as confirmed (awaiting finality) + pub fn mark_confirmed(&mut self, tx_hash: String) { + self.status = PaymentStatus::Confirmed; + self.transaction_hash = Some(tx_hash); + } + + /// Mark payment as completed + pub fn mark_completed(&mut self) { + self.status = PaymentStatus::Completed; + self.completed_at = Some(Utc::now()); + } + + /// Mark payment as failed + pub fn mark_failed(&mut self, reason: impl Into) { + self.status = PaymentStatus::Failed; + self.failure_reason = Some(reason.into()); + self.completed_at = Some(Utc::now()); + } + + /// Mark payment as refunded + pub fn mark_refunded(&mut self) { + self.status = PaymentStatus::Refunded; + self.completed_at = Some(Utc::now()); + } + + /// Check if payment is complete + pub fn is_complete(&self) -> bool { + self.status == PaymentStatus::Completed + } + + /// Check if payment is pending + pub fn is_pending(&self) -> bool { + matches!( + self.status, + PaymentStatus::Pending | PaymentStatus::Processing | PaymentStatus::Confirmed + ) + } +} + +/// Generate unique payment ID +fn generate_payment_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("pay_{:x}", nanos) +} + +/// Payment processor for handling different payment methods +pub struct PaymentProcessor; + +impl PaymentProcessor { + /// Process a SYNOR transfer payment + pub async fn process_synor_transfer( + payment: &mut Payment, + from_address: &str, + to_address: &str, + ) -> Result<(), PaymentError> { + // In production, this would: + // 1. Create and broadcast a transaction + // 2. Wait for confirmation + // 3. Update payment status + + payment.mark_processing(); + + // Simulate transaction + let tx_hash = format!("0x{:x}000000000000000000000000000000000000000000000000000000000000", + std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs()); + + payment.mark_confirmed(tx_hash); + + // Add addresses to metadata + payment.metadata.insert("from".to_string(), from_address.to_string()); + payment.metadata.insert("to".to_string(), to_address.to_string()); + + payment.mark_completed(); + + Ok(()) + } + + /// Process prepaid balance payment + pub async fn process_prepaid( + payment: &mut Payment, + available_balance: SynorDecimal, + ) -> Result<(), PaymentError> { + if available_balance < payment.amount { + payment.mark_failed("Insufficient prepaid balance"); + return Err(PaymentError::InsufficientBalance); + } + + payment.mark_processing(); + payment.mark_completed(); + + Ok(()) + } + + /// Validate payment request + pub fn validate(payment: &Payment) -> Result<(), PaymentError> { + if payment.amount <= Decimal::ZERO { + return Err(PaymentError::InvalidAmount); + } + + if payment.account_id.is_empty() { + return Err(PaymentError::InvalidAccount); + } + + Ok(()) + } +} + +/// Payment processing errors +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaymentError { + /// Invalid payment amount + InvalidAmount, + /// Invalid account + InvalidAccount, + /// Insufficient balance + InsufficientBalance, + /// Transaction failed + TransactionFailed(String), + /// Network error + NetworkError(String), +} + +impl std::fmt::Display for PaymentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PaymentError::InvalidAmount => write!(f, "Invalid payment amount"), + PaymentError::InvalidAccount => write!(f, "Invalid account"), + PaymentError::InsufficientBalance => write!(f, "Insufficient balance"), + PaymentError::TransactionFailed(msg) => write!(f, "Transaction failed: {}", msg), + PaymentError::NetworkError(msg) => write!(f, "Network error: {}", msg), + } + } +} + +impl std::error::Error for PaymentError {} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_payment_creation() { + let payment = Payment::new("account_123", dec!(50), PaymentMethod::SynorTransfer) + .with_invoice("inv_456"); + + assert_eq!(payment.account_id, "account_123"); + assert_eq!(payment.amount, dec!(50)); + assert_eq!(payment.invoice_id, Some("inv_456".to_string())); + assert_eq!(payment.status, PaymentStatus::Pending); + } + + #[test] + fn test_payment_flow() { + let mut payment = Payment::new("test", dec!(100), PaymentMethod::SynorTransfer); + + assert!(payment.is_pending()); + + payment.mark_processing(); + assert!(payment.is_pending()); + + payment.mark_confirmed("0x123".to_string()); + assert!(payment.is_pending()); + + payment.mark_completed(); + assert!(payment.is_complete()); + assert!(payment.completed_at.is_some()); + } + + #[test] + fn test_payment_failure() { + let mut payment = Payment::new("test", dec!(100), PaymentMethod::SynorTransfer); + + payment.mark_failed("Insufficient funds"); + assert!(!payment.is_complete()); + assert_eq!(payment.failure_reason, Some("Insufficient funds".to_string())); + } + + #[tokio::test] + async fn test_prepaid_processing() { + let mut payment = Payment::new("test", dec!(50), PaymentMethod::PrepaidBalance); + + // Sufficient balance + let result = PaymentProcessor::process_prepaid(&mut payment, dec!(100)).await; + assert!(result.is_ok()); + assert!(payment.is_complete()); + + // Insufficient balance + let mut payment2 = Payment::new("test", dec!(150), PaymentMethod::PrepaidBalance); + let result = PaymentProcessor::process_prepaid(&mut payment2, dec!(100)).await; + assert!(matches!(result, Err(PaymentError::InsufficientBalance))); + } +} diff --git a/crates/synor-economics/src/calculator/estimator.rs b/crates/synor-economics/src/calculator/estimator.rs new file mode 100644 index 0000000..705551b --- /dev/null +++ b/crates/synor-economics/src/calculator/estimator.rs @@ -0,0 +1,649 @@ +//! Cost Estimator +//! +//! Estimate costs for projected resource usage. + +use crate::error::Result; +use crate::pricing::PricingEngine; +use crate::{ResourceUnit, ServiceType, SynorDecimal}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; + +/// Usage projection for cost estimation +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UsageProjection { + /// Projected storage in GB + pub storage_gb: Decimal, + /// Projected storage retrieval in GB + pub storage_retrieval_gb: Decimal, + /// Projected hosting bandwidth in GB + pub hosting_bandwidth_gb: Decimal, + /// Projected custom domains + pub custom_domains: u32, + /// Projected database storage in GB + pub database_storage_gb: Decimal, + /// Projected database queries (in millions) + pub database_queries_millions: Decimal, + /// Projected vector searches (in millions) + pub vector_searches_millions: Decimal, + /// Projected CPU core-hours + pub cpu_core_hours: Decimal, + /// Projected GPU hours + pub gpu_hours: Decimal, + /// Projected memory GB-hours + pub memory_gb_hours: Decimal, + /// Projected serverless invocations (in millions) + pub invocations_millions: Decimal, + /// Projected network bandwidth in GB + pub network_bandwidth_gb: Decimal, + /// Duration in months (for monthly costs) + pub duration_months: u32, + /// Pricing tier to apply + pub tier: Option, +} + +impl UsageProjection { + /// Create a new empty projection + pub fn new() -> Self { + Self { + duration_months: 1, + ..Default::default() + } + } + + /// Set storage usage + pub fn with_storage(mut self, storage_gb: Decimal, retrieval_gb: Decimal) -> Self { + self.storage_gb = storage_gb; + self.storage_retrieval_gb = retrieval_gb; + self + } + + /// Set hosting usage + pub fn with_hosting(mut self, bandwidth_gb: Decimal, domains: u32) -> Self { + self.hosting_bandwidth_gb = bandwidth_gb; + self.custom_domains = domains; + self + } + + /// Set database usage + pub fn with_database( + mut self, + storage_gb: Decimal, + queries_millions: Decimal, + vector_searches_millions: Decimal, + ) -> Self { + self.database_storage_gb = storage_gb; + self.database_queries_millions = queries_millions; + self.vector_searches_millions = vector_searches_millions; + self + } + + /// Set compute usage + pub fn with_compute( + mut self, + cpu_hours: Decimal, + gpu_hours: Decimal, + memory_gb_hours: Decimal, + invocations_millions: Decimal, + ) -> Self { + self.cpu_core_hours = cpu_hours; + self.gpu_hours = gpu_hours; + self.memory_gb_hours = memory_gb_hours; + self.invocations_millions = invocations_millions; + self + } + + /// Set network usage + pub fn with_network(mut self, bandwidth_gb: Decimal) -> Self { + self.network_bandwidth_gb = bandwidth_gb; + self + } + + /// Set duration + pub fn for_months(mut self, months: u32) -> Self { + self.duration_months = months.max(1); + self + } + + /// Set pricing tier + pub fn with_tier(mut self, tier: impl Into) -> Self { + self.tier = Some(tier.into()); + self + } +} + +/// Detailed cost estimate +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CostEstimate { + /// Total estimated cost + pub total: SynorDecimal, + /// Monthly cost (if duration > 1) + pub monthly: SynorDecimal, + /// Breakdown by service + pub by_service: HashMap, + /// Free tier savings + pub free_tier_savings: SynorDecimal, + /// Tier discount applied + pub tier_discount: SynorDecimal, + /// Tier name used + pub tier: String, + /// Duration in months + pub duration_months: u32, + /// USD equivalent (if oracle available) + pub usd_equivalent: Option, +} + +/// Cost breakdown for a single service +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceCostBreakdown { + /// Service type + pub service: ServiceType, + /// Line items + pub items: Vec, + /// Subtotal before discounts + pub subtotal: SynorDecimal, + /// Free tier credit applied + pub free_tier_credit: SynorDecimal, + /// Net cost + pub net_cost: SynorDecimal, +} + +/// Individual cost line item +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CostLineItem { + /// Resource description + pub description: String, + /// Resource unit + pub unit: ResourceUnit, + /// Quantity + pub quantity: Decimal, + /// Unit price + pub unit_price: SynorDecimal, + /// Line total + pub total: SynorDecimal, +} + +/// Cost estimator service +pub struct CostEstimator { + pricing: Arc, +} + +impl CostEstimator { + /// Create a new cost estimator + pub fn new(pricing: Arc) -> Self { + Self { pricing } + } + + /// Estimate cost for a usage projection + pub async fn estimate(&self, projection: UsageProjection) -> Result { + let tier_name = projection.tier.clone().unwrap_or_else(|| "free".to_string()); + let months = projection.duration_months.max(1); + + let mut by_service = HashMap::new(); + let mut total_free_savings = Decimal::ZERO; + + // Storage costs + let storage_breakdown = self.estimate_storage(&projection)?; + total_free_savings += storage_breakdown.free_tier_credit; + by_service.insert(ServiceType::Storage, storage_breakdown); + + // Hosting costs + let hosting_breakdown = self.estimate_hosting(&projection)?; + total_free_savings += hosting_breakdown.free_tier_credit; + by_service.insert(ServiceType::Hosting, hosting_breakdown); + + // Database costs + let database_breakdown = self.estimate_database(&projection)?; + total_free_savings += database_breakdown.free_tier_credit; + by_service.insert(ServiceType::Database, database_breakdown); + + // Compute costs + let compute_breakdown = self.estimate_compute(&projection)?; + total_free_savings += compute_breakdown.free_tier_credit; + by_service.insert(ServiceType::Compute, compute_breakdown); + + // Network costs + let network_breakdown = self.estimate_network(&projection)?; + total_free_savings += network_breakdown.free_tier_credit; + by_service.insert(ServiceType::Network, network_breakdown); + + // Calculate subtotal + let subtotal: Decimal = by_service.values().map(|b| b.net_cost).sum(); + + // Apply tier discount + let tier_discount = self.pricing.calculate_tier_discount(&tier_name, subtotal)?; + let total = subtotal - tier_discount; + + // Monthly cost + let monthly = total / Decimal::from(months); + + Ok(CostEstimate { + total, + monthly, + by_service, + free_tier_savings: total_free_savings, + tier_discount, + tier: tier_name, + duration_months: months, + usd_equivalent: None, // Would come from oracle + }) + } + + /// Estimate storage costs + fn estimate_storage(&self, projection: &UsageProjection) -> Result { + let mut items = Vec::new(); + let mut subtotal = Decimal::ZERO; + + // Storage + if projection.storage_gb > Decimal::ZERO { + let price = self + .pricing + .get_base_price(ServiceType::Storage, ResourceUnit::GbMonth) + .unwrap_or(Decimal::new(2, 2)); + + let months = Decimal::from(projection.duration_months.max(1)); + let total = projection.storage_gb * price * months; + + items.push(CostLineItem { + description: "Data Storage".to_string(), + unit: ResourceUnit::GbMonth, + quantity: projection.storage_gb * months, + unit_price: price, + total, + }); + subtotal += total; + } + + // Retrieval + if projection.storage_retrieval_gb > Decimal::ZERO { + let price = self + .pricing + .get_base_price(ServiceType::Storage, ResourceUnit::BandwidthGb) + .unwrap_or(Decimal::new(1, 2)); + + let total = projection.storage_retrieval_gb * price; + + items.push(CostLineItem { + description: "Data Retrieval".to_string(), + unit: ResourceUnit::BandwidthGb, + quantity: projection.storage_retrieval_gb, + unit_price: price, + total, + }); + subtotal += total; + } + + // Free tier + let free_storage = self + .pricing + .get_free_allocation(ServiceType::Storage, ResourceUnit::GbMonth); + let free_credit = (free_storage * Decimal::new(2, 2)).min(subtotal); + + Ok(ServiceCostBreakdown { + service: ServiceType::Storage, + items, + subtotal, + free_tier_credit: free_credit, + net_cost: subtotal - free_credit, + }) + } + + /// Estimate hosting costs + fn estimate_hosting(&self, projection: &UsageProjection) -> Result { + let mut items = Vec::new(); + let mut subtotal = Decimal::ZERO; + + // Bandwidth + if projection.hosting_bandwidth_gb > Decimal::ZERO { + let price = self + .pricing + .get_base_price(ServiceType::Hosting, ResourceUnit::BandwidthGb) + .unwrap_or(Decimal::new(5, 2)); + + let total = projection.hosting_bandwidth_gb * price; + + items.push(CostLineItem { + description: "Hosting Bandwidth".to_string(), + unit: ResourceUnit::BandwidthGb, + quantity: projection.hosting_bandwidth_gb, + unit_price: price, + total, + }); + subtotal += total; + } + + // Custom domains + if projection.custom_domains > 0 { + let price = self + .pricing + .get_base_price(ServiceType::Hosting, ResourceUnit::Domains) + .unwrap_or(Decimal::new(50, 2)); + + let months = Decimal::from(projection.duration_months.max(1)); + let total = Decimal::from(projection.custom_domains) * price * months; + + items.push(CostLineItem { + description: "Custom Domains".to_string(), + unit: ResourceUnit::Domains, + quantity: Decimal::from(projection.custom_domains), + unit_price: price, + total, + }); + subtotal += total; + } + + // Free tier + let free_bandwidth = self + .pricing + .get_free_allocation(ServiceType::Hosting, ResourceUnit::BandwidthGb); + let free_credit = (free_bandwidth * Decimal::new(5, 2)).min(subtotal); + + Ok(ServiceCostBreakdown { + service: ServiceType::Hosting, + items, + subtotal, + free_tier_credit: free_credit, + net_cost: subtotal - free_credit, + }) + } + + /// Estimate database costs + fn estimate_database(&self, projection: &UsageProjection) -> Result { + let mut items = Vec::new(); + let mut subtotal = Decimal::ZERO; + + // Storage + if projection.database_storage_gb > Decimal::ZERO { + let price = self + .pricing + .get_base_price(ServiceType::Database, ResourceUnit::GbMonth) + .unwrap_or(Decimal::new(10, 2)); + + let months = Decimal::from(projection.duration_months.max(1)); + let total = projection.database_storage_gb * price * months; + + items.push(CostLineItem { + description: "Database Storage".to_string(), + unit: ResourceUnit::GbMonth, + quantity: projection.database_storage_gb * months, + unit_price: price, + total, + }); + subtotal += total; + } + + // Queries + if projection.database_queries_millions > Decimal::ZERO { + let price_per_query = self + .pricing + .get_base_price(ServiceType::Database, ResourceUnit::Queries) + .unwrap_or(Decimal::new(1, 8)); + + let queries = projection.database_queries_millions * Decimal::from(1_000_000); + let total = queries * price_per_query; + + items.push(CostLineItem { + description: "Database Queries".to_string(), + unit: ResourceUnit::Queries, + quantity: queries, + unit_price: price_per_query, + total, + }); + subtotal += total; + } + + // Vector searches + if projection.vector_searches_millions > Decimal::ZERO { + let price_per_search = self + .pricing + .get_base_price(ServiceType::Database, ResourceUnit::VectorSearches) + .unwrap_or(Decimal::new(5, 8)); + + let searches = projection.vector_searches_millions * Decimal::from(1_000_000); + let total = searches * price_per_search; + + items.push(CostLineItem { + description: "Vector Searches".to_string(), + unit: ResourceUnit::VectorSearches, + quantity: searches, + unit_price: price_per_search, + total, + }); + subtotal += total; + } + + // Free tier + let free_queries = self + .pricing + .get_free_allocation(ServiceType::Database, ResourceUnit::Queries); + let free_credit = (free_queries * Decimal::new(1, 8)).min(subtotal); + + Ok(ServiceCostBreakdown { + service: ServiceType::Database, + items, + subtotal, + free_tier_credit: free_credit, + net_cost: subtotal - free_credit, + }) + } + + /// Estimate compute costs + fn estimate_compute(&self, projection: &UsageProjection) -> Result { + let mut items = Vec::new(); + let mut subtotal = Decimal::ZERO; + + // CPU + if projection.cpu_core_hours > Decimal::ZERO { + let price = self + .pricing + .get_base_price(ServiceType::Compute, ResourceUnit::CpuCoreHours) + .unwrap_or(Decimal::new(2, 2)); + + let total = projection.cpu_core_hours * price; + + items.push(CostLineItem { + description: "CPU Core-Hours".to_string(), + unit: ResourceUnit::CpuCoreHours, + quantity: projection.cpu_core_hours, + unit_price: price, + total, + }); + subtotal += total; + } + + // GPU + if projection.gpu_hours > Decimal::ZERO { + let price = self + .pricing + .get_base_price(ServiceType::Compute, ResourceUnit::GpuHours) + .unwrap_or(Decimal::new(50, 2)); + + let total = projection.gpu_hours * price; + + items.push(CostLineItem { + description: "GPU Hours".to_string(), + unit: ResourceUnit::GpuHours, + quantity: projection.gpu_hours, + unit_price: price, + total, + }); + subtotal += total; + } + + // Memory + if projection.memory_gb_hours > Decimal::ZERO { + let price = self + .pricing + .get_base_price(ServiceType::Compute, ResourceUnit::MemoryGbHours) + .unwrap_or(Decimal::new(5, 3)); + + let total = projection.memory_gb_hours * price; + + items.push(CostLineItem { + description: "Memory GB-Hours".to_string(), + unit: ResourceUnit::MemoryGbHours, + quantity: projection.memory_gb_hours, + unit_price: price, + total, + }); + subtotal += total; + } + + // Invocations + if projection.invocations_millions > Decimal::ZERO { + let price_per_inv = self + .pricing + .get_base_price(ServiceType::Compute, ResourceUnit::Invocations) + .unwrap_or(Decimal::new(2, 7)); + + let invocations = projection.invocations_millions * Decimal::from(1_000_000); + let total = invocations * price_per_inv; + + items.push(CostLineItem { + description: "Serverless Invocations".to_string(), + unit: ResourceUnit::Invocations, + quantity: invocations, + unit_price: price_per_inv, + total, + }); + subtotal += total; + } + + // Free tier + let free_cpu = self + .pricing + .get_free_allocation(ServiceType::Compute, ResourceUnit::CpuCoreHours); + let free_credit = (free_cpu * Decimal::new(2, 2)).min(subtotal); + + Ok(ServiceCostBreakdown { + service: ServiceType::Compute, + items, + subtotal, + free_tier_credit: free_credit, + net_cost: subtotal - free_credit, + }) + } + + /// Estimate network costs + fn estimate_network(&self, projection: &UsageProjection) -> Result { + let mut items = Vec::new(); + let mut subtotal = Decimal::ZERO; + + if projection.network_bandwidth_gb > Decimal::ZERO { + let price = self + .pricing + .get_base_price(ServiceType::Network, ResourceUnit::BandwidthGb) + .unwrap_or(Decimal::new(1, 2)); + + let total = projection.network_bandwidth_gb * price; + + items.push(CostLineItem { + description: "Network Bandwidth".to_string(), + unit: ResourceUnit::BandwidthGb, + quantity: projection.network_bandwidth_gb, + unit_price: price, + total, + }); + subtotal += total; + } + + // Free tier + let free_bandwidth = self + .pricing + .get_free_allocation(ServiceType::Network, ResourceUnit::BandwidthGb); + let free_credit = (free_bandwidth * Decimal::new(1, 2)).min(subtotal); + + Ok(ServiceCostBreakdown { + service: ServiceType::Network, + items, + subtotal, + free_tier_credit: free_credit, + net_cost: subtotal - free_credit, + }) + } + + /// Quick estimate for a single service + pub fn quick_estimate( + &self, + service: ServiceType, + resource: ResourceUnit, + amount: Decimal, + ) -> Result { + self.pricing.calculate_cost(service, resource, amount) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + fn setup_estimator() -> CostEstimator { + let pricing = Arc::new(PricingEngine::new()); + CostEstimator::new(pricing) + } + + #[tokio::test] + async fn test_storage_estimate() { + let estimator = setup_estimator(); + + let projection = UsageProjection::new() + .with_storage(dec!(100), dec!(50)) // 100 GB storage, 50 GB retrieval + .for_months(1); + + let estimate = estimator.estimate(projection).await.unwrap(); + + // Storage: 100 * 0.02 = 2.00 + // Retrieval: 50 * 0.01 = 0.50 + // Total before free tier: 2.50 + assert!(estimate.total > Decimal::ZERO); + assert!(estimate.by_service.contains_key(&ServiceType::Storage)); + } + + #[tokio::test] + async fn test_compute_estimate() { + let estimator = setup_estimator(); + + let projection = UsageProjection::new() + .with_compute(dec!(100), dec!(10), dec!(200), dec!(5)) // 100 CPU hours, 10 GPU hours, 200 mem hours, 5M invocations + .for_months(1); + + let estimate = estimator.estimate(projection).await.unwrap(); + + // CPU: 100 * 0.02 = 2.00 + // GPU: 10 * 0.50 = 5.00 + // Memory: 200 * 0.005 = 1.00 + // Invocations: 5M * 0.0000002 = 1.00 + // Total: ~9.00 before free tier + assert!(estimate.by_service.contains_key(&ServiceType::Compute)); + + let compute = &estimate.by_service[&ServiceType::Compute]; + assert_eq!(compute.items.len(), 4); + } + + #[tokio::test] + async fn test_tier_discount() { + let estimator = setup_estimator(); + + let projection = UsageProjection::new() + .with_storage(dec!(1000), dec!(500)) + .with_tier("premium"); // 20% discount + + let estimate = estimator.estimate(projection).await.unwrap(); + + assert_eq!(estimate.tier, "premium"); + assert!(estimate.tier_discount > Decimal::ZERO); + } + + #[test] + fn test_quick_estimate() { + let estimator = setup_estimator(); + + let cost = estimator + .quick_estimate(ServiceType::Compute, ResourceUnit::GpuHours, dec!(10)) + .unwrap(); + + assert_eq!(cost, dec!(5.00)); // 10 * 0.50 + } +} diff --git a/crates/synor-economics/src/calculator/mod.rs b/crates/synor-economics/src/calculator/mod.rs new file mode 100644 index 0000000..7cb897d --- /dev/null +++ b/crates/synor-economics/src/calculator/mod.rs @@ -0,0 +1,7 @@ +//! Cost Calculator +//! +//! Estimate costs before resource consumption. + +mod estimator; + +pub use estimator::{CostEstimate, CostEstimator, UsageProjection}; diff --git a/crates/synor-economics/src/error.rs b/crates/synor-economics/src/error.rs new file mode 100644 index 0000000..09ba811 --- /dev/null +++ b/crates/synor-economics/src/error.rs @@ -0,0 +1,142 @@ +//! Error types for Synor Economics + +use thiserror::Error; + +/// Result type for economics operations +pub type Result = std::result::Result; + +/// Economics error types +#[derive(Debug, Error)] +pub enum EconomicsError { + /// Price feed unavailable + #[error("Price feed unavailable: {0}")] + PriceFeedUnavailable(String), + + /// Price too stale + #[error("Price data stale: last update {0} ago")] + PriceStale(String), + + /// Invalid price + #[error("Invalid price: {0}")] + InvalidPrice(String), + + /// Insufficient balance + #[error("Insufficient balance: required {required}, available {available}")] + InsufficientBalance { + required: String, + available: String, + }, + + /// Account not found + #[error("Account not found: {0}")] + AccountNotFound(String), + + /// Invoice not found + #[error("Invoice not found: {0}")] + InvoiceNotFound(String), + + /// Invoice already paid + #[error("Invoice {0} already paid")] + InvoiceAlreadyPaid(String), + + /// Payment failed + #[error("Payment failed: {0}")] + PaymentFailed(String), + + /// Invalid metering event + #[error("Invalid metering event: {0}")] + InvalidMeteringEvent(String), + + /// Usage limit exceeded + #[error("Usage limit exceeded: {resource} limit is {limit}, requested {requested}")] + UsageLimitExceeded { + resource: String, + limit: String, + requested: String, + }, + + /// Invalid fee distribution + #[error("Invalid fee distribution: {0}")] + InvalidFeeDistribution(String), + + /// Invalid pricing tier + #[error("Invalid pricing tier: {0}")] + InvalidPricingTier(String), + + /// Discount expired + #[error("Discount {0} has expired")] + DiscountExpired(String), + + /// Discount not applicable + #[error("Discount not applicable: {0}")] + DiscountNotApplicable(String), + + /// Service not configured + #[error("Service not configured: {0}")] + ServiceNotConfigured(String), + + /// Rate limit exceeded + #[error("Rate limit exceeded: {0}")] + RateLimitExceeded(String), + + /// Serialization error + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// IO error + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// Database error + #[error("Database error: {0}")] + Database(String), + + /// Configuration error + #[error("Configuration error: {0}")] + Configuration(String), + + /// Internal error + #[error("Internal error: {0}")] + Internal(String), +} + +impl EconomicsError { + /// Check if the error is retryable + pub fn is_retryable(&self) -> bool { + matches!( + self, + EconomicsError::PriceFeedUnavailable(_) + | EconomicsError::PaymentFailed(_) + | EconomicsError::Database(_) + | EconomicsError::Io(_) + | EconomicsError::RateLimitExceeded(_) + ) + } + + /// Get error code for API responses + pub fn error_code(&self) -> &'static str { + match self { + EconomicsError::PriceFeedUnavailable(_) => "PRICE_FEED_UNAVAILABLE", + EconomicsError::PriceStale(_) => "PRICE_STALE", + EconomicsError::InvalidPrice(_) => "INVALID_PRICE", + EconomicsError::InsufficientBalance { .. } => "INSUFFICIENT_BALANCE", + EconomicsError::AccountNotFound(_) => "ACCOUNT_NOT_FOUND", + EconomicsError::InvoiceNotFound(_) => "INVOICE_NOT_FOUND", + EconomicsError::InvoiceAlreadyPaid(_) => "INVOICE_ALREADY_PAID", + EconomicsError::PaymentFailed(_) => "PAYMENT_FAILED", + EconomicsError::InvalidMeteringEvent(_) => "INVALID_METERING_EVENT", + EconomicsError::UsageLimitExceeded { .. } => "USAGE_LIMIT_EXCEEDED", + EconomicsError::InvalidFeeDistribution(_) => "INVALID_FEE_DISTRIBUTION", + EconomicsError::InvalidPricingTier(_) => "INVALID_PRICING_TIER", + EconomicsError::DiscountExpired(_) => "DISCOUNT_EXPIRED", + EconomicsError::DiscountNotApplicable(_) => "DISCOUNT_NOT_APPLICABLE", + EconomicsError::ServiceNotConfigured(_) => "SERVICE_NOT_CONFIGURED", + EconomicsError::RateLimitExceeded(_) => "RATE_LIMIT_EXCEEDED", + EconomicsError::Serialization(_) => "SERIALIZATION_ERROR", + EconomicsError::Io(_) => "IO_ERROR", + EconomicsError::Database(_) => "DATABASE_ERROR", + EconomicsError::Configuration(_) => "CONFIGURATION_ERROR", + EconomicsError::Internal(_) => "INTERNAL_ERROR", + } + } +} diff --git a/crates/synor-economics/src/lib.rs b/crates/synor-economics/src/lib.rs new file mode 100644 index 0000000..0579243 --- /dev/null +++ b/crates/synor-economics/src/lib.rs @@ -0,0 +1,388 @@ +//! # Synor Economics +//! +//! Economics, pricing, metering, and billing infrastructure for Synor L2 services. +//! +//! ## Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────┐ +//! │ SYNOR ECONOMICS LAYER │ +//! ├─────────────────────────────────────────────────────────────┤ +//! │ Billing Engine: Invoice → Payment → Credit │ +//! │ Metering: Storage | Hosting | Database | Compute | Network │ +//! │ Pricing: Oracle → TWAP → Tiers → Discounts │ +//! └─────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! ## Components +//! +//! - **Pricing Oracle**: Aggregates SYNOR/USD prices from multiple sources +//! - **Metering Service**: Tracks real-time resource usage across L2 services +//! - **Billing Engine**: Generates invoices, processes payments, manages credits +//! - **Cost Calculator**: Estimates costs before resource consumption + +pub mod billing; +pub mod calculator; +pub mod error; +pub mod metering; +pub mod oracle; +pub mod pricing; + +pub use billing::{BillingEngine, Credit, Invoice, InvoiceStatus, Payment, PaymentMethod}; +pub use calculator::{CostEstimate, CostEstimator, UsageProjection}; +pub use error::{EconomicsError, Result}; +pub use metering::{ + ComputeUsage, DatabaseUsage, HostingUsage, MeteringService, NetworkUsage, StorageUsage, + UsageEvent, UsageRecord, +}; +pub use oracle::{PriceFeed, PriceOracle, PriceSource, TokenPrice}; +pub use pricing::{Discount, DiscountType, PricingEngine, PricingTier, ServicePricing}; + +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Synor token amount in smallest unit (1 SYNOR = 10^18 units) +pub type SynorAmount = u128; + +/// SYNOR decimal representation (for UI display) +pub type SynorDecimal = Decimal; + +/// Account identifier +pub type AccountId = String; + +/// Service identifier +pub type ServiceId = String; + +/// Fee distribution configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeeDistribution { + /// Percentage burned (deflationary) + pub burn_rate: Decimal, + /// Percentage to stakers + pub staker_rate: Decimal, + /// Percentage to community treasury + pub treasury_rate: Decimal, + /// Percentage to validators/node operators + pub operator_rate: Decimal, +} + +impl Default for FeeDistribution { + fn default() -> Self { + Self::transaction_fees() + } +} + +impl FeeDistribution { + /// Transaction fee distribution (L1) + pub fn transaction_fees() -> Self { + Self { + burn_rate: Decimal::new(10, 2), // 10% + staker_rate: Decimal::new(60, 2), // 60% + treasury_rate: Decimal::new(20, 2), // 20% + operator_rate: Decimal::new(10, 2), // 10% + } + } + + /// L2 service fee distribution + pub fn l2_service_fees() -> Self { + Self { + burn_rate: Decimal::new(10, 2), // 10% + staker_rate: Decimal::ZERO, // 0% + treasury_rate: Decimal::new(20, 2), // 20% + operator_rate: Decimal::new(70, 2), // 70% + } + } + + /// Calculate distribution amounts from a total fee + pub fn distribute(&self, total: SynorDecimal) -> FeeBreakdown { + FeeBreakdown { + burn: total * self.burn_rate, + stakers: total * self.staker_rate, + treasury: total * self.treasury_rate, + operators: total * self.operator_rate, + total, + } + } + + /// Validate that rates sum to 100% + pub fn validate(&self) -> Result<()> { + let sum = self.burn_rate + self.staker_rate + self.treasury_rate + self.operator_rate; + if sum != Decimal::ONE { + return Err(EconomicsError::InvalidFeeDistribution(format!( + "Rates must sum to 100%, got {}%", + sum * Decimal::ONE_HUNDRED + ))); + } + Ok(()) + } +} + +/// Breakdown of fee distribution +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeeBreakdown { + pub burn: SynorDecimal, + pub stakers: SynorDecimal, + pub treasury: SynorDecimal, + pub operators: SynorDecimal, + pub total: SynorDecimal, +} + +/// Service type for metering and billing +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ServiceType { + /// Storage L2 + Storage, + /// Web hosting + Hosting, + /// Database L2 + Database, + /// Compute L2 (CPU/GPU/TPU) + Compute, + /// Network bandwidth + Network, + /// Smart contract execution + Contract, +} + +impl ServiceType { + /// Get the fee distribution model for this service type + pub fn fee_distribution(&self) -> FeeDistribution { + match self { + ServiceType::Contract => FeeDistribution::transaction_fees(), + _ => FeeDistribution::l2_service_fees(), + } + } +} + +impl std::fmt::Display for ServiceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ServiceType::Storage => write!(f, "storage"), + ServiceType::Hosting => write!(f, "hosting"), + ServiceType::Database => write!(f, "database"), + ServiceType::Compute => write!(f, "compute"), + ServiceType::Network => write!(f, "network"), + ServiceType::Contract => write!(f, "contract"), + } + } +} + +/// Resource unit for pricing and metering +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResourceUnit { + /// Storage: bytes + Bytes, + /// Storage: gigabytes per month + GbMonth, + /// Database: queries + Queries, + /// Database: vector searches + VectorSearches, + /// Compute: CPU core hours + CpuCoreHours, + /// Compute: GPU hours + GpuHours, + /// Compute: memory GB hours + MemoryGbHours, + /// Compute: serverless invocations + Invocations, + /// Network: bandwidth in GB + BandwidthGb, + /// Hosting: custom domains + Domains, + /// Contract: gas units + Gas, +} + +impl std::fmt::Display for ResourceUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResourceUnit::Bytes => write!(f, "bytes"), + ResourceUnit::GbMonth => write!(f, "GB/month"), + ResourceUnit::Queries => write!(f, "queries"), + ResourceUnit::VectorSearches => write!(f, "vector searches"), + ResourceUnit::CpuCoreHours => write!(f, "CPU core hours"), + ResourceUnit::GpuHours => write!(f, "GPU hours"), + ResourceUnit::MemoryGbHours => write!(f, "memory GB hours"), + ResourceUnit::Invocations => write!(f, "invocations"), + ResourceUnit::BandwidthGb => write!(f, "GB bandwidth"), + ResourceUnit::Domains => write!(f, "domains"), + ResourceUnit::Gas => write!(f, "gas"), + } + } +} + +/// Account balance and usage summary +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountSummary { + pub account_id: AccountId, + pub balance: SynorDecimal, + pub credit_balance: SynorDecimal, + pub prepaid_balance: SynorDecimal, + pub current_period_usage: HashMap, + pub current_period_cost: SynorDecimal, + pub billing_tier: String, + pub last_payment: Option>, + pub next_invoice_date: Option>, +} + +/// Unified economics manager combining all services +pub struct EconomicsManager { + pub oracle: Arc>, + pub metering: Arc, + pub billing: Arc, + pub pricing: Arc, + pub calculator: Arc, +} + +impl EconomicsManager { + /// Create a new economics manager with default configuration + pub fn new() -> Self { + let oracle = Arc::new(RwLock::new(PriceOracle::new())); + let pricing = Arc::new(PricingEngine::new()); + let metering = Arc::new(MeteringService::new(pricing.clone())); + let billing = Arc::new(BillingEngine::new(metering.clone(), pricing.clone())); + let calculator = Arc::new(CostEstimator::new(pricing.clone())); + + Self { + oracle, + metering, + billing, + pricing, + calculator, + } + } + + /// Get account summary including balance, usage, and billing info + pub async fn get_account_summary(&self, account_id: &str) -> Result { + let usage = self.metering.get_account_usage(account_id).await?; + let billing_info = self.billing.get_account_info(account_id).await?; + + let mut current_period_usage = HashMap::new(); + let mut current_period_cost = Decimal::ZERO; + + for (service_type, amount) in usage.by_service.iter() { + current_period_usage.insert(*service_type, *amount); + current_period_cost += *amount; + } + + Ok(AccountSummary { + account_id: account_id.to_string(), + balance: billing_info.balance, + credit_balance: billing_info.credit_balance, + prepaid_balance: billing_info.prepaid_balance, + current_period_usage, + current_period_cost, + billing_tier: billing_info.tier_name, + last_payment: billing_info.last_payment, + next_invoice_date: billing_info.next_invoice, + }) + } + + /// Estimate cost for a usage projection + pub async fn estimate_cost(&self, projection: UsageProjection) -> Result { + self.calculator.estimate(projection).await + } + + /// Record usage event + pub async fn record_usage(&self, event: UsageEvent) -> Result<()> { + self.metering.record(event).await + } + + /// Update price from external feed + pub async fn update_price(&self, price: TokenPrice) -> Result<()> { + let mut oracle = self.oracle.write().await; + oracle.update_price(price) + } + + /// Get current SYNOR/USD price + pub async fn get_synor_price(&self) -> Result { + let oracle = self.oracle.read().await; + oracle.get_price("SYNOR", "USD") + } + + /// Generate invoice for account + pub async fn generate_invoice(&self, account_id: &str) -> Result { + self.billing.generate_invoice(account_id).await + } + + /// Process payment + pub async fn process_payment(&self, payment: Payment) -> Result<()> { + self.billing.process_payment(payment).await + } +} + +impl Default for EconomicsManager { + fn default() -> Self { + Self::new() + } +} + +/// Convert raw amount to SYNOR decimal (18 decimals) +pub fn to_synor_decimal(raw: SynorAmount) -> SynorDecimal { + Decimal::from(raw) / Decimal::from(10u128.pow(18)) +} + +/// Convert SYNOR decimal to raw amount +pub fn from_synor_decimal(decimal: SynorDecimal) -> SynorAmount { + let scaled = decimal * Decimal::from(10u128.pow(18)); + scaled.try_into().unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_fee_distribution_transaction() { + let dist = FeeDistribution::transaction_fees(); + assert!(dist.validate().is_ok()); + + let breakdown = dist.distribute(dec!(100)); + assert_eq!(breakdown.burn, dec!(10)); + assert_eq!(breakdown.stakers, dec!(60)); + assert_eq!(breakdown.treasury, dec!(20)); + assert_eq!(breakdown.operators, dec!(10)); + } + + #[test] + fn test_fee_distribution_l2() { + let dist = FeeDistribution::l2_service_fees(); + assert!(dist.validate().is_ok()); + + let breakdown = dist.distribute(dec!(100)); + assert_eq!(breakdown.burn, dec!(10)); + assert_eq!(breakdown.stakers, dec!(0)); + assert_eq!(breakdown.treasury, dec!(20)); + assert_eq!(breakdown.operators, dec!(70)); + } + + #[test] + fn test_synor_decimal_conversion() { + // 1 SYNOR = 10^18 units + let one_synor = 1_000_000_000_000_000_000u128; + let decimal = to_synor_decimal(one_synor); + assert_eq!(decimal, Decimal::ONE); + + let back = from_synor_decimal(decimal); + assert_eq!(back, one_synor); + } + + #[test] + fn test_invalid_fee_distribution() { + let invalid = FeeDistribution { + burn_rate: dec!(0.50), + staker_rate: dec!(0.50), + treasury_rate: dec!(0.10), + operator_rate: dec!(0.10), + }; + assert!(invalid.validate().is_err()); + } +} diff --git a/crates/synor-economics/src/metering/compute.rs b/crates/synor-economics/src/metering/compute.rs new file mode 100644 index 0000000..e75f821 --- /dev/null +++ b/crates/synor-economics/src/metering/compute.rs @@ -0,0 +1,137 @@ +//! Compute L2 Usage Metering + +use serde::{Deserialize, Serialize}; + +/// Compute service usage metrics +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ComputeUsage { + /// CPU core-seconds consumed + pub cpu_core_seconds: u64, + /// GPU seconds consumed + pub gpu_seconds: u64, + /// Memory GB-seconds consumed + pub memory_gb_seconds: u64, + /// Number of serverless invocations + pub invocations: u64, + /// TPU seconds consumed + pub tpu_seconds: u64, +} + +impl ComputeUsage { + /// Create new compute usage + pub fn new() -> Self { + Self::default() + } + + /// Add CPU usage + pub fn with_cpu(mut self, core_seconds: u64) -> Self { + self.cpu_core_seconds = core_seconds; + self + } + + /// Add GPU usage + pub fn with_gpu(mut self, seconds: u64) -> Self { + self.gpu_seconds = seconds; + self + } + + /// Add memory usage + pub fn with_memory(mut self, gb_seconds: u64) -> Self { + self.memory_gb_seconds = gb_seconds; + self + } + + /// Add serverless invocations + pub fn with_invocations(mut self, count: u64) -> Self { + self.invocations = count; + self + } + + /// Add TPU usage + pub fn with_tpu(mut self, seconds: u64) -> Self { + self.tpu_seconds = seconds; + self + } + + /// Get CPU core-hours + pub fn cpu_core_hours(&self) -> f64 { + self.cpu_core_seconds as f64 / 3600.0 + } + + /// Get GPU hours + pub fn gpu_hours(&self) -> f64 { + self.gpu_seconds as f64 / 3600.0 + } + + /// Get memory GB-hours + pub fn memory_gb_hours(&self) -> f64 { + self.memory_gb_seconds as f64 / 3600.0 + } + + /// Get TPU hours + pub fn tpu_hours(&self) -> f64 { + self.tpu_seconds as f64 / 3600.0 + } + + /// Get invocations in millions + pub fn invocations_millions(&self) -> f64 { + self.invocations as f64 / 1_000_000.0 + } +} + +/// GPU type for pricing differentiation +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GpuType { + /// NVIDIA RTX 3090 + Rtx3090, + /// NVIDIA RTX 4090 + Rtx4090, + /// NVIDIA A100 + A100, + /// NVIDIA H100 + H100, + /// AMD MI250 + Mi250, + /// Generic GPU + Generic, +} + +impl GpuType { + /// Get price multiplier relative to base GPU price + pub fn price_multiplier(&self) -> f64 { + match self { + GpuType::Rtx3090 => 0.8, + GpuType::Rtx4090 => 1.0, + GpuType::A100 => 2.5, + GpuType::H100 => 4.0, + GpuType::Mi250 => 2.0, + GpuType::Generic => 0.5, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_usage() { + let usage = ComputeUsage::new() + .with_cpu(7200) // 2 core-hours + .with_gpu(3600) // 1 GPU hour + .with_memory(14400) // 4 GB-hours + .with_invocations(1_000_000); + + assert_eq!(usage.cpu_core_hours(), 2.0); + assert_eq!(usage.gpu_hours(), 1.0); + assert_eq!(usage.memory_gb_hours(), 4.0); + assert_eq!(usage.invocations_millions(), 1.0); + } + + #[test] + fn test_gpu_pricing() { + assert!(GpuType::H100.price_multiplier() > GpuType::Rtx4090.price_multiplier()); + assert!(GpuType::Rtx4090.price_multiplier() > GpuType::Generic.price_multiplier()); + } +} diff --git a/crates/synor-economics/src/metering/database.rs b/crates/synor-economics/src/metering/database.rs new file mode 100644 index 0000000..942c2c6 --- /dev/null +++ b/crates/synor-economics/src/metering/database.rs @@ -0,0 +1,89 @@ +//! Database L2 Usage Metering + +use serde::{Deserialize, Serialize}; + +/// Database service usage metrics +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DatabaseUsage { + /// Number of queries executed + pub queries: u64, + /// Number of vector searches + pub vector_searches: u64, + /// Storage used in bytes + pub storage_bytes: u64, + /// Number of documents stored + pub documents: u64, + /// Number of indexes maintained + pub indexes: u32, +} + +impl DatabaseUsage { + /// Create new database usage + pub fn new() -> Self { + Self::default() + } + + /// Add queries + pub fn with_queries(mut self, count: u64) -> Self { + self.queries = count; + self + } + + /// Add vector searches + pub fn with_vector_searches(mut self, count: u64) -> Self { + self.vector_searches = count; + self + } + + /// Add storage + pub fn with_storage(mut self, bytes: u64) -> Self { + self.storage_bytes = bytes; + self + } + + /// Add documents + pub fn with_documents(mut self, count: u64) -> Self { + self.documents = count; + self + } + + /// Add indexes + pub fn with_indexes(mut self, count: u32) -> Self { + self.indexes = count; + self + } + + /// Convert storage to GB + pub fn storage_gb(&self) -> f64 { + self.storage_bytes as f64 / 1_073_741_824.0 + } + + /// Get queries in millions + pub fn queries_millions(&self) -> f64 { + self.queries as f64 / 1_000_000.0 + } + + /// Get vector searches in millions + pub fn vector_searches_millions(&self) -> f64 { + self.vector_searches as f64 / 1_000_000.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_database_usage() { + let usage = DatabaseUsage::new() + .with_queries(5_000_000) + .with_vector_searches(100_000) + .with_storage(536_870_912) // 0.5 GB + .with_documents(10_000) + .with_indexes(5); + + assert_eq!(usage.queries_millions(), 5.0); + assert_eq!(usage.vector_searches_millions(), 0.1); + assert_eq!(usage.storage_gb(), 0.5); + } +} diff --git a/crates/synor-economics/src/metering/hosting.rs b/crates/synor-economics/src/metering/hosting.rs new file mode 100644 index 0000000..1138454 --- /dev/null +++ b/crates/synor-economics/src/metering/hosting.rs @@ -0,0 +1,70 @@ +//! Hosting Service Usage Metering + +use serde::{Deserialize, Serialize}; + +/// Hosting service usage metrics +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HostingUsage { + /// Bandwidth consumed in bytes + pub bandwidth_bytes: u64, + /// Number of custom domains active + pub custom_domains: u32, + /// Number of HTTP requests + pub requests: u64, + /// Number of unique visitors + pub unique_visitors: u64, +} + +impl HostingUsage { + /// Create new hosting usage + pub fn new() -> Self { + Self::default() + } + + /// Add bandwidth + pub fn with_bandwidth(mut self, bytes: u64) -> Self { + self.bandwidth_bytes = bytes; + self + } + + /// Add custom domains + pub fn with_domains(mut self, count: u32) -> Self { + self.custom_domains = count; + self + } + + /// Add requests + pub fn with_requests(mut self, count: u64) -> Self { + self.requests = count; + self + } + + /// Add unique visitors + pub fn with_visitors(mut self, count: u64) -> Self { + self.unique_visitors = count; + self + } + + /// Convert bandwidth to GB + pub fn bandwidth_gb(&self) -> f64 { + self.bandwidth_bytes as f64 / 1_073_741_824.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hosting_usage() { + let usage = HostingUsage::new() + .with_bandwidth(10_737_418_240) // 10 GB + .with_domains(3) + .with_requests(100_000) + .with_visitors(5_000); + + assert_eq!(usage.bandwidth_gb(), 10.0); + assert_eq!(usage.custom_domains, 3); + assert_eq!(usage.requests, 100_000); + } +} diff --git a/crates/synor-economics/src/metering/mod.rs b/crates/synor-economics/src/metering/mod.rs new file mode 100644 index 0000000..c73c53b --- /dev/null +++ b/crates/synor-economics/src/metering/mod.rs @@ -0,0 +1,559 @@ +//! Metering Service +//! +//! Real-time usage tracking for all Synor L2 services: +//! - Storage L2: bytes stored, retrieved, deals created +//! - Hosting: bandwidth, domains +//! - Database L2: queries, vector searches, storage +//! - Compute L2: CPU hours, GPU hours, memory, invocations +//! - Network: bandwidth consumed + +mod compute; +mod database; +mod hosting; +mod network; +mod storage; + +pub use compute::ComputeUsage; +pub use database::DatabaseUsage; +pub use hosting::HostingUsage; +pub use network::NetworkUsage; +pub use storage::StorageUsage; + +use crate::error::{EconomicsError, Result}; +use crate::pricing::PricingEngine; +use crate::{AccountId, ResourceUnit, ServiceId, ServiceType, SynorDecimal}; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Usage event from L2 services +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageEvent { + /// Unique event ID + pub id: String, + /// Account being charged + pub account_id: AccountId, + /// Service generating the usage + pub service_id: ServiceId, + /// Type of service + pub service_type: ServiceType, + /// Resource unit consumed + pub resource_unit: ResourceUnit, + /// Amount consumed + pub amount: Decimal, + /// Timestamp + pub timestamp: DateTime, + /// Additional metadata + pub metadata: HashMap, +} + +impl UsageEvent { + /// Create a new usage event + pub fn new( + account_id: impl Into, + service_type: ServiceType, + resource_unit: ResourceUnit, + amount: Decimal, + ) -> Self { + Self { + id: generate_event_id(), + account_id: account_id.into(), + service_id: String::new(), + service_type, + resource_unit, + amount, + timestamp: Utc::now(), + metadata: HashMap::new(), + } + } + + /// Add service ID + pub fn with_service_id(mut self, service_id: impl Into) -> Self { + self.service_id = service_id.into(); + self + } + + /// Add metadata + pub fn with_metadata(mut self, key: impl Into, value: impl Into) -> Self { + self.metadata.insert(key.into(), value.into()); + self + } +} + +/// Generate unique event ID +fn generate_event_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("evt_{:x}", nanos) +} + +/// Aggregated usage record for a billing period +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageRecord { + pub account_id: AccountId, + pub period_start: DateTime, + pub period_end: DateTime, + pub by_service: HashMap, + pub by_resource: HashMap, + pub total_cost: SynorDecimal, + pub event_count: usize, +} + +impl UsageRecord { + /// Create empty usage record for a period + pub fn new(account_id: impl Into, start: DateTime, end: DateTime) -> Self { + Self { + account_id: account_id.into(), + period_start: start, + period_end: end, + by_service: HashMap::new(), + by_resource: HashMap::new(), + total_cost: Decimal::ZERO, + event_count: 0, + } + } +} + +/// Account usage summary +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountUsage { + pub account_id: AccountId, + pub by_service: HashMap, + pub current_period_start: DateTime, + pub last_event: Option>, +} + +/// Metering service configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeteringConfig { + /// Aggregation interval (default: 1 hour) + pub aggregation_interval_secs: u64, + /// Maximum events to buffer before flush + pub buffer_size: usize, + /// Enable real-time usage tracking + pub real_time_tracking: bool, + /// Event retention period in days + pub retention_days: u32, +} + +impl Default for MeteringConfig { + fn default() -> Self { + Self { + aggregation_interval_secs: 3600, // 1 hour + buffer_size: 10000, + real_time_tracking: true, + retention_days: 90, + } + } +} + +/// Metering service for tracking resource usage +pub struct MeteringService { + config: MeteringConfig, + pricing: Arc, + /// Buffered events pending aggregation + event_buffer: RwLock>, + /// Current period usage by account + current_usage: RwLock>, + /// Historical aggregated records + records: RwLock>, + /// Event channel for real-time processing + event_tx: async_channel::Sender, + event_rx: async_channel::Receiver, +} + +impl MeteringService { + /// Create a new metering service + pub fn new(pricing: Arc) -> Self { + let (event_tx, event_rx) = async_channel::bounded(10000); + + Self { + config: MeteringConfig::default(), + pricing, + event_buffer: RwLock::new(Vec::new()), + current_usage: RwLock::new(HashMap::new()), + records: RwLock::new(Vec::new()), + event_tx, + event_rx, + } + } + + /// Create with custom configuration + pub fn with_config(pricing: Arc, config: MeteringConfig) -> Self { + let (event_tx, event_rx) = async_channel::bounded(config.buffer_size); + + Self { + config, + pricing, + event_buffer: RwLock::new(Vec::new()), + current_usage: RwLock::new(HashMap::new()), + records: RwLock::new(Vec::new()), + event_tx, + event_rx, + } + } + + /// Record a usage event + pub async fn record(&self, event: UsageEvent) -> Result<()> { + // Validate event + if event.amount <= Decimal::ZERO { + return Err(EconomicsError::InvalidMeteringEvent( + "Amount must be positive".to_string(), + )); + } + + // Calculate cost for this event + let cost = self.pricing.calculate_cost( + event.service_type, + event.resource_unit, + event.amount, + )?; + + // Update current usage + { + let mut usage = self.current_usage.write().await; + let account_usage = usage.entry(event.account_id.clone()).or_insert_with(|| { + AccountUsage { + account_id: event.account_id.clone(), + by_service: HashMap::new(), + current_period_start: Utc::now(), + last_event: None, + } + }); + + *account_usage + .by_service + .entry(event.service_type) + .or_insert(Decimal::ZERO) += cost; + + account_usage.last_event = Some(event.timestamp); + } + + // Buffer event for aggregation + { + let mut buffer = self.event_buffer.write().await; + buffer.push(event.clone()); + + // Flush if buffer is full + if buffer.len() >= self.config.buffer_size { + self.flush_buffer(&mut buffer).await?; + } + } + + // Send to event channel for real-time processing + if self.config.real_time_tracking { + let _ = self.event_tx.try_send(event); + } + + Ok(()) + } + + /// Record storage usage + pub async fn record_storage(&self, account_id: &str, usage: StorageUsage) -> Result<()> { + // Storage: bytes stored + if usage.bytes_stored > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Storage, + ResourceUnit::Bytes, + Decimal::from(usage.bytes_stored), + )).await?; + } + + // Storage: bytes retrieved + if usage.bytes_retrieved > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Storage, + ResourceUnit::BandwidthGb, + Decimal::from(usage.bytes_retrieved) / Decimal::from(1_073_741_824u64), // to GB + )).await?; + } + + Ok(()) + } + + /// Record hosting usage + pub async fn record_hosting(&self, account_id: &str, usage: HostingUsage) -> Result<()> { + // Bandwidth + if usage.bandwidth_bytes > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Hosting, + ResourceUnit::BandwidthGb, + Decimal::from(usage.bandwidth_bytes) / Decimal::from(1_073_741_824u64), + )).await?; + } + + // Custom domains + if usage.custom_domains > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Hosting, + ResourceUnit::Domains, + Decimal::from(usage.custom_domains), + )).await?; + } + + Ok(()) + } + + /// Record database usage + pub async fn record_database(&self, account_id: &str, usage: DatabaseUsage) -> Result<()> { + // Queries + if usage.queries > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Database, + ResourceUnit::Queries, + Decimal::from(usage.queries), + )).await?; + } + + // Vector searches + if usage.vector_searches > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Database, + ResourceUnit::VectorSearches, + Decimal::from(usage.vector_searches), + )).await?; + } + + // Storage + if usage.storage_bytes > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Database, + ResourceUnit::GbMonth, + Decimal::from(usage.storage_bytes) / Decimal::from(1_073_741_824u64), + )).await?; + } + + Ok(()) + } + + /// Record compute usage + pub async fn record_compute(&self, account_id: &str, usage: ComputeUsage) -> Result<()> { + // CPU hours + if usage.cpu_core_seconds > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Compute, + ResourceUnit::CpuCoreHours, + Decimal::from(usage.cpu_core_seconds) / Decimal::from(3600), + )).await?; + } + + // GPU hours + if usage.gpu_seconds > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Compute, + ResourceUnit::GpuHours, + Decimal::from(usage.gpu_seconds) / Decimal::from(3600), + )).await?; + } + + // Memory GB hours + if usage.memory_gb_seconds > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Compute, + ResourceUnit::MemoryGbHours, + Decimal::from(usage.memory_gb_seconds) / Decimal::from(3600), + )).await?; + } + + // Invocations (serverless) + if usage.invocations > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Compute, + ResourceUnit::Invocations, + Decimal::from(usage.invocations), + )).await?; + } + + Ok(()) + } + + /// Record network usage + pub async fn record_network(&self, account_id: &str, usage: NetworkUsage) -> Result<()> { + let total_bytes = usage.ingress_bytes + usage.egress_bytes; + if total_bytes > 0 { + self.record(UsageEvent::new( + account_id, + ServiceType::Network, + ResourceUnit::BandwidthGb, + Decimal::from(total_bytes) / Decimal::from(1_073_741_824u64), + )).await?; + } + + Ok(()) + } + + /// Get current usage for an account + pub async fn get_account_usage(&self, account_id: &str) -> Result { + let usage = self.current_usage.read().await; + usage + .get(account_id) + .cloned() + .ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string())) + } + + /// Get usage for a specific billing period + pub async fn get_period_usage( + &self, + account_id: &str, + start: DateTime, + end: DateTime, + ) -> Result { + // Aggregate from buffer and records + let mut record = UsageRecord::new(account_id, start, end); + + // Check buffered events + let buffer = self.event_buffer.read().await; + for event in buffer.iter() { + if event.account_id == account_id + && event.timestamp >= start + && event.timestamp < end + { + let cost = self.pricing.calculate_cost( + event.service_type, + event.resource_unit, + event.amount, + )?; + + *record + .by_service + .entry(event.service_type) + .or_insert(Decimal::ZERO) += cost; + + *record + .by_resource + .entry(event.resource_unit) + .or_insert(Decimal::ZERO) += event.amount; + + record.total_cost += cost; + record.event_count += 1; + } + } + + Ok(record) + } + + /// Flush event buffer to storage + async fn flush_buffer(&self, buffer: &mut Vec) -> Result<()> { + if buffer.is_empty() { + return Ok(()); + } + + tracing::info!("Flushing {} metering events", buffer.len()); + + // In production, this would persist to database + // For now, we just aggregate and clear + + buffer.clear(); + + Ok(()) + } + + /// Reset usage for a new billing period + pub async fn reset_period(&self, account_id: &str) -> Result<()> { + let mut usage = self.current_usage.write().await; + if let Some(account_usage) = usage.get_mut(account_id) { + account_usage.by_service.clear(); + account_usage.current_period_start = Utc::now(); + } + Ok(()) + } + + /// Get event receiver for real-time processing + pub fn subscribe(&self) -> async_channel::Receiver { + self.event_rx.clone() + } + + /// Get metering stats + pub async fn stats(&self) -> MeteringStats { + let buffer = self.event_buffer.read().await; + let usage = self.current_usage.read().await; + + MeteringStats { + buffer_size: buffer.len(), + active_accounts: usage.len(), + total_events_buffered: buffer.len(), + } + } +} + +/// Metering service statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeteringStats { + pub buffer_size: usize, + pub active_accounts: usize, + pub total_events_buffered: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[tokio::test] + async fn test_record_usage_event() { + let pricing = Arc::new(PricingEngine::new()); + let metering = MeteringService::new(pricing); + + let event = UsageEvent::new( + "account_123", + ServiceType::Storage, + ResourceUnit::Bytes, + dec!(1000000), + ); + + metering.record(event).await.unwrap(); + + let usage = metering.get_account_usage("account_123").await.unwrap(); + assert!(usage.by_service.contains_key(&ServiceType::Storage)); + } + + #[tokio::test] + async fn test_storage_usage() { + let pricing = Arc::new(PricingEngine::new()); + let metering = MeteringService::new(pricing); + + let usage = StorageUsage { + bytes_stored: 1_000_000, + bytes_retrieved: 500_000, + deals_created: 1, + }; + + metering.record_storage("account_456", usage).await.unwrap(); + + let account_usage = metering.get_account_usage("account_456").await.unwrap(); + assert!(account_usage.by_service.contains_key(&ServiceType::Storage)); + } + + #[tokio::test] + async fn test_invalid_amount() { + let pricing = Arc::new(PricingEngine::new()); + let metering = MeteringService::new(pricing); + + let event = UsageEvent::new( + "account_789", + ServiceType::Storage, + ResourceUnit::Bytes, + dec!(-100), // Invalid: negative + ); + + let result = metering.record(event).await; + assert!(result.is_err()); + } +} diff --git a/crates/synor-economics/src/metering/network.rs b/crates/synor-economics/src/metering/network.rs new file mode 100644 index 0000000..963d781 --- /dev/null +++ b/crates/synor-economics/src/metering/network.rs @@ -0,0 +1,85 @@ +//! Network Usage Metering + +use serde::{Deserialize, Serialize}; + +/// Network bandwidth usage metrics +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NetworkUsage { + /// Ingress bytes (incoming) + pub ingress_bytes: u64, + /// Egress bytes (outgoing) + pub egress_bytes: u64, + /// Number of P2P connections + pub connections: u32, + /// Cross-shard messages sent + pub cross_shard_messages: u64, +} + +impl NetworkUsage { + /// Create new network usage + pub fn new() -> Self { + Self::default() + } + + /// Add ingress bandwidth + pub fn with_ingress(mut self, bytes: u64) -> Self { + self.ingress_bytes = bytes; + self + } + + /// Add egress bandwidth + pub fn with_egress(mut self, bytes: u64) -> Self { + self.egress_bytes = bytes; + self + } + + /// Add connections + pub fn with_connections(mut self, count: u32) -> Self { + self.connections = count; + self + } + + /// Add cross-shard messages + pub fn with_cross_shard(mut self, count: u64) -> Self { + self.cross_shard_messages = count; + self + } + + /// Get total bandwidth in bytes + pub fn total_bytes(&self) -> u64 { + self.ingress_bytes + self.egress_bytes + } + + /// Get ingress in GB + pub fn ingress_gb(&self) -> f64 { + self.ingress_bytes as f64 / 1_073_741_824.0 + } + + /// Get egress in GB + pub fn egress_gb(&self) -> f64 { + self.egress_bytes as f64 / 1_073_741_824.0 + } + + /// Get total bandwidth in GB + pub fn total_gb(&self) -> f64 { + self.total_bytes() as f64 / 1_073_741_824.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_network_usage() { + let usage = NetworkUsage::new() + .with_ingress(2_147_483_648) // 2 GB + .with_egress(1_073_741_824) // 1 GB + .with_connections(50) + .with_cross_shard(1000); + + assert_eq!(usage.ingress_gb(), 2.0); + assert_eq!(usage.egress_gb(), 1.0); + assert_eq!(usage.total_gb(), 3.0); + } +} diff --git a/crates/synor-economics/src/metering/storage.rs b/crates/synor-economics/src/metering/storage.rs new file mode 100644 index 0000000..b03938f --- /dev/null +++ b/crates/synor-economics/src/metering/storage.rs @@ -0,0 +1,71 @@ +//! Storage L2 Usage Metering + +use serde::{Deserialize, Serialize}; + +/// Storage service usage metrics +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StorageUsage { + /// Bytes stored during period + pub bytes_stored: u64, + /// Bytes retrieved during period + pub bytes_retrieved: u64, + /// Number of storage deals created + pub deals_created: u32, +} + +impl StorageUsage { + /// Create new storage usage + pub fn new() -> Self { + Self::default() + } + + /// Add bytes stored + pub fn with_stored(mut self, bytes: u64) -> Self { + self.bytes_stored = bytes; + self + } + + /// Add bytes retrieved + pub fn with_retrieved(mut self, bytes: u64) -> Self { + self.bytes_retrieved = bytes; + self + } + + /// Add deals created + pub fn with_deals(mut self, count: u32) -> Self { + self.deals_created = count; + self + } + + /// Get total bytes (stored + retrieved) + pub fn total_bytes(&self) -> u64 { + self.bytes_stored + self.bytes_retrieved + } + + /// Convert bytes stored to GB + pub fn stored_gb(&self) -> f64 { + self.bytes_stored as f64 / 1_073_741_824.0 + } + + /// Convert bytes retrieved to GB + pub fn retrieved_gb(&self) -> f64 { + self.bytes_retrieved as f64 / 1_073_741_824.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_storage_usage() { + let usage = StorageUsage::new() + .with_stored(1_073_741_824) // 1 GB + .with_retrieved(536_870_912) // 0.5 GB + .with_deals(5); + + assert_eq!(usage.stored_gb(), 1.0); + assert_eq!(usage.retrieved_gb(), 0.5); + assert_eq!(usage.deals_created, 5); + } +} diff --git a/crates/synor-economics/src/oracle/mod.rs b/crates/synor-economics/src/oracle/mod.rs new file mode 100644 index 0000000..e620ead --- /dev/null +++ b/crates/synor-economics/src/oracle/mod.rs @@ -0,0 +1,453 @@ +//! Price Oracle +//! +//! Aggregates SYNOR/USD prices from multiple sources with TWAP for stability. + +mod price_feed; +mod twap; + +pub use price_feed::{PriceFeed, PriceSource}; +pub use twap::TwapCalculator; + +use crate::error::{EconomicsError, Result}; +use crate::SynorDecimal; +use chrono::{DateTime, Duration, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Token price data point +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenPrice { + /// Token symbol (e.g., "SYNOR") + pub token: String, + /// Quote currency (e.g., "USD") + pub quote: String, + /// Price value + pub price: SynorDecimal, + /// Timestamp + pub timestamp: DateTime, + /// Source of the price + pub source: PriceSource, + /// Confidence score (0.0 - 1.0) + pub confidence: f64, +} + +impl TokenPrice { + /// Create a new token price + pub fn new( + token: impl Into, + quote: impl Into, + price: SynorDecimal, + source: PriceSource, + ) -> Self { + Self { + token: token.into(), + quote: quote.into(), + price, + timestamp: Utc::now(), + source, + confidence: 1.0, + } + } + + /// Check if the price is stale (older than max_age) + pub fn is_stale(&self, max_age: Duration) -> bool { + Utc::now() - self.timestamp > max_age + } + + /// Get price pair key + pub fn pair_key(&self) -> String { + format!("{}/{}", self.token, self.quote) + } +} + +/// Oracle configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OracleConfig { + /// Maximum price age before considered stale + pub max_price_age: Duration, + /// TWAP window duration + pub twap_window: Duration, + /// Minimum number of price sources required + pub min_sources: usize, + /// Maximum deviation between sources (percentage) + pub max_deviation: Decimal, + /// Update interval + pub update_interval: Duration, +} + +impl Default for OracleConfig { + fn default() -> Self { + Self { + max_price_age: Duration::minutes(5), + twap_window: Duration::hours(1), + min_sources: 2, + max_deviation: Decimal::new(5, 2), // 5% + update_interval: Duration::seconds(30), + } + } +} + +/// Aggregated price with metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AggregatedPrice { + /// Spot price (latest) + pub spot: SynorDecimal, + /// Time-weighted average price + pub twap: SynorDecimal, + /// Median price across sources + pub median: SynorDecimal, + /// Number of active sources + pub source_count: usize, + /// Last update timestamp + pub last_update: DateTime, + /// Overall confidence score + pub confidence: f64, +} + +/// Price oracle aggregating multiple sources +pub struct PriceOracle { + config: OracleConfig, + /// Price history keyed by pair (e.g., "SYNOR/USD") + price_history: HashMap>, + /// Active price feeds + feeds: Vec>, + /// TWAP calculator + twap_calculator: TwapCalculator, + /// Cached aggregated prices + cache: HashMap, +} + +impl PriceOracle { + /// Create a new price oracle + pub fn new() -> Self { + Self::with_config(OracleConfig::default()) + } + + /// Create with custom configuration + pub fn with_config(config: OracleConfig) -> Self { + let twap_calculator = TwapCalculator::new(config.twap_window); + Self { + config, + price_history: HashMap::new(), + feeds: Vec::new(), + twap_calculator, + cache: HashMap::new(), + } + } + + /// Add a price feed source + pub fn add_feed(&mut self, feed: Box) { + self.feeds.push(feed); + } + + /// Update price from external source + pub fn update_price(&mut self, price: TokenPrice) -> Result<()> { + // Validate price + if price.price <= Decimal::ZERO { + return Err(EconomicsError::InvalidPrice( + "Price must be positive".to_string(), + )); + } + + let pair_key = price.pair_key(); + + // Add to history + let history = self.price_history.entry(pair_key.clone()).or_default(); + history.push(price.clone()); + + // Keep only prices within TWAP window + let cutoff = Utc::now() - self.config.twap_window; + history.retain(|p| p.timestamp > cutoff); + + // Update aggregated price + self.update_aggregated_price(&pair_key)?; + + Ok(()) + } + + /// Get current spot price + pub fn get_price(&self, token: &str, quote: &str) -> Result { + let pair_key = format!("{}/{}", token, quote); + self.cache + .get(&pair_key) + .map(|p| p.spot) + .ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair_key)) + } + + /// Get TWAP price + pub fn get_twap(&self, token: &str, quote: &str) -> Result { + let pair_key = format!("{}/{}", token, quote); + self.cache + .get(&pair_key) + .map(|p| p.twap) + .ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair_key)) + } + + /// Get full aggregated price data + pub fn get_aggregated_price(&self, token: &str, quote: &str) -> Result { + let pair_key = format!("{}/{}", token, quote); + self.cache + .get(&pair_key) + .cloned() + .ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair_key)) + } + + /// Get price history for a pair + pub fn get_price_history(&self, token: &str, quote: &str) -> Vec { + let pair_key = format!("{}/{}", token, quote); + self.price_history.get(&pair_key).cloned().unwrap_or_default() + } + + /// Fetch prices from all configured feeds + pub async fn fetch_all_prices(&mut self) -> Result<()> { + let mut new_prices = Vec::new(); + + for feed in &self.feeds { + match feed.fetch_price("SYNOR", "USD").await { + Ok(price) => new_prices.push(price), + Err(e) => { + tracing::warn!("Failed to fetch price from {}: {}", feed.source(), e); + } + } + } + + if new_prices.len() < self.config.min_sources { + return Err(EconomicsError::PriceFeedUnavailable(format!( + "Only {} sources available, need at least {}", + new_prices.len(), + self.config.min_sources + ))); + } + + // Check for excessive deviation + let prices: Vec<_> = new_prices.iter().map(|p| p.price).collect(); + if !self.validate_price_consistency(&prices)? { + return Err(EconomicsError::InvalidPrice( + "Excessive price deviation between sources".to_string(), + )); + } + + // Update all prices + for price in new_prices { + self.update_price(price)?; + } + + Ok(()) + } + + /// Validate that prices from different sources are consistent + fn validate_price_consistency(&self, prices: &[SynorDecimal]) -> Result { + if prices.len() < 2 { + return Ok(true); + } + + // Calculate median + let mut sorted = prices.to_vec(); + sorted.sort(); + let median = sorted[sorted.len() / 2]; + + // Check each price against median + for price in prices { + let deviation = (*price - median).abs() / median; + if deviation > self.config.max_deviation { + return Ok(false); + } + } + + Ok(true) + } + + /// Update aggregated price for a pair + fn update_aggregated_price(&mut self, pair_key: &str) -> Result<()> { + let history = match self.price_history.get(pair_key) { + Some(h) if !h.is_empty() => h, + _ => return Ok(()), + }; + + // Get latest price (spot) + let latest = history.last().unwrap(); + + // Check if stale + if latest.is_stale(self.config.max_price_age) { + // Don't update cache with stale price, but don't error + return Ok(()); + } + + // Calculate TWAP + let twap = self.twap_calculator.calculate(history); + + // Calculate median + let mut prices: Vec<_> = history.iter().map(|p| p.price).collect(); + prices.sort(); + let median = prices[prices.len() / 2]; + + // Count unique sources + let mut sources: std::collections::HashSet = std::collections::HashSet::new(); + for p in history { + sources.insert(format!("{:?}", p.source)); + } + + // Calculate confidence based on source diversity and freshness + let freshness = 1.0 + - (Utc::now() - latest.timestamp).num_seconds() as f64 + / self.config.max_price_age.num_seconds() as f64; + let diversity = (sources.len() as f64 / self.config.min_sources as f64).min(1.0); + let confidence = (freshness * 0.6 + diversity * 0.4) * latest.confidence; + + let aggregated = AggregatedPrice { + spot: latest.price, + twap, + median, + source_count: sources.len(), + last_update: latest.timestamp, + confidence: confidence.max(0.0).min(1.0), + }; + + self.cache.insert(pair_key.to_string(), aggregated); + + Ok(()) + } + + /// Convert amount from one currency to another + pub fn convert( + &self, + amount: SynorDecimal, + from: &str, + to: &str, + use_twap: bool, + ) -> Result { + if from == to { + return Ok(amount); + } + + // Try direct conversion + let price = if use_twap { + self.get_twap(from, to).or_else(|_| { + // Try inverse + self.get_twap(to, from).map(|p| Decimal::ONE / p) + }) + } else { + self.get_price(from, to).or_else(|_| { + // Try inverse + self.get_price(to, from).map(|p| Decimal::ONE / p) + }) + }?; + + Ok(amount * price) + } + + /// Get oracle health status + pub fn health_check(&self) -> OracleHealthStatus { + let mut pairs_status = HashMap::new(); + + for (pair, prices) in &self.price_history { + let latest = prices.last(); + let is_stale = latest + .map(|p| p.is_stale(self.config.max_price_age)) + .unwrap_or(true); + + pairs_status.insert( + pair.clone(), + PairStatus { + price_count: prices.len(), + is_stale, + last_update: latest.map(|p| p.timestamp), + }, + ); + } + + let healthy = !pairs_status.is_empty() + && pairs_status.values().all(|s| !s.is_stale && s.price_count > 0); + + OracleHealthStatus { + healthy, + feed_count: self.feeds.len(), + pairs: pairs_status, + } + } +} + +impl Default for PriceOracle { + fn default() -> Self { + Self::new() + } +} + +/// Status of a trading pair +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairStatus { + pub price_count: usize, + pub is_stale: bool, + pub last_update: Option>, +} + +/// Oracle health status +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OracleHealthStatus { + pub healthy: bool, + pub feed_count: usize, + pub pairs: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_update_and_get_price() { + let mut oracle = PriceOracle::new(); + + let price = TokenPrice::new("SYNOR", "USD", dec!(1.50), PriceSource::Internal); + oracle.update_price(price).unwrap(); + + let retrieved = oracle.get_price("SYNOR", "USD").unwrap(); + assert_eq!(retrieved, dec!(1.50)); + } + + #[test] + fn test_twap_calculation() { + let mut oracle = PriceOracle::new(); + + // Add multiple prices + for i in 0..5 { + let price = TokenPrice { + token: "SYNOR".to_string(), + quote: "USD".to_string(), + price: dec!(1.0) + Decimal::new(i, 1), + timestamp: Utc::now() - Duration::minutes(5 - i as i64), + source: PriceSource::Internal, + confidence: 1.0, + }; + oracle.update_price(price).unwrap(); + } + + let twap = oracle.get_twap("SYNOR", "USD").unwrap(); + // TWAP should be weighted average + assert!(twap > dec!(1.0) && twap < dec!(1.5)); + } + + #[test] + fn test_price_conversion() { + let mut oracle = PriceOracle::new(); + + let price = TokenPrice::new("SYNOR", "USD", dec!(2.0), PriceSource::Internal); + oracle.update_price(price).unwrap(); + + // Convert 10 SYNOR to USD + let usd = oracle.convert(dec!(10), "SYNOR", "USD", false).unwrap(); + assert_eq!(usd, dec!(20)); + + // Convert 20 USD to SYNOR (inverse) + let synor = oracle.convert(dec!(20), "USD", "SYNOR", false).unwrap(); + assert_eq!(synor, dec!(10)); + } + + #[test] + fn test_health_check() { + let oracle = PriceOracle::new(); + let status = oracle.health_check(); + assert!(!status.healthy); // No prices yet + } +} diff --git a/crates/synor-economics/src/oracle/price_feed.rs b/crates/synor-economics/src/oracle/price_feed.rs new file mode 100644 index 0000000..576add5 --- /dev/null +++ b/crates/synor-economics/src/oracle/price_feed.rs @@ -0,0 +1,393 @@ +//! Price Feed Sources +//! +//! Trait and implementations for fetching prices from various sources. + +use crate::error::{EconomicsError, Result}; +use crate::oracle::TokenPrice; +use crate::SynorDecimal; +use async_trait::async_trait; +use chrono::Utc; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +/// Price source identifier +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PriceSource { + /// Internal DEX prices + Internal, + /// Synor native DEX + SynorDex, + /// Chainlink oracle + Chainlink, + /// Pyth network + Pyth, + /// Band protocol + Band, + /// CoinGecko API + CoinGecko, + /// CoinMarketCap API + CoinMarketCap, + /// Binance exchange + Binance, + /// Uniswap TWAP + Uniswap, + /// Custom/external source + Custom(u8), +} + +impl std::fmt::Display for PriceSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PriceSource::Internal => write!(f, "internal"), + PriceSource::SynorDex => write!(f, "synor_dex"), + PriceSource::Chainlink => write!(f, "chainlink"), + PriceSource::Pyth => write!(f, "pyth"), + PriceSource::Band => write!(f, "band"), + PriceSource::CoinGecko => write!(f, "coingecko"), + PriceSource::CoinMarketCap => write!(f, "coinmarketcap"), + PriceSource::Binance => write!(f, "binance"), + PriceSource::Uniswap => write!(f, "uniswap"), + PriceSource::Custom(id) => write!(f, "custom_{}", id), + } + } +} + +/// Trait for price feed implementations +#[async_trait] +pub trait PriceFeed: Send + Sync { + /// Get the source identifier + fn source(&self) -> PriceSource; + + /// Fetch current price for a token pair + async fn fetch_price(&self, token: &str, quote: &str) -> Result; + + /// Check if the feed is healthy + async fn health_check(&self) -> bool { + true + } + + /// Get supported pairs + fn supported_pairs(&self) -> Vec<(String, String)> { + vec![("SYNOR".to_string(), "USD".to_string())] + } +} + +/// Mock price feed for testing +pub struct MockPriceFeed { + source: PriceSource, + base_price: SynorDecimal, + volatility: f64, +} + +impl MockPriceFeed { + pub fn new(source: PriceSource, base_price: SynorDecimal) -> Self { + Self { + source, + base_price, + volatility: 0.01, + } + } + + pub fn with_volatility(mut self, volatility: f64) -> Self { + self.volatility = volatility; + self + } +} + +#[async_trait] +impl PriceFeed for MockPriceFeed { + fn source(&self) -> PriceSource { + self.source + } + + async fn fetch_price(&self, token: &str, quote: &str) -> Result { + // Add small random variance + let variance = (rand_simple() * 2.0 - 1.0) * self.volatility; + let price = self.base_price * (Decimal::ONE + Decimal::from_f64_retain(variance).unwrap_or_default()); + + Ok(TokenPrice { + token: token.to_string(), + quote: quote.to_string(), + price, + timestamp: Utc::now(), + source: self.source, + confidence: 0.95, + }) + } +} + +/// Simple random number generator (no external deps) +fn rand_simple() -> f64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .subsec_nanos(); + (nanos as f64 % 1000.0) / 1000.0 +} + +/// Internal DEX price feed +pub struct InternalDexFeed { + dex_endpoint: String, +} + +impl InternalDexFeed { + pub fn new(endpoint: impl Into) -> Self { + Self { + dex_endpoint: endpoint.into(), + } + } +} + +#[async_trait] +impl PriceFeed for InternalDexFeed { + fn source(&self) -> PriceSource { + PriceSource::SynorDex + } + + async fn fetch_price(&self, token: &str, quote: &str) -> Result { + // In production, this would query the Synor DEX contract + #[cfg(feature = "http-feeds")] + { + let url = format!( + "{}/api/v1/price?token={}"e={}", + self.dex_endpoint, token, quote + ); + + let response = reqwest::get(&url) + .await + .map_err(|e| EconomicsError::PriceFeedUnavailable(e.to_string()))?; + + #[derive(Deserialize)] + struct DexPriceResponse { + price: String, + timestamp: i64, + } + + let data: DexPriceResponse = response + .json() + .await + .map_err(|e| EconomicsError::InvalidPrice(e.to_string()))?; + + let price = data + .price + .parse::() + .map_err(|e| EconomicsError::InvalidPrice(e.to_string()))?; + + Ok(TokenPrice { + token: token.to_string(), + quote: quote.to_string(), + price, + timestamp: chrono::DateTime::from_timestamp(data.timestamp, 0) + .unwrap_or_else(Utc::now), + source: PriceSource::SynorDex, + confidence: 0.99, + }) + } + + #[cfg(not(feature = "http-feeds"))] + { + let _ = (token, quote); + Err(EconomicsError::PriceFeedUnavailable( + "HTTP feeds not enabled".to_string(), + )) + } + } +} + +/// Chainlink price feed adapter +pub struct ChainlinkFeed { + contract_address: String, + rpc_url: String, +} + +impl ChainlinkFeed { + pub fn new(contract_address: impl Into, rpc_url: impl Into) -> Self { + Self { + contract_address: contract_address.into(), + rpc_url: rpc_url.into(), + } + } +} + +#[async_trait] +impl PriceFeed for ChainlinkFeed { + fn source(&self) -> PriceSource { + PriceSource::Chainlink + } + + async fn fetch_price(&self, token: &str, quote: &str) -> Result { + // In production, this would call the Chainlink price feed contract + // via eth_call to latestRoundData() + let _ = (&self.contract_address, &self.rpc_url, token, quote); + + Err(EconomicsError::PriceFeedUnavailable( + "Chainlink integration pending".to_string(), + )) + } +} + +/// CoinGecko API price feed +pub struct CoinGeckoFeed { + api_key: Option, +} + +impl CoinGeckoFeed { + pub fn new(api_key: Option) -> Self { + Self { api_key } + } +} + +#[async_trait] +impl PriceFeed for CoinGeckoFeed { + fn source(&self) -> PriceSource { + PriceSource::CoinGecko + } + + async fn fetch_price(&self, token: &str, quote: &str) -> Result { + #[cfg(feature = "http-feeds")] + { + let token_id = match token.to_uppercase().as_str() { + "SYNOR" => "synor", // Would need actual CoinGecko ID + "BTC" => "bitcoin", + "ETH" => "ethereum", + _ => return Err(EconomicsError::PriceFeedUnavailable( + format!("Token {} not supported on CoinGecko", token) + )), + }; + + let quote_currency = quote.to_lowercase(); + + let url = format!( + "https://api.coingecko.com/api/v3/simple/price?ids={}&vs_currencies={}", + token_id, quote_currency + ); + + let client = reqwest::Client::new(); + let mut request = client.get(&url); + + if let Some(ref key) = self.api_key { + request = request.header("x-cg-demo-api-key", key); + } + + let response = request + .send() + .await + .map_err(|e| EconomicsError::PriceFeedUnavailable(e.to_string()))?; + + let data: serde_json::Value = response + .json() + .await + .map_err(|e| EconomicsError::InvalidPrice(e.to_string()))?; + + let price = data[token_id]["e_currency] + .as_f64() + .ok_or_else(|| EconomicsError::InvalidPrice("Price not found".to_string()))?; + + Ok(TokenPrice { + token: token.to_string(), + quote: quote.to_string(), + price: Decimal::from_f64_retain(price) + .unwrap_or_default(), + timestamp: Utc::now(), + source: PriceSource::CoinGecko, + confidence: 0.90, + }) + } + + #[cfg(not(feature = "http-feeds"))] + { + let _ = (token, quote); + Err(EconomicsError::PriceFeedUnavailable( + "HTTP feeds not enabled".to_string(), + )) + } + } + + fn supported_pairs(&self) -> Vec<(String, String)> { + vec![ + ("SYNOR".to_string(), "USD".to_string()), + ("BTC".to_string(), "USD".to_string()), + ("ETH".to_string(), "USD".to_string()), + ] + } +} + +/// Aggregate multiple price feeds +pub struct AggregateFeed { + feeds: Vec>, +} + +impl AggregateFeed { + pub fn new(feeds: Vec>) -> Self { + Self { feeds } + } + + /// Fetch prices from all feeds and return median + pub async fn fetch_median(&self, token: &str, quote: &str) -> Result { + let mut prices = Vec::new(); + + for feed in &self.feeds { + match feed.fetch_price(token, quote).await { + Ok(price) => prices.push(price), + Err(e) => { + tracing::warn!("Feed {} failed: {}", feed.source(), e); + } + } + } + + if prices.is_empty() { + return Err(EconomicsError::PriceFeedUnavailable( + "All feeds failed".to_string(), + )); + } + + // Sort by price and take median + prices.sort_by(|a, b| a.price.cmp(&b.price)); + let median_idx = prices.len() / 2; + let mut median = prices.remove(median_idx); + + // Adjust confidence based on source count + median.confidence *= prices.len() as f64 / self.feeds.len() as f64; + + Ok(median) + } +} + +#[async_trait] +impl PriceFeed for AggregateFeed { + fn source(&self) -> PriceSource { + PriceSource::Internal + } + + async fn fetch_price(&self, token: &str, quote: &str) -> Result { + self.fetch_median(token, quote).await + } + + async fn health_check(&self) -> bool { + let mut healthy_count = 0; + for feed in &self.feeds { + if feed.health_check().await { + healthy_count += 1; + } + } + healthy_count >= self.feeds.len() / 2 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[tokio::test] + async fn test_mock_feed() { + let feed = MockPriceFeed::new(PriceSource::Internal, dec!(1.50)); + let price = feed.fetch_price("SYNOR", "USD").await.unwrap(); + + assert_eq!(price.token, "SYNOR"); + assert_eq!(price.quote, "USD"); + assert!(price.price > dec!(1.40) && price.price < dec!(1.60)); + } +} diff --git a/crates/synor-economics/src/oracle/twap.rs b/crates/synor-economics/src/oracle/twap.rs new file mode 100644 index 0000000..a7ca168 --- /dev/null +++ b/crates/synor-economics/src/oracle/twap.rs @@ -0,0 +1,387 @@ +//! Time-Weighted Average Price (TWAP) Calculator +//! +//! TWAP provides price stability by averaging over a time window, +//! reducing impact of short-term manipulation or volatility. + +use crate::oracle::TokenPrice; +use crate::SynorDecimal; +use chrono::{DateTime, Duration, Utc}; +use rust_decimal::Decimal; + +/// TWAP calculator configuration +#[derive(Debug, Clone)] +pub struct TwapConfig { + /// Time window for TWAP calculation + pub window: Duration, + /// Minimum data points required + pub min_data_points: usize, + /// Whether to use volume weighting (when available) + pub volume_weighted: bool, +} + +impl Default for TwapConfig { + fn default() -> Self { + Self { + window: Duration::hours(1), + min_data_points: 3, + volume_weighted: false, + } + } +} + +/// TWAP calculator +pub struct TwapCalculator { + config: TwapConfig, +} + +impl TwapCalculator { + /// Create a new TWAP calculator with specified window + pub fn new(window: Duration) -> Self { + Self { + config: TwapConfig { + window, + ..Default::default() + }, + } + } + + /// Create with full configuration + pub fn with_config(config: TwapConfig) -> Self { + Self { config } + } + + /// Calculate TWAP from price history + /// + /// Uses time-weighted interpolation between price points + pub fn calculate(&self, prices: &[TokenPrice]) -> SynorDecimal { + if prices.is_empty() { + return Decimal::ZERO; + } + + if prices.len() == 1 { + return prices[0].price; + } + + let now = Utc::now(); + let window_start = now - self.config.window; + + // Filter prices within window + let relevant: Vec<_> = prices + .iter() + .filter(|p| p.timestamp >= window_start) + .collect(); + + if relevant.is_empty() { + // Fall back to latest price if all data is stale + return prices.last().map(|p| p.price).unwrap_or(Decimal::ZERO); + } + + // Time-weighted average + self.calculate_time_weighted(&relevant, window_start, now) + } + + /// Calculate time-weighted average + /// + /// For each time interval between price points, the price is weighted + /// by the duration of that interval. + fn calculate_time_weighted( + &self, + prices: &[&TokenPrice], + start: DateTime, + end: DateTime, + ) -> SynorDecimal { + let total_duration = (end - start).num_seconds() as f64; + if total_duration <= 0.0 { + return prices.last().map(|p| p.price).unwrap_or(Decimal::ZERO); + } + + let mut weighted_sum = Decimal::ZERO; + let mut total_weight = Decimal::ZERO; + + for i in 0..prices.len() { + let price = &prices[i]; + + // Calculate time weight for this price point + let interval_start = if i == 0 { + start.max(price.timestamp) + } else { + prices[i - 1].timestamp.max(price.timestamp) + }; + + let interval_end = if i + 1 < prices.len() { + prices[i + 1].timestamp + } else { + end + }; + + let duration = (interval_end - interval_start).num_seconds() as f64; + if duration > 0.0 { + let weight = Decimal::from_f64_retain(duration / total_duration) + .unwrap_or(Decimal::ZERO); + + weighted_sum += price.price * weight; + total_weight += weight; + } + } + + if total_weight == Decimal::ZERO { + prices.last().map(|p| p.price).unwrap_or(Decimal::ZERO) + } else { + weighted_sum / total_weight + } + } + + /// Calculate TWAP with exponential decay (more recent prices weighted higher) + pub fn calculate_exponential(&self, prices: &[TokenPrice], decay_rate: f64) -> SynorDecimal { + if prices.is_empty() { + return Decimal::ZERO; + } + + if prices.len() == 1 { + return prices[0].price; + } + + let now = Utc::now(); + let mut weighted_sum = Decimal::ZERO; + let mut total_weight = Decimal::ZERO; + + for price in prices { + let age_seconds = (now - price.timestamp).num_seconds() as f64; + let weight = (-decay_rate * age_seconds).exp(); + let weight_decimal = Decimal::from_f64_retain(weight).unwrap_or(Decimal::ZERO); + + weighted_sum += price.price * weight_decimal; + total_weight += weight_decimal; + } + + if total_weight == Decimal::ZERO { + prices.last().map(|p| p.price).unwrap_or(Decimal::ZERO) + } else { + weighted_sum / total_weight + } + } + + /// Calculate simple moving average (not time-weighted) + pub fn calculate_sma(&self, prices: &[TokenPrice]) -> SynorDecimal { + if prices.is_empty() { + return Decimal::ZERO; + } + + let sum: Decimal = prices.iter().map(|p| p.price).sum(); + sum / Decimal::from(prices.len()) + } + + /// Calculate volume-weighted average price (VWAP) + /// Requires prices with volume data + pub fn calculate_vwap(&self, prices: &[(TokenPrice, SynorDecimal)]) -> SynorDecimal { + if prices.is_empty() { + return Decimal::ZERO; + } + + let mut weighted_sum = Decimal::ZERO; + let mut total_volume = Decimal::ZERO; + + for (price, volume) in prices { + weighted_sum += price.price * *volume; + total_volume += *volume; + } + + if total_volume == Decimal::ZERO { + prices.last().map(|(p, _)| p.price).unwrap_or(Decimal::ZERO) + } else { + weighted_sum / total_volume + } + } + + /// Calculate price volatility (standard deviation) + pub fn calculate_volatility(&self, prices: &[TokenPrice]) -> SynorDecimal { + if prices.len() < 2 { + return Decimal::ZERO; + } + + let mean = self.calculate_sma(prices); + let variance: Decimal = prices + .iter() + .map(|p| { + let diff = p.price - mean; + diff * diff + }) + .sum::() + / Decimal::from(prices.len()); + + // Approximate square root using Newton's method + let mut guess = variance; + for _ in 0..10 { + if guess == Decimal::ZERO { + break; + } + guess = (guess + variance / guess) / Decimal::from(2); + } + + guess + } +} + +/// TWAP observation for on-chain storage +#[derive(Debug, Clone)] +pub struct TwapObservation { + pub timestamp: DateTime, + pub price_cumulative: SynorDecimal, + pub seconds_per_liquidity_cumulative: SynorDecimal, +} + +/// On-chain TWAP oracle (Uniswap V3 style) +pub struct OnChainTwap { + observations: Vec, + cardinality: usize, + cardinality_next: usize, +} + +impl OnChainTwap { + pub fn new(cardinality: usize) -> Self { + Self { + observations: Vec::with_capacity(cardinality), + cardinality, + cardinality_next: cardinality, + } + } + + /// Record new price observation + pub fn observe(&mut self, price: SynorDecimal, liquidity: SynorDecimal) { + let last = self.observations.last(); + let timestamp = Utc::now(); + + let (price_cumulative, spl_cumulative) = if let Some(last_obs) = last { + let time_delta = (timestamp - last_obs.timestamp).num_seconds(); + let time_delta_dec = Decimal::from(time_delta); + + ( + last_obs.price_cumulative + price * time_delta_dec, + last_obs.seconds_per_liquidity_cumulative + + if liquidity > Decimal::ZERO { + time_delta_dec / liquidity + } else { + Decimal::ZERO + }, + ) + } else { + (Decimal::ZERO, Decimal::ZERO) + }; + + let observation = TwapObservation { + timestamp, + price_cumulative, + seconds_per_liquidity_cumulative: spl_cumulative, + }; + + if self.observations.len() < self.cardinality { + self.observations.push(observation); + } else { + // Ring buffer - overwrite oldest + let index = self.observations.len() % self.cardinality; + self.observations[index] = observation; + } + } + + /// Calculate TWAP between two timestamps + pub fn consult(&self, seconds_ago: i64) -> Option { + if self.observations.len() < 2 { + return None; + } + + let now = Utc::now(); + let target_time = now - Duration::seconds(seconds_ago); + + // Find observations bracketing target_time + let mut before: Option<&TwapObservation> = None; + let mut after: Option<&TwapObservation> = None; + + for obs in &self.observations { + if obs.timestamp <= target_time { + before = Some(obs); + } else if after.is_none() { + after = Some(obs); + } + } + + match (before, after) { + (Some(b), Some(a)) => { + let time_delta = (a.timestamp - b.timestamp).num_seconds(); + if time_delta <= 0 { + return None; + } + let price_delta = a.price_cumulative - b.price_cumulative; + Some(price_delta / Decimal::from(time_delta)) + } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::oracle::PriceSource; + use rust_decimal_macros::dec; + + fn make_price(price: Decimal, minutes_ago: i64) -> TokenPrice { + TokenPrice { + token: "SYNOR".to_string(), + quote: "USD".to_string(), + price, + timestamp: Utc::now() - Duration::minutes(minutes_ago), + source: PriceSource::Internal, + confidence: 1.0, + } + } + + #[test] + fn test_sma() { + let calculator = TwapCalculator::new(Duration::hours(1)); + let prices = vec![ + make_price(dec!(100), 5), + make_price(dec!(110), 4), + make_price(dec!(105), 3), + make_price(dec!(115), 2), + make_price(dec!(120), 1), + ]; + + let sma = calculator.calculate_sma(&prices); + assert_eq!(sma, dec!(110)); // (100+110+105+115+120)/5 = 110 + } + + #[test] + fn test_twap_single_price() { + let calculator = TwapCalculator::new(Duration::hours(1)); + let prices = vec![make_price(dec!(100), 5)]; + + let twap = calculator.calculate(&prices); + assert_eq!(twap, dec!(100)); + } + + #[test] + fn test_volatility() { + let calculator = TwapCalculator::new(Duration::hours(1)); + let prices = vec![ + make_price(dec!(100), 5), + make_price(dec!(100), 4), + make_price(dec!(100), 3), + ]; + + let volatility = calculator.calculate_volatility(&prices); + assert_eq!(volatility, dec!(0)); // No variation + } + + #[test] + fn test_on_chain_twap() { + let mut twap = OnChainTwap::new(10); + + // Add observations + twap.observe(dec!(100), dec!(1000)); + std::thread::sleep(std::time::Duration::from_millis(10)); + twap.observe(dec!(110), dec!(1000)); + + // Should have 2 observations + assert_eq!(twap.observations.len(), 2); + } +} diff --git a/crates/synor-economics/src/pricing/discounts.rs b/crates/synor-economics/src/pricing/discounts.rs new file mode 100644 index 0000000..5acc03e --- /dev/null +++ b/crates/synor-economics/src/pricing/discounts.rs @@ -0,0 +1,399 @@ +//! Discount System +//! +//! Promotional, volume, and loyalty discounts. + +use crate::{ServiceType, SynorDecimal}; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +/// Discount type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiscountType { + /// Percentage off + Percentage, + /// Fixed amount off + FixedAmount, + /// Volume-based (spend X, get Y% off) + Volume, + /// Loyalty reward + Loyalty, + /// Referral discount + Referral, + /// Promotional campaign + Promotional, + /// Partner/reseller discount + Partner, +} + +impl std::fmt::Display for DiscountType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DiscountType::Percentage => write!(f, "Percentage"), + DiscountType::FixedAmount => write!(f, "Fixed Amount"), + DiscountType::Volume => write!(f, "Volume"), + DiscountType::Loyalty => write!(f, "Loyalty"), + DiscountType::Referral => write!(f, "Referral"), + DiscountType::Promotional => write!(f, "Promotional"), + DiscountType::Partner => write!(f, "Partner"), + } + } +} + +/// Discount definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Discount { + /// Unique discount code/ID + pub code: String, + /// Discount name + pub name: String, + /// Description + pub description: String, + /// Discount type + pub discount_type: DiscountType, + /// Discount value (percentage or fixed amount) + pub value: SynorDecimal, + /// Minimum spend to qualify + pub min_spend: Option, + /// Maximum discount amount + pub max_discount: Option, + /// Applicable service types (None = all) + pub service_types: Option>, + /// Applicable account IDs (None = all) + pub account_ids: Option>, + /// Start date + pub start_date: DateTime, + /// End date (None = no expiry) + pub end_date: Option>, + /// Maximum uses (None = unlimited) + pub max_uses: Option, + /// Current uses + pub current_uses: u32, + /// Active status + pub is_active: bool, +} + +impl Discount { + /// Create a new percentage discount + pub fn percentage(code: impl Into, name: impl Into, percentage: SynorDecimal) -> Self { + Self { + code: code.into(), + name: name.into(), + description: format!("{}% off", percentage), + discount_type: DiscountType::Percentage, + value: percentage, + min_spend: None, + max_discount: None, + service_types: None, + account_ids: None, + start_date: Utc::now(), + end_date: None, + max_uses: None, + current_uses: 0, + is_active: true, + } + } + + /// Create a fixed amount discount + pub fn fixed_amount(code: impl Into, name: impl Into, amount: SynorDecimal) -> Self { + Self { + code: code.into(), + name: name.into(), + description: format!("{} SYNOR off", amount), + discount_type: DiscountType::FixedAmount, + value: amount, + min_spend: None, + max_discount: Some(amount), + service_types: None, + account_ids: None, + start_date: Utc::now(), + end_date: None, + max_uses: None, + current_uses: 0, + is_active: true, + } + } + + /// Create a volume discount + pub fn volume(min_spend: SynorDecimal, percentage: SynorDecimal) -> Self { + Self { + code: format!("VOLUME_{}", min_spend), + name: format!("Volume Discount ({} SYNOR+)", min_spend), + description: format!("{}% off when spending {} SYNOR or more", percentage, min_spend), + discount_type: DiscountType::Volume, + value: percentage, + min_spend: Some(min_spend), + max_discount: None, + service_types: None, + account_ids: None, + start_date: Utc::now(), + end_date: None, + max_uses: None, + current_uses: 0, + is_active: true, + } + } + + /// Create a referral discount + pub fn referral(referrer_id: impl Into) -> Self { + let referrer = referrer_id.into(); + Self { + code: format!("REF_{}", &referrer[..8.min(referrer.len())].to_uppercase()), + name: "Referral Discount".to_string(), + description: "10% off for referred users".to_string(), + discount_type: DiscountType::Referral, + value: Decimal::new(10, 0), // 10% + min_spend: None, + max_discount: Some(Decimal::new(50, 0)), // Max 50 SYNOR + service_types: None, + account_ids: None, + start_date: Utc::now(), + end_date: Some(Utc::now() + chrono::Duration::days(90)), + max_uses: Some(1), + current_uses: 0, + is_active: true, + } + } + + /// Set expiration + pub fn with_expiry(mut self, end_date: DateTime) -> Self { + self.end_date = Some(end_date); + self + } + + /// Set expiration days from now + pub fn with_expiry_days(mut self, days: i64) -> Self { + self.end_date = Some(Utc::now() + chrono::Duration::days(days)); + self + } + + /// Set minimum spend + pub fn with_min_spend(mut self, min: SynorDecimal) -> Self { + self.min_spend = Some(min); + self + } + + /// Set maximum discount + pub fn with_max_discount(mut self, max: SynorDecimal) -> Self { + self.max_discount = Some(max); + self + } + + /// Limit to specific service types + pub fn for_services(mut self, services: Vec) -> Self { + self.service_types = Some(services); + self + } + + /// Limit to specific accounts + pub fn for_accounts(mut self, accounts: Vec) -> Self { + self.account_ids = Some(accounts); + self + } + + /// Set maximum uses + pub fn with_max_uses(mut self, max: u32) -> Self { + self.max_uses = Some(max); + self + } + + /// Check if discount is currently valid + pub fn is_valid(&self) -> bool { + if !self.is_active { + return false; + } + + let now = Utc::now(); + + if now < self.start_date { + return false; + } + + if let Some(end) = self.end_date { + if now > end { + return false; + } + } + + if let Some(max) = self.max_uses { + if self.current_uses >= max { + return false; + } + } + + true + } + + /// Check if discount is applicable to an account and service + pub fn is_applicable(&self, account_id: &str, service_type: Option) -> bool { + if !self.is_valid() { + return false; + } + + // Check account restriction + if let Some(ref accounts) = self.account_ids { + if !accounts.iter().any(|a| a == account_id) { + return false; + } + } + + // Check service restriction + if let Some(ref services) = self.service_types { + if let Some(st) = service_type { + if !services.contains(&st) { + return false; + } + } + } + + true + } + + /// Calculate discount amount for a given spend + pub fn calculate_discount(&self, amount: SynorDecimal) -> SynorDecimal { + // Check minimum spend + if let Some(min) = self.min_spend { + if amount < min { + return Decimal::ZERO; + } + } + + let discount = match self.discount_type { + DiscountType::Percentage | DiscountType::Volume | DiscountType::Loyalty | DiscountType::Referral | DiscountType::Partner => { + amount * (self.value / Decimal::ONE_HUNDRED) + } + DiscountType::FixedAmount | DiscountType::Promotional => { + self.value.min(amount) // Can't discount more than amount + } + }; + + // Apply maximum discount cap + if let Some(max) = self.max_discount { + discount.min(max) + } else { + discount + } + } + + /// Use the discount (increment counter) + pub fn use_discount(&mut self) -> bool { + if !self.is_valid() { + return false; + } + + self.current_uses += 1; + + // Check if now exhausted + if let Some(max) = self.max_uses { + if self.current_uses >= max { + self.is_active = false; + } + } + + true + } +} + +/// Volume discount tiers +pub fn standard_volume_discounts() -> Vec { + vec![ + Discount::volume(Decimal::new(100, 0), Decimal::new(5, 0)), // 5% at 100+ SYNOR + Discount::volume(Decimal::new(500, 0), Decimal::new(10, 0)), // 10% at 500+ SYNOR + Discount::volume(Decimal::new(1000, 0), Decimal::new(15, 0)), // 15% at 1000+ SYNOR + Discount::volume(Decimal::new(5000, 0), Decimal::new(20, 0)), // 20% at 5000+ SYNOR + ] +} + +/// Find best applicable volume discount +pub fn find_best_volume_discount(amount: SynorDecimal) -> Option { + standard_volume_discounts() + .into_iter() + .filter(|d| { + d.min_spend + .map(|min| amount >= min) + .unwrap_or(false) + }) + .max_by(|a, b| a.value.cmp(&b.value)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_percentage_discount() { + let discount = Discount::percentage("TEST10", "Test Discount", dec!(10)); + + assert!(discount.is_valid()); + + let amount = discount.calculate_discount(dec!(100)); + assert_eq!(amount, dec!(10)); // 10% of 100 + } + + #[test] + fn test_fixed_discount() { + let discount = Discount::fixed_amount("FIXED50", "Fixed 50", dec!(50)); + + // Normal case + let amount = discount.calculate_discount(dec!(100)); + assert_eq!(amount, dec!(50)); + + // Amount less than discount + let amount = discount.calculate_discount(dec!(30)); + assert_eq!(amount, dec!(30)); // Can't exceed original amount + } + + #[test] + fn test_volume_discount() { + let discount = Discount::volume(dec!(100), dec!(10)).with_min_spend(dec!(100)); + + // Below minimum + let amount = discount.calculate_discount(dec!(50)); + assert_eq!(amount, dec!(0)); + + // Above minimum + let amount = discount.calculate_discount(dec!(200)); + assert_eq!(amount, dec!(20)); // 10% of 200 + } + + #[test] + fn test_discount_max_cap() { + let discount = Discount::percentage("CAPPED", "Capped Discount", dec!(50)) + .with_max_discount(dec!(100)); + + // Would be 250 but capped at 100 + let amount = discount.calculate_discount(dec!(500)); + assert_eq!(amount, dec!(100)); + } + + #[test] + fn test_discount_expiry() { + let mut discount = Discount::percentage("EXPIRED", "Expired", dec!(10)) + .with_expiry(Utc::now() - chrono::Duration::days(1)); + + assert!(!discount.is_valid()); + } + + #[test] + fn test_discount_usage_limit() { + let mut discount = Discount::percentage("LIMITED", "Limited Use", dec!(10)) + .with_max_uses(2); + + assert!(discount.use_discount()); + assert!(discount.use_discount()); + assert!(!discount.use_discount()); // Third use should fail + } + + #[test] + fn test_find_best_volume_discount() { + let best = find_best_volume_discount(dec!(750)); + assert!(best.is_some()); + assert_eq!(best.unwrap().value, dec!(10)); // 10% tier (500+) + + let best_high = find_best_volume_discount(dec!(6000)); + assert!(best_high.is_some()); + assert_eq!(best_high.unwrap().value, dec!(20)); // 20% tier (5000+) + } +} diff --git a/crates/synor-economics/src/pricing/mod.rs b/crates/synor-economics/src/pricing/mod.rs new file mode 100644 index 0000000..f0e7ae2 --- /dev/null +++ b/crates/synor-economics/src/pricing/mod.rs @@ -0,0 +1,463 @@ +//! Pricing Engine +//! +//! Service pricing, tier management, and discount system. + +mod discounts; +mod tiers; + +pub use discounts::{Discount, DiscountType}; +pub use tiers::PricingTier; + +use crate::error::{EconomicsError, Result}; +use crate::{ResourceUnit, ServiceType, SynorDecimal}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Service pricing configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServicePricing { + /// Base prices by resource unit + pub base_prices: HashMap, + /// Minimum billable amount + pub minimum_charge: SynorDecimal, + /// Free tier included + pub free_tier: Option, +} + +/// Free tier allocation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FreeTier { + /// Free allocation per resource + pub allocations: HashMap, + /// Monthly reset + pub monthly_reset: bool, +} + +/// Pricing engine managing all pricing logic +pub struct PricingEngine { + /// Pricing by service type + service_pricing: HashMap, + /// Available pricing tiers + tiers: Vec, + /// Active discounts + discounts: Vec, +} + +impl PricingEngine { + /// Create a new pricing engine with default prices + pub fn new() -> Self { + let mut engine = Self { + service_pricing: HashMap::new(), + tiers: Vec::new(), + discounts: Vec::new(), + }; + + engine.initialize_default_pricing(); + engine.initialize_default_tiers(); + + engine + } + + /// Initialize default service pricing + fn initialize_default_pricing(&mut self) { + // Storage L2 pricing + let mut storage_prices = HashMap::new(); + storage_prices.insert(ResourceUnit::GbMonth, Decimal::new(2, 2)); // 0.02 SYNOR/GB/month + storage_prices.insert(ResourceUnit::BandwidthGb, Decimal::new(1, 2)); // 0.01 SYNOR/GB retrieval + storage_prices.insert(ResourceUnit::Bytes, Decimal::new(2, 11)); // For small files + + self.service_pricing.insert( + ServiceType::Storage, + ServicePricing { + base_prices: storage_prices, + minimum_charge: Decimal::new(1, 4), // 0.0001 SYNOR + free_tier: Some(FreeTier { + allocations: [(ResourceUnit::GbMonth, Decimal::new(5, 1))].into(), // 0.5 GB free + monthly_reset: true, + }), + }, + ); + + // Hosting pricing + let mut hosting_prices = HashMap::new(); + hosting_prices.insert(ResourceUnit::BandwidthGb, Decimal::new(5, 2)); // 0.05 SYNOR/GB + hosting_prices.insert(ResourceUnit::Domains, Decimal::new(50, 2)); // 0.50 SYNOR/domain/month + + self.service_pricing.insert( + ServiceType::Hosting, + ServicePricing { + base_prices: hosting_prices, + minimum_charge: Decimal::ZERO, + free_tier: Some(FreeTier { + allocations: [ + (ResourceUnit::BandwidthGb, Decimal::new(1, 0)), // 1 GB free + ] + .into(), + monthly_reset: true, + }), + }, + ); + + // Database L2 pricing + let mut db_prices = HashMap::new(); + db_prices.insert(ResourceUnit::GbMonth, Decimal::new(10, 2)); // 0.10 SYNOR/GB/month + db_prices.insert(ResourceUnit::Queries, Decimal::new(1, 8)); // 0.00000001 SYNOR/query (0.01/million) + db_prices.insert(ResourceUnit::VectorSearches, Decimal::new(5, 8)); // 0.00000005 SYNOR/search (0.05/million) + + self.service_pricing.insert( + ServiceType::Database, + ServicePricing { + base_prices: db_prices, + minimum_charge: Decimal::new(1, 4), + free_tier: Some(FreeTier { + allocations: [ + (ResourceUnit::GbMonth, Decimal::new(5, 1)), // 0.5 GB free + (ResourceUnit::Queries, Decimal::new(1_000_000, 0)), // 1M queries free + (ResourceUnit::VectorSearches, Decimal::new(100_000, 0)), // 100K vector searches free + ] + .into(), + monthly_reset: true, + }), + }, + ); + + // Compute L2 pricing + let mut compute_prices = HashMap::new(); + compute_prices.insert(ResourceUnit::CpuCoreHours, Decimal::new(2, 2)); // 0.02 SYNOR/core-hour + compute_prices.insert(ResourceUnit::GpuHours, Decimal::new(50, 2)); // 0.50 SYNOR/GPU-hour (RTX 4090) + compute_prices.insert(ResourceUnit::MemoryGbHours, Decimal::new(5, 3)); // 0.005 SYNOR/GB-hour + compute_prices.insert(ResourceUnit::Invocations, Decimal::new(2, 7)); // 0.0000002 SYNOR/invocation (0.20/million) + + self.service_pricing.insert( + ServiceType::Compute, + ServicePricing { + base_prices: compute_prices, + minimum_charge: Decimal::new(1, 4), + free_tier: Some(FreeTier { + allocations: [ + (ResourceUnit::CpuCoreHours, Decimal::new(100, 0)), // 100 core-hours free + (ResourceUnit::MemoryGbHours, Decimal::new(200, 0)), // 200 GB-hours free + (ResourceUnit::Invocations, Decimal::new(1_000_000, 0)), // 1M invocations free + ] + .into(), + monthly_reset: true, + }), + }, + ); + + // Network pricing + let mut network_prices = HashMap::new(); + network_prices.insert(ResourceUnit::BandwidthGb, Decimal::new(1, 2)); // 0.01 SYNOR/GB + + self.service_pricing.insert( + ServiceType::Network, + ServicePricing { + base_prices: network_prices, + minimum_charge: Decimal::ZERO, + free_tier: Some(FreeTier { + allocations: [(ResourceUnit::BandwidthGb, Decimal::new(10, 0))].into(), // 10 GB free + monthly_reset: true, + }), + }, + ); + + // Contract execution (gas pricing) + let mut contract_prices = HashMap::new(); + contract_prices.insert(ResourceUnit::Gas, Decimal::new(1, 9)); // 0.000000001 SYNOR/gas + + self.service_pricing.insert( + ServiceType::Contract, + ServicePricing { + base_prices: contract_prices, + minimum_charge: Decimal::ZERO, + free_tier: None, + }, + ); + } + + /// Initialize default pricing tiers + fn initialize_default_tiers(&mut self) { + self.tiers = vec![ + PricingTier::free(), + PricingTier::standard(), + PricingTier::premium(), + PricingTier::enterprise(), + ]; + } + + /// Calculate cost for resource usage + pub fn calculate_cost( + &self, + service_type: ServiceType, + resource_unit: ResourceUnit, + amount: Decimal, + ) -> Result { + let pricing = self + .service_pricing + .get(&service_type) + .ok_or_else(|| EconomicsError::ServiceNotConfigured(service_type.to_string()))?; + + let unit_price = pricing + .base_prices + .get(&resource_unit) + .ok_or_else(|| { + EconomicsError::ServiceNotConfigured(format!( + "{} - {}", + service_type, resource_unit + )) + })?; + + let cost = amount * unit_price; + + // Apply minimum charge + Ok(cost.max(pricing.minimum_charge)) + } + + /// Calculate cost with tier discount applied + pub fn calculate_cost_with_tier( + &self, + service_type: ServiceType, + resource_unit: ResourceUnit, + amount: Decimal, + tier_name: &str, + ) -> Result { + let base_cost = self.calculate_cost(service_type, resource_unit, amount)?; + + let tier = self.tiers.iter().find(|t| t.name == tier_name); + + if let Some(tier) = tier { + let discount_rate = tier.discount_percentage / Decimal::ONE_HUNDRED; + Ok(base_cost * (Decimal::ONE - discount_rate)) + } else { + Ok(base_cost) + } + } + + /// Calculate tier discount amount + pub fn calculate_tier_discount( + &self, + tier_name: &str, + subtotal: SynorDecimal, + ) -> Result { + let tier = self.tiers.iter().find(|t| t.name == tier_name); + + if let Some(tier) = tier { + let discount_rate = tier.discount_percentage / Decimal::ONE_HUNDRED; + Ok(subtotal * discount_rate) + } else { + Ok(Decimal::ZERO) + } + } + + /// Get free tier allocation for a resource + pub fn get_free_allocation( + &self, + service_type: ServiceType, + resource_unit: ResourceUnit, + ) -> Decimal { + self.service_pricing + .get(&service_type) + .and_then(|p| p.free_tier.as_ref()) + .and_then(|f| f.allocations.get(&resource_unit)) + .copied() + .unwrap_or(Decimal::ZERO) + } + + /// Get base price for a resource + pub fn get_base_price( + &self, + service_type: ServiceType, + resource_unit: ResourceUnit, + ) -> Option { + self.service_pricing + .get(&service_type) + .and_then(|p| p.base_prices.get(&resource_unit)) + .copied() + } + + /// Get pricing tier by name + pub fn get_tier(&self, name: &str) -> Option<&PricingTier> { + self.tiers.iter().find(|t| t.name == name) + } + + /// Get all tiers + pub fn get_all_tiers(&self) -> &[PricingTier] { + &self.tiers + } + + /// Add a custom discount + pub fn add_discount(&mut self, discount: Discount) { + self.discounts.push(discount); + } + + /// Apply applicable discounts to an amount + pub fn apply_discounts( + &self, + amount: SynorDecimal, + account_id: &str, + service_type: Option, + ) -> (SynorDecimal, Vec) { + let mut discounted = amount; + let mut applied = Vec::new(); + + for discount in &self.discounts { + if discount.is_applicable(account_id, service_type) { + let discount_amount = discount.calculate_discount(discounted); + discounted -= discount_amount; + applied.push(discount.clone()); + } + } + + (discounted, applied) + } + + /// Get pricing summary for display + pub fn get_pricing_summary(&self) -> PricingSummary { + PricingSummary { + storage: ServicePricingSummary { + gb_month: self.get_base_price(ServiceType::Storage, ResourceUnit::GbMonth), + retrieval_gb: self.get_base_price(ServiceType::Storage, ResourceUnit::BandwidthGb), + free_storage_gb: self.get_free_allocation(ServiceType::Storage, ResourceUnit::GbMonth), + }, + hosting: HostingPricingSummary { + bandwidth_gb: self.get_base_price(ServiceType::Hosting, ResourceUnit::BandwidthGb), + domain_month: self.get_base_price(ServiceType::Hosting, ResourceUnit::Domains), + free_bandwidth_gb: self + .get_free_allocation(ServiceType::Hosting, ResourceUnit::BandwidthGb), + }, + database: DatabasePricingSummary { + storage_gb_month: self.get_base_price(ServiceType::Database, ResourceUnit::GbMonth), + queries_million: self + .get_base_price(ServiceType::Database, ResourceUnit::Queries) + .map(|p| p * Decimal::from(1_000_000)), + vector_searches_million: self + .get_base_price(ServiceType::Database, ResourceUnit::VectorSearches) + .map(|p| p * Decimal::from(1_000_000)), + free_queries: self + .get_free_allocation(ServiceType::Database, ResourceUnit::Queries), + }, + compute: ComputePricingSummary { + cpu_core_hour: self.get_base_price(ServiceType::Compute, ResourceUnit::CpuCoreHours), + gpu_hour: self.get_base_price(ServiceType::Compute, ResourceUnit::GpuHours), + memory_gb_hour: self + .get_base_price(ServiceType::Compute, ResourceUnit::MemoryGbHours), + invocations_million: self + .get_base_price(ServiceType::Compute, ResourceUnit::Invocations) + .map(|p| p * Decimal::from(1_000_000)), + free_cpu_hours: self + .get_free_allocation(ServiceType::Compute, ResourceUnit::CpuCoreHours), + }, + } + } +} + +impl Default for PricingEngine { + fn default() -> Self { + Self::new() + } +} + +/// Pricing summary for display +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PricingSummary { + pub storage: ServicePricingSummary, + pub hosting: HostingPricingSummary, + pub database: DatabasePricingSummary, + pub compute: ComputePricingSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServicePricingSummary { + pub gb_month: Option, + pub retrieval_gb: Option, + pub free_storage_gb: SynorDecimal, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HostingPricingSummary { + pub bandwidth_gb: Option, + pub domain_month: Option, + pub free_bandwidth_gb: SynorDecimal, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabasePricingSummary { + pub storage_gb_month: Option, + pub queries_million: Option, + pub vector_searches_million: Option, + pub free_queries: SynorDecimal, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComputePricingSummary { + pub cpu_core_hour: Option, + pub gpu_hour: Option, + pub memory_gb_hour: Option, + pub invocations_million: Option, + pub free_cpu_hours: SynorDecimal, +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_storage_pricing() { + let engine = PricingEngine::new(); + + // 10 GB storage for a month + let cost = engine + .calculate_cost(ServiceType::Storage, ResourceUnit::GbMonth, dec!(10)) + .unwrap(); + + assert_eq!(cost, dec!(0.20)); // 10 * 0.02 + } + + #[test] + fn test_compute_pricing() { + let engine = PricingEngine::new(); + + // 5 GPU hours + let cost = engine + .calculate_cost(ServiceType::Compute, ResourceUnit::GpuHours, dec!(5)) + .unwrap(); + + assert_eq!(cost, dec!(2.50)); // 5 * 0.50 + } + + #[test] + fn test_database_queries() { + let engine = PricingEngine::new(); + + // 10 million queries + let cost = engine + .calculate_cost(ServiceType::Database, ResourceUnit::Queries, dec!(10_000_000)) + .unwrap(); + + assert_eq!(cost, dec!(0.10)); // 10M * 0.00000001 + } + + #[test] + fn test_tier_discount() { + let engine = PricingEngine::new(); + + // Premium tier gets 20% discount + let base_cost = dec!(100); + let discount = engine.calculate_tier_discount("premium", base_cost).unwrap(); + + assert_eq!(discount, dec!(20)); // 20% + } + + #[test] + fn test_free_allocation() { + let engine = PricingEngine::new(); + + let free_storage = engine.get_free_allocation(ServiceType::Storage, ResourceUnit::GbMonth); + assert_eq!(free_storage, dec!(0.5)); + + let free_queries = engine.get_free_allocation(ServiceType::Database, ResourceUnit::Queries); + assert_eq!(free_queries, dec!(1_000_000)); + } +} diff --git a/crates/synor-economics/src/pricing/tiers.rs b/crates/synor-economics/src/pricing/tiers.rs new file mode 100644 index 0000000..c18d0be --- /dev/null +++ b/crates/synor-economics/src/pricing/tiers.rs @@ -0,0 +1,300 @@ +//! Pricing Tiers +//! +//! Define different pricing tiers: Free, Standard, Premium, Enterprise + +use crate::SynorDecimal; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +/// Pricing tier definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PricingTier { + /// Tier name/identifier + pub name: String, + /// Display name + pub display_name: String, + /// Monthly base fee (0 for free tier) + pub monthly_fee: SynorDecimal, + /// Discount percentage on usage + pub discount_percentage: SynorDecimal, + /// Priority support included + pub priority_support: bool, + /// SLA guarantee percentage + pub sla_percentage: SynorDecimal, + /// Custom domain limit (0 = unlimited) + pub custom_domain_limit: u32, + /// API rate limit (requests/minute) + pub api_rate_limit: u32, + /// Features included + pub features: Vec, + /// Minimum commitment (months, 0 = no commitment) + pub min_commitment_months: u32, +} + +impl PricingTier { + /// Free tier + pub fn free() -> Self { + Self { + name: "free".to_string(), + display_name: "Free".to_string(), + monthly_fee: Decimal::ZERO, + discount_percentage: Decimal::ZERO, + priority_support: false, + sla_percentage: Decimal::new(95, 0), // 95% SLA + custom_domain_limit: 1, + api_rate_limit: 100, + features: vec![ + "0.5 GB Storage".to_string(), + "1 GB Hosting Bandwidth".to_string(), + "1M Database Queries".to_string(), + "100 CPU Core-Hours".to_string(), + "Community Support".to_string(), + ], + min_commitment_months: 0, + } + } + + /// Standard tier + pub fn standard() -> Self { + Self { + name: "standard".to_string(), + display_name: "Standard".to_string(), + monthly_fee: Decimal::new(10, 0), // 10 SYNOR/month + discount_percentage: Decimal::new(10, 0), // 10% discount + priority_support: false, + sla_percentage: Decimal::new(99, 0), // 99% SLA + custom_domain_limit: 5, + api_rate_limit: 1000, + features: vec![ + "Everything in Free".to_string(), + "10% Usage Discount".to_string(), + "5 Custom Domains".to_string(), + "Email Support".to_string(), + "99% SLA Guarantee".to_string(), + ], + min_commitment_months: 0, + } + } + + /// Premium tier + pub fn premium() -> Self { + Self { + name: "premium".to_string(), + display_name: "Premium".to_string(), + monthly_fee: Decimal::new(50, 0), // 50 SYNOR/month + discount_percentage: Decimal::new(20, 0), // 20% discount + priority_support: true, + sla_percentage: Decimal::new(999, 1), // 99.9% SLA + custom_domain_limit: 20, + api_rate_limit: 5000, + features: vec![ + "Everything in Standard".to_string(), + "20% Usage Discount".to_string(), + "20 Custom Domains".to_string(), + "Priority Support".to_string(), + "99.9% SLA Guarantee".to_string(), + "Advanced Analytics".to_string(), + ], + min_commitment_months: 0, + } + } + + /// Enterprise tier + pub fn enterprise() -> Self { + Self { + name: "enterprise".to_string(), + display_name: "Enterprise".to_string(), + monthly_fee: Decimal::new(500, 0), // 500 SYNOR/month (or custom) + discount_percentage: Decimal::new(30, 0), // 30% discount + priority_support: true, + sla_percentage: Decimal::new(9999, 2), // 99.99% SLA + custom_domain_limit: 0, // Unlimited + api_rate_limit: 0, // Unlimited + features: vec![ + "Everything in Premium".to_string(), + "30%+ Usage Discount".to_string(), + "Unlimited Custom Domains".to_string(), + "Dedicated Support".to_string(), + "99.99% SLA Guarantee".to_string(), + "Custom Integrations".to_string(), + "Volume Pricing".to_string(), + "Invoice Billing".to_string(), + ], + min_commitment_months: 12, + } + } + + /// Create custom tier + pub fn custom( + name: impl Into, + display_name: impl Into, + monthly_fee: SynorDecimal, + discount_percentage: SynorDecimal, + ) -> Self { + Self { + name: name.into(), + display_name: display_name.into(), + monthly_fee, + discount_percentage, + priority_support: discount_percentage >= Decimal::new(20, 0), + sla_percentage: Decimal::new(99, 0), + custom_domain_limit: 10, + api_rate_limit: 2000, + features: vec!["Custom tier".to_string()], + min_commitment_months: 0, + } + } + + /// Check if this tier has a feature + pub fn has_feature(&self, feature: &str) -> bool { + self.features.iter().any(|f| f.to_lowercase().contains(&feature.to_lowercase())) + } + + /// Calculate effective monthly cost including usage discount + pub fn effective_cost(&self, base_usage: SynorDecimal) -> SynorDecimal { + let discount_rate = self.discount_percentage / Decimal::ONE_HUNDRED; + let discounted_usage = base_usage * (Decimal::ONE - discount_rate); + self.monthly_fee + discounted_usage + } + + /// Check if an upgrade to another tier makes sense + pub fn should_upgrade_to(&self, other: &PricingTier, monthly_usage: SynorDecimal) -> bool { + let current_cost = self.effective_cost(monthly_usage); + let other_cost = other.effective_cost(monthly_usage); + + // Upgrade if other tier is cheaper or offers significant benefits + other_cost < current_cost || (other.sla_percentage > self.sla_percentage && other_cost <= current_cost * Decimal::new(12, 1)) + } +} + +/// Tier comparison for upgrade recommendations +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TierComparison { + pub current_tier: String, + pub recommended_tier: String, + pub current_monthly_cost: SynorDecimal, + pub recommended_monthly_cost: SynorDecimal, + pub monthly_savings: SynorDecimal, + pub additional_benefits: Vec, +} + +/// Compare tiers based on usage +pub fn compare_tiers( + current: &PricingTier, + all_tiers: &[PricingTier], + monthly_usage: SynorDecimal, +) -> Option { + let current_cost = current.effective_cost(monthly_usage); + + // Find most cost-effective tier + let mut best_tier: Option<&PricingTier> = None; + let mut best_cost = current_cost; + + for tier in all_tiers { + if tier.name == current.name { + continue; + } + + let tier_cost = tier.effective_cost(monthly_usage); + if tier_cost < best_cost { + best_cost = tier_cost; + best_tier = Some(tier); + } + } + + best_tier.map(|recommended| { + let recommended_cost = recommended.effective_cost(monthly_usage); + let savings = current_cost - recommended_cost; + + // Find additional benefits + let additional_benefits: Vec<_> = recommended + .features + .iter() + .filter(|f| !current.has_feature(f)) + .cloned() + .collect(); + + TierComparison { + current_tier: current.name.clone(), + recommended_tier: recommended.name.clone(), + current_monthly_cost: current_cost, + recommended_monthly_cost: recommended_cost, + monthly_savings: savings, + additional_benefits, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_free_tier() { + let tier = PricingTier::free(); + assert_eq!(tier.monthly_fee, Decimal::ZERO); + assert_eq!(tier.discount_percentage, Decimal::ZERO); + } + + #[test] + fn test_standard_tier() { + let tier = PricingTier::standard(); + assert_eq!(tier.monthly_fee, dec!(10)); + assert_eq!(tier.discount_percentage, dec!(10)); + assert!(!tier.priority_support); + } + + #[test] + fn test_premium_tier() { + let tier = PricingTier::premium(); + assert_eq!(tier.monthly_fee, dec!(50)); + assert_eq!(tier.discount_percentage, dec!(20)); + assert!(tier.priority_support); + } + + #[test] + fn test_effective_cost() { + let free = PricingTier::free(); + let premium = PricingTier::premium(); + + // With 200 SYNOR usage + let free_cost = free.effective_cost(dec!(200)); + let premium_cost = premium.effective_cost(dec!(200)); + + // Free: 0 + 200 = 200 + // Premium: 50 + (200 * 0.80) = 50 + 160 = 210 + assert_eq!(free_cost, dec!(200)); + assert_eq!(premium_cost, dec!(210)); + + // With 500 SYNOR usage, premium becomes cheaper + let free_cost_high = free.effective_cost(dec!(500)); + let premium_cost_high = premium.effective_cost(dec!(500)); + + // Free: 0 + 500 = 500 + // Premium: 50 + (500 * 0.80) = 50 + 400 = 450 + assert_eq!(free_cost_high, dec!(500)); + assert_eq!(premium_cost_high, dec!(450)); + + assert!(premium_cost_high < free_cost_high); + } + + #[test] + fn test_tier_comparison() { + let tiers = vec![ + PricingTier::free(), + PricingTier::standard(), + PricingTier::premium(), + ]; + + let free = &tiers[0]; + + // High usage - should recommend upgrade + let comparison = compare_tiers(free, &tiers, dec!(500)); + assert!(comparison.is_some()); + + let comp = comparison.unwrap(); + assert_eq!(comp.current_tier, "free"); + assert!(comp.monthly_savings > Decimal::ZERO); + } +} diff --git a/docker-compose.economics.yml b/docker-compose.economics.yml new file mode 100644 index 0000000..639bc8d --- /dev/null +++ b/docker-compose.economics.yml @@ -0,0 +1,169 @@ +# Docker Compose for Synor Economics Services +# Phase 12: Economics & Billing Infrastructure + +version: "3.8" + +services: + # Price Oracle Service + # Aggregates SYNOR/USD prices from multiple sources + price-oracle: + build: + context: . + dockerfile: docker/economics-service/Dockerfile + container_name: synor-price-oracle + ports: + - "4010:4010" # HTTP API + - "4011:4011" # Metrics + environment: + - RUST_LOG=info + - SERVICE_TYPE=oracle + - ORACLE_UPDATE_INTERVAL=30 + - ORACLE_MIN_SOURCES=2 + volumes: + - ./docker/economics-service/config.toml:/app/config/config.toml:ro + - oracle-data:/app/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4010/health"] + interval: 30s + timeout: 10s + retries: 3 + restart: unless-stopped + networks: + - synor-economics + + # Metering Service + # Real-time usage tracking for L2 services + metering-service: + build: + context: . + dockerfile: docker/economics-service/Dockerfile + container_name: synor-metering + ports: + - "4012:4010" # HTTP API (different host port) + - "4013:4011" # Metrics + environment: + - RUST_LOG=info + - SERVICE_TYPE=metering + - METERING_BUFFER_SIZE=10000 + - METERING_AGGREGATION_INTERVAL=3600 + volumes: + - ./docker/economics-service/config.toml:/app/config/config.toml:ro + - metering-data:/app/data + depends_on: + - price-oracle + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4010/health"] + interval: 30s + timeout: 10s + retries: 3 + restart: unless-stopped + networks: + - synor-economics + + # Billing Service + # Invoice generation and payment processing + billing-service: + build: + context: . + dockerfile: docker/economics-service/Dockerfile + container_name: synor-billing + ports: + - "4014:4010" # HTTP API + - "4015:4011" # Metrics + environment: + - RUST_LOG=info + - SERVICE_TYPE=billing + - BILLING_CYCLE_DAYS=30 + - AUTO_PAY_ENABLED=true + volumes: + - ./docker/economics-service/config.toml:/app/config/config.toml:ro + - billing-data:/app/data + depends_on: + - price-oracle + - metering-service + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4010/health"] + interval: 30s + timeout: 10s + retries: 3 + restart: unless-stopped + networks: + - synor-economics + + # Cost Calculator API + # Cost estimation endpoint + cost-calculator: + build: + context: . + dockerfile: docker/economics-service/Dockerfile + container_name: synor-cost-calculator + ports: + - "4016:4010" # HTTP API + - "4017:4011" # Metrics + environment: + - RUST_LOG=info + - SERVICE_TYPE=calculator + volumes: + - ./docker/economics-service/config.toml:/app/config/config.toml:ro + depends_on: + - price-oracle + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4010/health"] + interval: 30s + timeout: 10s + retries: 3 + restart: unless-stopped + networks: + - synor-economics + + # Redis for caching and event streaming + redis: + image: redis:7-alpine + container_name: synor-economics-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - synor-economics + + # PostgreSQL for persistent storage + postgres: + image: postgres:16-alpine + container_name: synor-economics-postgres + ports: + - "5432:5432" + environment: + - POSTGRES_USER=synor + - POSTGRES_PASSWORD=synor_economics_dev + - POSTGRES_DB=synor_economics + volumes: + - postgres-data:/var/lib/postgresql/data + - ./docker/economics-service/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U synor -d synor_economics"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - synor-economics + +networks: + synor-economics: + driver: bridge + name: synor-economics-network + +volumes: + oracle-data: + metering-data: + billing-data: + redis-data: + postgres-data: diff --git a/docker/economics-service/Dockerfile b/docker/economics-service/Dockerfile new file mode 100644 index 0000000..ede18eb --- /dev/null +++ b/docker/economics-service/Dockerfile @@ -0,0 +1,49 @@ +# Synor Economics Service Dockerfile +# Provides pricing oracle, metering, and billing APIs + +FROM rust:1.75-bookworm AS builder + +WORKDIR /app + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY crates/ ./crates/ + +# Build the economics service binary +RUN cargo build --release -p synor-economics --features "http-feeds" + +# Runtime image +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy built binary +COPY --from=builder /app/target/release/synor-economics /usr/local/bin/ 2>/dev/null || true + +# Create config directory +RUN mkdir -p /app/config /app/data + +# Copy default config +COPY docker/economics-service/config.toml /app/config/ + +# Environment variables +ENV RUST_LOG=info +ENV CONFIG_PATH=/app/config/config.toml +ENV DATA_PATH=/app/data + +# Ports +# 4010 - HTTP API +# 4011 - Metrics +EXPOSE 4010 4011 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:4010/health || exit 1 + +# For library-only crate, we run a simple health indicator +CMD ["echo", "Economics service ready. Use as library dependency."] diff --git a/docker/economics-service/config.toml b/docker/economics-service/config.toml new file mode 100644 index 0000000..3f178dd --- /dev/null +++ b/docker/economics-service/config.toml @@ -0,0 +1,128 @@ +# Synor Economics Service Configuration + +[server] +# HTTP API port +http_port = 4010 +# Metrics port (Prometheus) +metrics_port = 4011 +# Bind address +bind_address = "0.0.0.0" + +[oracle] +# Maximum price age before considered stale (seconds) +max_price_age_secs = 300 +# TWAP window duration (seconds) +twap_window_secs = 3600 +# Minimum number of price sources required +min_sources = 2 +# Maximum deviation between sources (percentage) +max_deviation_percent = 5.0 +# Update interval (seconds) +update_interval_secs = 30 + +[oracle.sources] +# Enable internal DEX price feed +synor_dex = true +# Enable CoinGecko (requires API key for higher rate limits) +coingecko = true +coingecko_api_key = "" + +[metering] +# Aggregation interval (seconds) +aggregation_interval_secs = 3600 +# Maximum events to buffer before flush +buffer_size = 10000 +# Enable real-time usage tracking +real_time_tracking = true +# Event retention period (days) +retention_days = 90 + +[billing] +# Billing cycle in days +billing_cycle_days = 30 +# Grace period for late payments (days) +grace_period_days = 7 +# Minimum invoice amount (SYNOR) +minimum_invoice_amount = 0.01 +# Enable auto-pay from prepaid balance +auto_pay_enabled = true +# Enable invoice reminders +reminders_enabled = true +# Days before due date to send reminder +reminder_days_before = 3 + +[pricing] +# Default tier for new accounts +default_tier = "free" + +# Storage L2 pricing (SYNOR) +[pricing.storage] +gb_month = 0.02 +retrieval_gb = 0.01 +deal_creation = 0.001 + +# Hosting pricing (SYNOR) +[pricing.hosting] +bandwidth_gb = 0.05 +custom_domain_month = 0.50 +ssl_certificate = 0.00 + +# Database L2 pricing (SYNOR) +[pricing.database] +storage_gb_month = 0.10 +queries_per_million = 0.01 +vector_searches_per_million = 0.05 + +# Compute L2 pricing (SYNOR) +[pricing.compute] +cpu_core_hour = 0.02 +gpu_hour_rtx4090 = 0.50 +memory_gb_hour = 0.005 +invocations_per_million = 0.20 + +# Free tier allocations +[pricing.free_tier] +storage_gb = 0.5 +hosting_bandwidth_gb = 1.0 +database_queries = 1000000 +compute_cpu_hours = 100 +compute_memory_gb_hours = 200 +compute_invocations = 1000000 +network_bandwidth_gb = 10 + +# Tier discounts +[pricing.tiers.free] +monthly_fee = 0.0 +discount_percent = 0 + +[pricing.tiers.standard] +monthly_fee = 10.0 +discount_percent = 10 + +[pricing.tiers.premium] +monthly_fee = 50.0 +discount_percent = 20 + +[pricing.tiers.enterprise] +monthly_fee = 500.0 +discount_percent = 30 + +# Fee distribution +[distribution] +# L1 transaction fees +[distribution.l1] +burn_percent = 10 +staker_percent = 60 +treasury_percent = 20 +validator_percent = 10 + +# L2 service fees +[distribution.l2] +burn_percent = 10 +operator_percent = 70 +treasury_percent = 20 + +[logging] +level = "info" +format = "json" +output = "stdout" diff --git a/docker/economics-service/init.sql b/docker/economics-service/init.sql new file mode 100644 index 0000000..6833177 --- /dev/null +++ b/docker/economics-service/init.sql @@ -0,0 +1,226 @@ +-- Synor Economics Database Schema +-- Phase 12: Economics & Billing Infrastructure + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Accounts table +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id VARCHAR(255) UNIQUE NOT NULL, + tier VARCHAR(50) NOT NULL DEFAULT 'free', + prepaid_balance DECIMAL(38, 18) NOT NULL DEFAULT 0, + credit_balance DECIMAL(38, 18) NOT NULL DEFAULT 0, + billing_cycle_start TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Price history table +CREATE TABLE IF NOT EXISTS price_history ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + token VARCHAR(20) NOT NULL, + quote VARCHAR(20) NOT NULL, + price DECIMAL(38, 18) NOT NULL, + source VARCHAR(50) NOT NULL, + confidence DECIMAL(5, 4) NOT NULL DEFAULT 1.0, + timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Create index for price lookups +CREATE INDEX IF NOT EXISTS idx_price_history_pair_time + ON price_history(token, quote, timestamp DESC); + +-- Usage events table +CREATE TABLE IF NOT EXISTS usage_events ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + event_id VARCHAR(100) UNIQUE NOT NULL, + account_id VARCHAR(255) NOT NULL REFERENCES accounts(account_id), + service_id VARCHAR(255), + service_type VARCHAR(50) NOT NULL, + resource_unit VARCHAR(50) NOT NULL, + amount DECIMAL(38, 18) NOT NULL, + cost DECIMAL(38, 18), + timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + metadata JSONB DEFAULT '{}' +); + +-- Create indexes for usage queries +CREATE INDEX IF NOT EXISTS idx_usage_events_account + ON usage_events(account_id, timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_usage_events_service + ON usage_events(service_type, timestamp DESC); + +-- Invoices table +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + invoice_id VARCHAR(100) UNIQUE NOT NULL, + invoice_number VARCHAR(50) NOT NULL, + account_id VARCHAR(255) NOT NULL REFERENCES accounts(account_id), + status VARCHAR(20) NOT NULL DEFAULT 'draft', + period_start TIMESTAMP WITH TIME ZONE NOT NULL, + period_end TIMESTAMP WITH TIME ZONE NOT NULL, + subtotal DECIMAL(38, 18) NOT NULL DEFAULT 0, + discount DECIMAL(38, 18) NOT NULL DEFAULT 0, + discount_description TEXT, + tax DECIMAL(38, 18) NOT NULL DEFAULT 0, + total DECIMAL(38, 18) NOT NULL DEFAULT 0, + due_date TIMESTAMP WITH TIME ZONE NOT NULL, + paid_at TIMESTAMP WITH TIME ZONE, + payment_id VARCHAR(100), + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Create indexes for invoice queries +CREATE INDEX IF NOT EXISTS idx_invoices_account + ON invoices(account_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_invoices_status + ON invoices(status); + +-- Invoice line items table +CREATE TABLE IF NOT EXISTS invoice_line_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + invoice_id VARCHAR(100) NOT NULL REFERENCES invoices(invoice_id), + description TEXT NOT NULL, + service_type VARCHAR(50) NOT NULL, + quantity DECIMAL(38, 18) NOT NULL, + unit_price DECIMAL(38, 18) NOT NULL, + amount DECIMAL(38, 18) NOT NULL +); + +-- Payments table +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + payment_id VARCHAR(100) UNIQUE NOT NULL, + account_id VARCHAR(255) NOT NULL REFERENCES accounts(account_id), + invoice_id VARCHAR(100) REFERENCES invoices(invoice_id), + amount DECIMAL(38, 18) NOT NULL, + method VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + transaction_hash VARCHAR(100), + block_number BIGINT, + failure_reason TEXT, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE +); + +-- Create indexes for payment queries +CREATE INDEX IF NOT EXISTS idx_payments_account + ON payments(account_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_payments_status + ON payments(status); + +-- Credits table +CREATE TABLE IF NOT EXISTS credits ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + credit_id VARCHAR(100) UNIQUE NOT NULL, + account_id VARCHAR(255) NOT NULL REFERENCES accounts(account_id), + original_amount DECIMAL(38, 18) NOT NULL, + remaining_amount DECIMAL(38, 18) NOT NULL, + used_amount DECIMAL(38, 18) NOT NULL DEFAULT 0, + credit_type VARCHAR(50) NOT NULL, + reason TEXT NOT NULL, + reference_id VARCHAR(255), + approved_by VARCHAR(255), + is_active BOOLEAN NOT NULL DEFAULT true, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Create indexes for credit queries +CREATE INDEX IF NOT EXISTS idx_credits_account + ON credits(account_id, is_active); + +-- Discounts table +CREATE TABLE IF NOT EXISTS discounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + code VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + discount_type VARCHAR(50) NOT NULL, + value DECIMAL(38, 18) NOT NULL, + min_spend DECIMAL(38, 18), + max_discount DECIMAL(38, 18), + service_types TEXT[], + account_ids TEXT[], + start_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + end_date TIMESTAMP WITH TIME ZONE, + max_uses INTEGER, + current_uses INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Pricing tiers table +CREATE TABLE IF NOT EXISTS pricing_tiers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(50) UNIQUE NOT NULL, + display_name VARCHAR(100) NOT NULL, + monthly_fee DECIMAL(38, 18) NOT NULL DEFAULT 0, + discount_percentage DECIMAL(5, 2) NOT NULL DEFAULT 0, + priority_support BOOLEAN NOT NULL DEFAULT false, + sla_percentage DECIMAL(5, 2) NOT NULL DEFAULT 95.00, + custom_domain_limit INTEGER NOT NULL DEFAULT 1, + api_rate_limit INTEGER NOT NULL DEFAULT 100, + features TEXT[] NOT NULL DEFAULT '{}', + min_commitment_months INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Insert default pricing tiers +INSERT INTO pricing_tiers (name, display_name, monthly_fee, discount_percentage, priority_support, sla_percentage, custom_domain_limit, api_rate_limit, features, min_commitment_months) +VALUES + ('free', 'Free', 0, 0, false, 95.00, 1, 100, ARRAY['0.5 GB Storage', '1 GB Hosting Bandwidth', '1M Database Queries', '100 CPU Core-Hours', 'Community Support'], 0), + ('standard', 'Standard', 10, 10, false, 99.00, 5, 1000, ARRAY['Everything in Free', '10% Usage Discount', '5 Custom Domains', 'Email Support', '99% SLA Guarantee'], 0), + ('premium', 'Premium', 50, 20, true, 99.90, 20, 5000, ARRAY['Everything in Standard', '20% Usage Discount', '20 Custom Domains', 'Priority Support', '99.9% SLA Guarantee', 'Advanced Analytics'], 0), + ('enterprise', 'Enterprise', 500, 30, true, 99.99, 0, 0, ARRAY['Everything in Premium', '30%+ Usage Discount', 'Unlimited Custom Domains', 'Dedicated Support', '99.99% SLA Guarantee', 'Custom Integrations', 'Volume Pricing', 'Invoice Billing'], 12) +ON CONFLICT (name) DO NOTHING; + +-- Aggregated usage view +CREATE OR REPLACE VIEW usage_summary AS +SELECT + account_id, + service_type, + DATE_TRUNC('day', timestamp) as usage_date, + SUM(amount) as total_amount, + SUM(cost) as total_cost, + COUNT(*) as event_count +FROM usage_events +GROUP BY account_id, service_type, DATE_TRUNC('day', timestamp); + +-- Outstanding invoices view +CREATE OR REPLACE VIEW outstanding_invoices AS +SELECT + i.*, + a.prepaid_balance, + a.credit_balance, + (a.prepaid_balance + a.credit_balance) >= i.total as can_auto_pay +FROM invoices i +JOIN accounts a ON i.account_id = a.account_id +WHERE i.status IN ('pending', 'overdue') +ORDER BY i.due_date ASC; + +-- Function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Add triggers for updated_at +CREATE TRIGGER update_accounts_updated_at + BEFORE UPDATE ON accounts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_invoices_updated_at + BEFORE UPDATE ON invoices + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Grant permissions +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO synor; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO synor;