feat(economics): add Phase 12 - Economics & Billing infrastructure
Complete economics service implementation with: - Price Oracle with TWAP (Time-Weighted Average Price) - Multi-source price aggregation - Configurable staleness thresholds - Exponential, SMA, and standard TWAP strategies - Metering Service for L2 usage tracking - Storage: GB-months, retrieval bandwidth - Hosting: bandwidth, custom domains - Database: queries, vector searches - Compute: CPU core-hours, GPU hours, memory - Network: bandwidth, requests - Billing Engine - Invoice generation with line items - Payment processing (crypto/fiat) - Credit management with expiration - Auto-pay from prepaid balance - Pricing Tiers: Free, Standard, Premium, Enterprise - 0%, 10%, 20%, 30% usage discounts - SLA guarantees: 95%, 99%, 99.9%, 99.99% - Cost Calculator & Estimator - Usage projections - Tier comparison recommendations - ROI analysis - Docker deployment with PostgreSQL schema All 61 tests passing.
This commit is contained in:
parent
8b152a5a23
commit
17f0b4ce4b
26 changed files with 6826 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
58
crates/synor-economics/Cargo.toml
Normal file
58
crates/synor-economics/Cargo.toml
Normal file
|
|
@ -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"]
|
||||
|
||||
338
crates/synor-economics/src/billing/credit.rs
Normal file
338
crates/synor-economics/src/billing/credit.rs
Normal file
|
|
@ -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<Utc>,
|
||||
/// Expiration date (optional)
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
/// 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<String>,
|
||||
/// Approved by (admin user)
|
||||
pub approved_by: Option<String>,
|
||||
}
|
||||
|
||||
impl Credit {
|
||||
/// Create a new credit
|
||||
pub fn new(
|
||||
account_id: impl Into<String>,
|
||||
amount: SynorDecimal,
|
||||
reason: impl Into<String>,
|
||||
) -> 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<String>, 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<String>,
|
||||
amount: SynorDecimal,
|
||||
referrer_id: impl Into<String>,
|
||||
) -> 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<String>,
|
||||
amount: SynorDecimal,
|
||||
incident_id: impl Into<String>,
|
||||
) -> 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<Utc>) -> 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<String>) -> Self {
|
||||
self.reference_id = Some(reference_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set approver
|
||||
pub fn with_approver(mut self, approver: impl Into<String>) -> Self {
|
||||
self.approved_by = Some(approver.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Use some of the credit
|
||||
pub fn use_credit(&mut self, amount: SynorDecimal) -> Result<SynorDecimal, CreditError> {
|
||||
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<i64> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
344
crates/synor-economics/src/billing/invoice.rs
Normal file
344
crates/synor-economics/src/billing/invoice.rs
Normal file
|
|
@ -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<String>,
|
||||
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<Utc>,
|
||||
/// Billing period end
|
||||
pub period_end: DateTime<Utc>,
|
||||
/// Line items
|
||||
pub line_items: Vec<InvoiceLineItem>,
|
||||
/// Subtotal before discounts
|
||||
pub subtotal: SynorDecimal,
|
||||
/// Discount amount
|
||||
pub discount: SynorDecimal,
|
||||
/// Discount description
|
||||
pub discount_description: Option<String>,
|
||||
/// 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<Utc>,
|
||||
/// Date created
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Date paid (if paid)
|
||||
pub paid_at: Option<DateTime<Utc>>,
|
||||
/// Payment ID (if paid)
|
||||
pub payment_id: Option<String>,
|
||||
/// Notes/memo
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
impl Invoice {
|
||||
/// Create a new invoice
|
||||
pub fn new(account_id: impl Into<String>) -> 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<Utc>, end: DateTime<Utc>) -> Self {
|
||||
self.period_start = start;
|
||||
self.period_end = end;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add line items
|
||||
pub fn with_line_items(mut self, items: Vec<InvoiceLineItem>) -> 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<String>,
|
||||
) -> 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<Utc>) -> Self {
|
||||
self.due_date = due_date;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set notes
|
||||
pub fn with_notes(mut self, notes: impl Into<String>) -> 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
|
||||
}
|
||||
}
|
||||
576
crates/synor-economics/src/billing/mod.rs
Normal file
576
crates/synor-economics/src/billing/mod.rs
Normal file
|
|
@ -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<DateTime<Utc>>,
|
||||
pub next_invoice: Option<DateTime<Utc>>,
|
||||
pub outstanding_invoices: Vec<String>,
|
||||
}
|
||||
|
||||
/// Billing engine for managing invoices and payments
|
||||
pub struct BillingEngine {
|
||||
config: BillingConfig,
|
||||
metering: Arc<MeteringService>,
|
||||
pricing: Arc<PricingEngine>,
|
||||
/// Account billing data
|
||||
accounts: RwLock<HashMap<AccountId, AccountData>>,
|
||||
/// Invoices by ID
|
||||
invoices: RwLock<HashMap<String, Invoice>>,
|
||||
/// Payments by ID
|
||||
payments: RwLock<HashMap<String, Payment>>,
|
||||
/// Credits by ID
|
||||
credits: RwLock<HashMap<String, Credit>>,
|
||||
}
|
||||
|
||||
/// Internal account data
|
||||
#[derive(Debug, Clone)]
|
||||
struct AccountData {
|
||||
account_id: AccountId,
|
||||
prepaid_balance: SynorDecimal,
|
||||
credit_balance: SynorDecimal,
|
||||
tier: String,
|
||||
billing_cycle_start: DateTime<Utc>,
|
||||
created_at: DateTime<Utc>,
|
||||
last_payment: Option<DateTime<Utc>>,
|
||||
invoice_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl BillingEngine {
|
||||
/// Create a new billing engine
|
||||
pub fn new(metering: Arc<MeteringService>, pricing: Arc<PricingEngine>) -> 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<MeteringService>,
|
||||
pricing: Arc<PricingEngine>,
|
||||
) -> 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<AccountBillingInfo> {
|
||||
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<Invoice> {
|
||||
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<Invoice> {
|
||||
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<Vec<Invoice>> {
|
||||
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<Vec<Invoice>> {
|
||||
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<Vec<Invoice>> {
|
||||
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(_))));
|
||||
}
|
||||
}
|
||||
345
crates/synor-economics/src/billing/payment.rs
Normal file
345
crates/synor-economics/src/billing/payment.rs
Normal file
|
|
@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// Block number (for on-chain payments)
|
||||
pub block_number: Option<u64>,
|
||||
/// Date created
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Date completed
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
/// Failure reason (if failed)
|
||||
pub failure_reason: Option<String>,
|
||||
/// Additional metadata
|
||||
pub metadata: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Payment {
|
||||
/// Create a new payment
|
||||
pub fn new(account_id: impl Into<String>, 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<String>) -> Self {
|
||||
self.invoice_id = Some(invoice_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add transaction hash
|
||||
pub fn with_transaction(mut self, hash: impl Into<String>) -> 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<String>, value: impl Into<String>) -> 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<String>) {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
649
crates/synor-economics/src/calculator/estimator.rs
Normal file
649
crates/synor-economics/src/calculator/estimator.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<String>) -> 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<ServiceType, ServiceCostBreakdown>,
|
||||
/// 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<Decimal>,
|
||||
}
|
||||
|
||||
/// Cost breakdown for a single service
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServiceCostBreakdown {
|
||||
/// Service type
|
||||
pub service: ServiceType,
|
||||
/// Line items
|
||||
pub items: Vec<CostLineItem>,
|
||||
/// 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<PricingEngine>,
|
||||
}
|
||||
|
||||
impl CostEstimator {
|
||||
/// Create a new cost estimator
|
||||
pub fn new(pricing: Arc<PricingEngine>) -> Self {
|
||||
Self { pricing }
|
||||
}
|
||||
|
||||
/// Estimate cost for a usage projection
|
||||
pub async fn estimate(&self, projection: UsageProjection) -> Result<CostEstimate> {
|
||||
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<ServiceCostBreakdown> {
|
||||
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<ServiceCostBreakdown> {
|
||||
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<ServiceCostBreakdown> {
|
||||
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<ServiceCostBreakdown> {
|
||||
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<ServiceCostBreakdown> {
|
||||
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<SynorDecimal> {
|
||||
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
|
||||
}
|
||||
}
|
||||
7
crates/synor-economics/src/calculator/mod.rs
Normal file
7
crates/synor-economics/src/calculator/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//! Cost Calculator
|
||||
//!
|
||||
//! Estimate costs before resource consumption.
|
||||
|
||||
mod estimator;
|
||||
|
||||
pub use estimator::{CostEstimate, CostEstimator, UsageProjection};
|
||||
142
crates/synor-economics/src/error.rs
Normal file
142
crates/synor-economics/src/error.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
//! Error types for Synor Economics
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Result type for economics operations
|
||||
pub type Result<T> = std::result::Result<T, EconomicsError>;
|
||||
|
||||
/// 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
388
crates/synor-economics/src/lib.rs
Normal file
388
crates/synor-economics/src/lib.rs
Normal file
|
|
@ -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<ServiceType, SynorDecimal>,
|
||||
pub current_period_cost: SynorDecimal,
|
||||
pub billing_tier: String,
|
||||
pub last_payment: Option<DateTime<Utc>>,
|
||||
pub next_invoice_date: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Unified economics manager combining all services
|
||||
pub struct EconomicsManager {
|
||||
pub oracle: Arc<RwLock<PriceOracle>>,
|
||||
pub metering: Arc<MeteringService>,
|
||||
pub billing: Arc<BillingEngine>,
|
||||
pub pricing: Arc<PricingEngine>,
|
||||
pub calculator: Arc<CostEstimator>,
|
||||
}
|
||||
|
||||
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<AccountSummary> {
|
||||
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<CostEstimate> {
|
||||
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<SynorDecimal> {
|
||||
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<Invoice> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
137
crates/synor-economics/src/metering/compute.rs
Normal file
137
crates/synor-economics/src/metering/compute.rs
Normal file
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
89
crates/synor-economics/src/metering/database.rs
Normal file
89
crates/synor-economics/src/metering/database.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
70
crates/synor-economics/src/metering/hosting.rs
Normal file
70
crates/synor-economics/src/metering/hosting.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
559
crates/synor-economics/src/metering/mod.rs
Normal file
559
crates/synor-economics/src/metering/mod.rs
Normal file
|
|
@ -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<Utc>,
|
||||
/// Additional metadata
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl UsageEvent {
|
||||
/// Create a new usage event
|
||||
pub fn new(
|
||||
account_id: impl Into<String>,
|
||||
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<String>) -> Self {
|
||||
self.service_id = service_id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Add metadata
|
||||
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> 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<Utc>,
|
||||
pub period_end: DateTime<Utc>,
|
||||
pub by_service: HashMap<ServiceType, SynorDecimal>,
|
||||
pub by_resource: HashMap<ResourceUnit, Decimal>,
|
||||
pub total_cost: SynorDecimal,
|
||||
pub event_count: usize,
|
||||
}
|
||||
|
||||
impl UsageRecord {
|
||||
/// Create empty usage record for a period
|
||||
pub fn new(account_id: impl Into<String>, start: DateTime<Utc>, end: DateTime<Utc>) -> 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<ServiceType, SynorDecimal>,
|
||||
pub current_period_start: DateTime<Utc>,
|
||||
pub last_event: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 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<PricingEngine>,
|
||||
/// Buffered events pending aggregation
|
||||
event_buffer: RwLock<Vec<UsageEvent>>,
|
||||
/// Current period usage by account
|
||||
current_usage: RwLock<HashMap<AccountId, AccountUsage>>,
|
||||
/// Historical aggregated records
|
||||
records: RwLock<Vec<UsageRecord>>,
|
||||
/// Event channel for real-time processing
|
||||
event_tx: async_channel::Sender<UsageEvent>,
|
||||
event_rx: async_channel::Receiver<UsageEvent>,
|
||||
}
|
||||
|
||||
impl MeteringService {
|
||||
/// Create a new metering service
|
||||
pub fn new(pricing: Arc<PricingEngine>) -> 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<PricingEngine>, 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<AccountUsage> {
|
||||
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<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
) -> Result<UsageRecord> {
|
||||
// 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<UsageEvent>) -> 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<UsageEvent> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
85
crates/synor-economics/src/metering/network.rs
Normal file
85
crates/synor-economics/src/metering/network.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
71
crates/synor-economics/src/metering/storage.rs
Normal file
71
crates/synor-economics/src/metering/storage.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
453
crates/synor-economics/src/oracle/mod.rs
Normal file
453
crates/synor-economics/src/oracle/mod.rs
Normal file
|
|
@ -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<Utc>,
|
||||
/// 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<String>,
|
||||
quote: impl Into<String>,
|
||||
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<Utc>,
|
||||
/// 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<String, Vec<TokenPrice>>,
|
||||
/// Active price feeds
|
||||
feeds: Vec<Box<dyn PriceFeed + Send + Sync>>,
|
||||
/// TWAP calculator
|
||||
twap_calculator: TwapCalculator,
|
||||
/// Cached aggregated prices
|
||||
cache: HashMap<String, AggregatedPrice>,
|
||||
}
|
||||
|
||||
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<dyn PriceFeed + Send + Sync>) {
|
||||
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<SynorDecimal> {
|
||||
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<SynorDecimal> {
|
||||
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<AggregatedPrice> {
|
||||
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<TokenPrice> {
|
||||
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<bool> {
|
||||
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<String> = 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<SynorDecimal> {
|
||||
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<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Oracle health status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OracleHealthStatus {
|
||||
pub healthy: bool,
|
||||
pub feed_count: usize,
|
||||
pub pairs: HashMap<String, PairStatus>,
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
393
crates/synor-economics/src/oracle/price_feed.rs
Normal file
393
crates/synor-economics/src/oracle/price_feed.rs
Normal file
|
|
@ -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<TokenPrice>;
|
||||
|
||||
/// 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<TokenPrice> {
|
||||
// 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<String>) -> 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<TokenPrice> {
|
||||
// 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::<Decimal>()
|
||||
.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<String>, rpc_url: impl Into<String>) -> 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<TokenPrice> {
|
||||
// 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<String>,
|
||||
}
|
||||
|
||||
impl CoinGeckoFeed {
|
||||
pub fn new(api_key: Option<String>) -> 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<TokenPrice> {
|
||||
#[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<Box<dyn PriceFeed + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl AggregateFeed {
|
||||
pub fn new(feeds: Vec<Box<dyn PriceFeed + Send + Sync>>) -> Self {
|
||||
Self { feeds }
|
||||
}
|
||||
|
||||
/// Fetch prices from all feeds and return median
|
||||
pub async fn fetch_median(&self, token: &str, quote: &str) -> Result<TokenPrice> {
|
||||
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<TokenPrice> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
387
crates/synor-economics/src/oracle/twap.rs
Normal file
387
crates/synor-economics/src/oracle/twap.rs
Normal file
|
|
@ -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<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
) -> 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>()
|
||||
/ 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<Utc>,
|
||||
pub price_cumulative: SynorDecimal,
|
||||
pub seconds_per_liquidity_cumulative: SynorDecimal,
|
||||
}
|
||||
|
||||
/// On-chain TWAP oracle (Uniswap V3 style)
|
||||
pub struct OnChainTwap {
|
||||
observations: Vec<TwapObservation>,
|
||||
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<SynorDecimal> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
399
crates/synor-economics/src/pricing/discounts.rs
Normal file
399
crates/synor-economics/src/pricing/discounts.rs
Normal file
|
|
@ -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<SynorDecimal>,
|
||||
/// Maximum discount amount
|
||||
pub max_discount: Option<SynorDecimal>,
|
||||
/// Applicable service types (None = all)
|
||||
pub service_types: Option<Vec<ServiceType>>,
|
||||
/// Applicable account IDs (None = all)
|
||||
pub account_ids: Option<Vec<String>>,
|
||||
/// Start date
|
||||
pub start_date: DateTime<Utc>,
|
||||
/// End date (None = no expiry)
|
||||
pub end_date: Option<DateTime<Utc>>,
|
||||
/// Maximum uses (None = unlimited)
|
||||
pub max_uses: Option<u32>,
|
||||
/// 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<String>, name: impl Into<String>, 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<String>, name: impl Into<String>, 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<String>) -> 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<Utc>) -> 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<ServiceType>) -> Self {
|
||||
self.service_types = Some(services);
|
||||
self
|
||||
}
|
||||
|
||||
/// Limit to specific accounts
|
||||
pub fn for_accounts(mut self, accounts: Vec<String>) -> 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<ServiceType>) -> 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<Discount> {
|
||||
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<Discount> {
|
||||
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+)
|
||||
}
|
||||
}
|
||||
463
crates/synor-economics/src/pricing/mod.rs
Normal file
463
crates/synor-economics/src/pricing/mod.rs
Normal file
|
|
@ -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<ResourceUnit, SynorDecimal>,
|
||||
/// Minimum billable amount
|
||||
pub minimum_charge: SynorDecimal,
|
||||
/// Free tier included
|
||||
pub free_tier: Option<FreeTier>,
|
||||
}
|
||||
|
||||
/// Free tier allocation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FreeTier {
|
||||
/// Free allocation per resource
|
||||
pub allocations: HashMap<ResourceUnit, Decimal>,
|
||||
/// Monthly reset
|
||||
pub monthly_reset: bool,
|
||||
}
|
||||
|
||||
/// Pricing engine managing all pricing logic
|
||||
pub struct PricingEngine {
|
||||
/// Pricing by service type
|
||||
service_pricing: HashMap<ServiceType, ServicePricing>,
|
||||
/// Available pricing tiers
|
||||
tiers: Vec<PricingTier>,
|
||||
/// Active discounts
|
||||
discounts: Vec<Discount>,
|
||||
}
|
||||
|
||||
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<SynorDecimal> {
|
||||
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<SynorDecimal> {
|
||||
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<SynorDecimal> {
|
||||
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<SynorDecimal> {
|
||||
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<ServiceType>,
|
||||
) -> (SynorDecimal, Vec<Discount>) {
|
||||
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<SynorDecimal>,
|
||||
pub retrieval_gb: Option<SynorDecimal>,
|
||||
pub free_storage_gb: SynorDecimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HostingPricingSummary {
|
||||
pub bandwidth_gb: Option<SynorDecimal>,
|
||||
pub domain_month: Option<SynorDecimal>,
|
||||
pub free_bandwidth_gb: SynorDecimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DatabasePricingSummary {
|
||||
pub storage_gb_month: Option<SynorDecimal>,
|
||||
pub queries_million: Option<SynorDecimal>,
|
||||
pub vector_searches_million: Option<SynorDecimal>,
|
||||
pub free_queries: SynorDecimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComputePricingSummary {
|
||||
pub cpu_core_hour: Option<SynorDecimal>,
|
||||
pub gpu_hour: Option<SynorDecimal>,
|
||||
pub memory_gb_hour: Option<SynorDecimal>,
|
||||
pub invocations_million: Option<SynorDecimal>,
|
||||
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));
|
||||
}
|
||||
}
|
||||
300
crates/synor-economics/src/pricing/tiers.rs
Normal file
300
crates/synor-economics/src/pricing/tiers.rs
Normal file
|
|
@ -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<String>,
|
||||
/// 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<String>,
|
||||
display_name: impl Into<String>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// Compare tiers based on usage
|
||||
pub fn compare_tiers(
|
||||
current: &PricingTier,
|
||||
all_tiers: &[PricingTier],
|
||||
monthly_usage: SynorDecimal,
|
||||
) -> Option<TierComparison> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
169
docker-compose.economics.yml
Normal file
169
docker-compose.economics.yml
Normal file
|
|
@ -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:
|
||||
49
docker/economics-service/Dockerfile
Normal file
49
docker/economics-service/Dockerfile
Normal file
|
|
@ -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."]
|
||||
128
docker/economics-service/config.toml
Normal file
128
docker/economics-service/config.toml
Normal file
|
|
@ -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"
|
||||
226
docker/economics-service/init.sql
Normal file
226
docker/economics-service/init.sql
Normal file
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue