From f71a5e77afd47841c7d117728fa8e3baa25b0a7b Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Mon, 20 Jul 2026 14:49:12 -0400 Subject: [PATCH 1/2] feat(costs): add custodial stored-value accounts --- Cargo.lock | 20 + Cargo.toml | 2 + costs/Cargo.toml | 31 + costs/README.md | 35 + costs/src/db.rs | 688 +++++++++++++++ costs/src/genesis.rs | 34 + costs/src/grant.rs | 110 +++ costs/src/ledger.rs | 1719 +++++++++++++++++++++++++++++++++++++ costs/src/lib.rs | 46 + costs/src/outcome.rs | 292 +++++++ costs/src/rpc.rs | 74 ++ costs/src/stored_value.rs | 245 ++++++ costs/src/tests.rs | 1390 ++++++++++++++++++++++++++++++ costs/src/transaction.rs | 565 ++++++++++++ costs/src/types.rs | 1121 ++++++++++++++++++++++++ 15 files changed, 6372 insertions(+) create mode 100644 costs/Cargo.toml create mode 100644 costs/README.md create mode 100644 costs/src/db.rs create mode 100644 costs/src/genesis.rs create mode 100644 costs/src/grant.rs create mode 100644 costs/src/ledger.rs create mode 100644 costs/src/lib.rs create mode 100644 costs/src/outcome.rs create mode 100644 costs/src/rpc.rs create mode 100644 costs/src/stored_value.rs create mode 100644 costs/src/tests.rs create mode 100644 costs/src/transaction.rs create mode 100644 costs/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index d3573cc..265e087 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2817,6 +2817,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "nunchi-costs" +version = "2026.6.0" +dependencies = [ + "async-trait", + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-formatting", + "commonware-macros", + "futures", + "jsonrpsee", + "nunchi-common", + "nunchi-crypto", + "nunchi-rpc", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "nunchi-crypto" version = "2026.6.0" diff --git a/Cargo.toml b/Cargo.toml index 1ceb164..1d55acf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "authority", "bridge", "common", + "costs", "chain", "clob", "coins", @@ -43,6 +44,7 @@ nunchi-bridge = { version = "2026.6.0", path = "bridge" } nunchi-chain = { version = "2026.6.0", path = "chain" } nunchi-clob = { version = "2026.6.0", path = "clob" } nunchi-common = { version = "2026.6.0", path = "common" } +nunchi-costs = { version = "2026.6.0", path = "costs" } nunchi-crypto = { version = "2026.6.0", path = "crypto" } nunchi-dkg = { version = "2026.6.0", path = "dkg" } nunchi-mempool = { version = "2026.6.0", path = "mempool" } diff --git a/costs/Cargo.toml b/costs/Cargo.toml new file mode 100644 index 0000000..3307fef --- /dev/null +++ b/costs/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "nunchi-costs" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Custodial account and usage-credit accounting primitives for Nunchi chains." + +[lints] +workspace = true + +[features] +default = [] +rpc = ["dep:jsonrpsee", "dep:nunchi-rpc"] + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +commonware-codec = { workspace = true } +commonware-cryptography = { workspace = true } +commonware-formatting = { workspace = true } +commonware-macros = { workspace = true } +jsonrpsee = { workspace = true, optional = true } +nunchi-common = { workspace = true } +nunchi-crypto = { workspace = true } +nunchi-rpc = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +futures = { workspace = true } diff --git a/costs/README.md b/costs/README.md new file mode 100644 index 0000000..181ee77 --- /dev/null +++ b/costs/README.md @@ -0,0 +1,35 @@ +# nunchi-costs + +`nunchi-costs` is an account-scoped custodial credit-accounting primitive for +Nunchi chains. It has no dependency on any product UI, billing provider, +event schema, data warehouse, accounting system, or client wallet. + +The module owns only opaque `account_id` account state: + +- active/suspended accounts, available credit, and reserved credit; +- backend writer capabilities (`Admin`, `Ingest`, `Billing`, `Adjustment`); +- idempotent top-ups, grants, reversals, normalized spend, and reservations; +- a registry for untracked/shared sources that has no automatic debit path; +- staged, atomic effective-at rate-card changes with global and exact-account + precedence; and +- finality-outbox envelopes derived after a transaction is finalized. + +All chain writes require an allowlisted backend signer. Client applications and +end users never receive a chain key or submit a raw transaction. + +## Local proof + +```sh +cargo test -p nunchi-costs +``` + +The test suite validates rate activation, payment-rail top-ups, reservation and +settlement, duplicate-event suppression, post-finality journal facts, stored +value provenance, refunds, and untracked-cost zero-debit behavior. + +## Deliberate boundaries + +This module is not a billing-provider integration. External event decoding, +pricing-policy approval, user interfaces, credentials, and production adapters +remain outside the ledger. Applications must preserve the module's idempotency, +authorization, provenance, and finality semantics. diff --git a/costs/src/db.rs b/costs/src/db.rs new file mode 100644 index 0000000..755f7c2 --- /dev/null +++ b/costs/src/db.rs @@ -0,0 +1,688 @@ +use async_trait::async_trait; +use commonware_codec::{Encode, EncodeSize, Read, ReadExt, Write}; +use commonware_cryptography::sha256::Digest; +use nunchi_common::{Address, Namespace, StateStore}; + +use crate::{ + CostsError, LedgerMutationV1, RateCardChangeSet, RateCardEntry, Reservation, CreditAccount, AccountProfile, + StatusHistoryEntry, UntrackedSourceV1, WriterRole, + StoredValueLedger, + COSTS_NAMESPACE, +}; + +const NS: Namespace = Namespace::new(COSTS_NAMESPACE); + +#[repr(u8)] +#[derive(Clone, Copy)] +enum Table { + Nonce = 0, + Account = 1, + Writer = 2, + Event = 3, + Rail = 4, + Reservation = 5, + UntrackedSource = 6, + ActiveRate = 7, + StagedRate = 8, + RateChangeSet = 9, + Profile = 10, + StatusHistory = 11, + StatusHistoryCount = 12, + AccountWriter = 13, + OnboardingRef = 14, + Journal = 15, + JournalCount = 16, + RateHistory = 17, + RateHistoryCount = 18, + ActivationEpoch = 19, + /// Bounded, append-only onboarding order. This is deliberately a list of + /// opaque `account_id`s only; it is used to materialize approved global rate + /// defaults to already-onboarded custodial accounts. + AccountRegistryCount = 20, + AccountRegistry = 21, + GlobalRateRegistryCount = 22, + GlobalRateRegistry = 23, + /// Whether a account/key's active materialization came from a global default + /// (rather than a account-owned override). This keeps subsequent global + /// propagation from treating its own previous materialization as an + /// override. + GlobalRateMaterialization = 24, + /// Per-history-revision provenance. A derived global snapshot must never + /// masquerade as an explicit account override during scoped lookup. + RateHistoryGlobalMaterialization = 25, + StoredValueLedger = 26, +} + +impl From for u8 { + fn from(table: Table) -> Self { + table as Self + } +} + +fn encoded(value: &T) -> Vec { + value.encode().as_ref().to_vec() +} + +fn decoded>(bytes: &[u8]) -> Result { + let mut buf = bytes; + T::read(&mut buf).map_err(|err| CostsError::Storage(err.to_string())) +} + +fn decode_fingerprint(bytes: &[u8]) -> Result { + String::from_utf8(bytes.to_vec()) + .map_err(|_| CostsError::Storage("invalid idempotency fingerprint".to_string())) +} + +fn nonce_key(account: &Address) -> Digest { + NS.key(Table::Nonce, account.encode().as_ref()) +} + +fn account_key(account_id: &str) -> Digest { + NS.key(Table::Account, account_id.as_bytes()) +} + +fn writer_key(role: WriterRole, writer: &Address) -> Digest { + let mut key = Vec::with_capacity(1 + writer.encode_size()); + role.write(&mut key); + writer.write(&mut key); + NS.key(Table::Writer, &key) +} + +fn account_writer_key(role: WriterRole, account_id: &str, writer: &Address) -> Digest { + let mut key = Vec::with_capacity(1 + account_id.len() + writer.encode_size() + 2); + role.write(&mut key); + key.extend_from_slice(account_id.as_bytes()); + key.push(0); + writer.write(&mut key); + NS.key(Table::AccountWriter, &key) +} + +fn external_ref_key(external_ref: &str) -> Digest { + NS.key(Table::OnboardingRef, external_ref.as_bytes()) +} + +fn event_key(event_id: &str) -> Digest { + NS.key(Table::Event, event_id.as_bytes()) +} + +fn profile_key(account_id: &str) -> Digest { + NS.key(Table::Profile, account_id.as_bytes()) +} + +fn status_history_count_key(account_id: &str) -> Digest { + NS.key(Table::StatusHistoryCount, account_id.as_bytes()) +} + +fn status_history_key(account_id: &str, sequence: u64) -> Digest { + let mut material = account_id.as_bytes().to_vec(); + material.extend_from_slice(&sequence.to_be_bytes()); + NS.key(Table::StatusHistory, &material) +} + +fn rail_key(rail_ref: &str) -> Digest { + NS.key(Table::Rail, rail_ref.as_bytes()) +} + +fn reservation_key(reservation_id: &str) -> Digest { + NS.key(Table::Reservation, reservation_id.as_bytes()) +} + +fn untracked_source_key(source_id: &str) -> Digest { + NS.key(Table::UntrackedSource, source_id.as_bytes()) +} + +fn rate_material(account_id: &str, event_category: &str, task_key: &str) -> Vec { + let mut material = Vec::with_capacity(account_id.len() + event_category.len() + task_key.len() + 2); + material.extend_from_slice(account_id.as_bytes()); + material.push(0); + material.extend_from_slice(event_category.as_bytes()); + material.push(0); + material.extend_from_slice(task_key.as_bytes()); + material +} + +fn active_rate_key(account_id: &str, event_category: &str, task_key: &str) -> Digest { + NS.key(Table::ActiveRate, &rate_material(account_id, event_category, task_key)) +} + +fn staged_rate_key(change_set_id: &str, entry: &RateCardEntry) -> Digest { + let mut material = change_set_id.as_bytes().to_vec(); + material.push(0); + material.extend_from_slice(&rate_material(&entry.account_id, &entry.event_category, &entry.task_key)); + NS.key(Table::StagedRate, &material) +} + +fn rate_change_set_key(change_set_id: &str) -> Digest { + NS.key(Table::RateChangeSet, change_set_id.as_bytes()) +} + +fn activation_epoch_key() -> Digest { + NS.key(Table::ActivationEpoch, b"global") +} + +fn account_registry_count_key() -> Digest { + NS.key(Table::AccountRegistryCount, b"all") +} + +fn account_registry_key(sequence: u64) -> Digest { + NS.key(Table::AccountRegistry, &sequence.to_be_bytes()) +} + +fn global_rate_registry_count_key() -> Digest { + NS.key(Table::GlobalRateRegistryCount, b"all") +} + +fn global_rate_registry_key(sequence: u64) -> Digest { + NS.key(Table::GlobalRateRegistry, &sequence.to_be_bytes()) +} + +fn global_rate_materialization_key(account_id: &str, event_category: &str, task_key: &str) -> Digest { + NS.key(Table::GlobalRateMaterialization, &rate_material(account_id, event_category, task_key)) +} + +fn rate_history_count_key(account_id: &str, event_category: &str, task_key: &str) -> Digest { + NS.key(Table::RateHistoryCount, &rate_material(account_id, event_category, task_key)) +} + +fn rate_history_global_materialization_key(account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Digest { + let mut material = rate_material(account_id, event_category, task_key); + material.extend_from_slice(&sequence.to_be_bytes()); + NS.key(Table::RateHistoryGlobalMaterialization, &material) +} + +fn rate_history_key(account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Digest { + let mut material = rate_material(account_id, event_category, task_key); + material.extend_from_slice(&sequence.to_be_bytes()); + NS.key(Table::RateHistory, &material) +} + +fn journal_count_key() -> Digest { + NS.key(Table::JournalCount, b"all") +} + +fn journal_key(sequence: u64) -> Digest { + NS.key(Table::Journal, &sequence.to_be_bytes()) +} + +fn stored_value_ledger_key() -> Digest { NS.key(Table::StoredValueLedger, b"v2") } + +/// Typed state access required by [`crate::CostsLedger`]. +#[async_trait] +pub trait CostsDB { + async fn nonce(&self, account: &Address) -> Result; + fn set_nonce(&mut self, account: &Address, nonce: u64); + + async fn account(&self, account_id: &str) -> Result, CostsError>; + fn set_account(&mut self, account_id: &str, account: CreditAccount); + + async fn profile(&self, account_id: &str) -> Result, CostsError>; + fn set_profile(&mut self, profile: AccountProfile); + async fn status_history_count(&self, account_id: &str) -> Result; + fn set_status_history_count(&mut self, account_id: &str, count: u64); + async fn status_history( + &self, + account_id: &str, + sequence: u64, + ) -> Result, CostsError>; + fn set_status_history(&mut self, account_id: &str, entry: StatusHistoryEntry); + + async fn writer(&self, role: WriterRole, writer: &Address) -> Result; + fn set_writer(&mut self, role: WriterRole, writer: &Address, enabled: bool); + async fn account_writer( + &self, + role: WriterRole, + account_id: &str, + writer: &Address, + ) -> Result; + fn set_account_writer(&mut self, role: WriterRole, account_id: &str, writer: &Address, enabled: bool); + + async fn onboarding_account(&self, external_ref: &str) -> Result, CostsError>; + fn set_onboarding_account(&mut self, external_ref: &str, account_id: &str); + /// Return the number of programmatically-onboarded account accounts. The + /// ledger bounds every scan of this registry before it writes state. + async fn account_registry_count(&self) -> Result; + fn set_account_registry_count(&mut self, count: u64); + async fn account_registry_account(&self, sequence: u64) -> Result, CostsError>; + fn set_account_registry_account(&mut self, sequence: u64, account_id: &str); + + async fn global_rate_registry_count(&self) -> Result; + fn set_global_rate_registry_count(&mut self, count: u64); + async fn global_rate_registry_entry(&self, sequence: u64) -> Result, CostsError>; + fn set_global_rate_registry_entry(&mut self, sequence: u64, entry: RateCardEntry); + async fn global_rate_materialized(&self, account_id: &str, event_category: &str, task_key: &str) -> Result; + fn set_global_rate_materialized(&mut self, account_id: &str, event_category: &str, task_key: &str, materialized: bool); + + async fn event_fingerprint(&self, event_id: &str) -> Result, CostsError>; + fn mark_event(&mut self, event_id: &str, fingerprint: &str); + + async fn rail_fingerprint(&self, rail_ref: &str) -> Result, CostsError>; + fn mark_rail(&mut self, rail_ref: &str, fingerprint: &str); + + async fn reservation(&self, reservation_id: &str) -> Result, CostsError>; + fn set_reservation(&mut self, reservation: Reservation); + + async fn untracked_source(&self, source_id: &str) -> Result, CostsError>; + fn set_untracked_source(&mut self, source: UntrackedSourceV1); + + async fn active_rate( + &self, + account_id: &str, + event_category: &str, + task_key: &str, + ) -> Result, CostsError>; + fn set_active_rate(&mut self, entry: RateCardEntry); + async fn rate_history_count(&self, account_id: &str, event_category: &str, task_key: &str) -> Result; + fn set_rate_history_count(&mut self, account_id: &str, event_category: &str, task_key: &str, count: u64); + async fn rate_history_entry(&self, account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Result, CostsError>; + fn set_rate_history_entry(&mut self, entry: RateCardEntry, sequence: u64); + async fn rate_history_global_materialization(&self, account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Result; + fn set_rate_history_global_materialization(&mut self, account_id: &str, event_category: &str, task_key: &str, sequence: u64, materialized: bool); + + async fn staged_rate( + &self, + change_set_id: &str, + entry: &RateCardEntry, + ) -> Result, CostsError>; + fn set_staged_rate(&mut self, change_set_id: &str, entry: RateCardEntry); + + async fn rate_change_set( + &self, + change_set_id: &str, + ) -> Result, CostsError>; + fn set_rate_change_set(&mut self, change_set: RateCardChangeSet); + /// Global high-watermark for approved rate activations. A change set may + /// never reuse or move this monotonic control-plane epoch backwards. + async fn activation_epoch_high_watermark(&self) -> Result; + fn set_activation_epoch_high_watermark(&mut self, epoch: u64); + + async fn journal_count(&self) -> Result; + fn set_journal_count(&mut self, count: u64); + async fn journal_entry(&self, sequence: u64) -> Result, CostsError>; + fn set_journal_entry(&mut self, entry: LedgerMutationV1); + + async fn stored_value_ledger(&self) -> Result; + fn set_stored_value_ledger(&mut self, ledger: StoredValueLedger); +} + +#[async_trait] +impl CostsDB for S { + async fn nonce(&self, account: &Address) -> Result { + match StateStore::get(self, &nonce_key(account)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_nonce(&mut self, account: &Address, nonce: u64) { + StateStore::set(self, nonce_key(account), encoded(&nonce)); + } + + async fn account(&self, account_id: &str) -> Result, CostsError> { + match StateStore::get(self, &account_key(account_id)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes).map(Some), + None => Ok(None), + } + } + + fn set_account(&mut self, account_id: &str, account: CreditAccount) { + StateStore::set(self, account_key(account_id), encoded(&account)); + } + + async fn profile(&self, account_id: &str) -> Result, CostsError> { + match StateStore::get(self, &profile_key(account_id)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes).map(Some), + None => Ok(None), + } + } + + fn set_profile(&mut self, profile: AccountProfile) { + StateStore::set(self, profile_key(&profile.account_id), encoded(&profile)); + } + + async fn status_history_count(&self, account_id: &str) -> Result { + match StateStore::get(self, &status_history_count_key(account_id)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_status_history_count(&mut self, account_id: &str, count: u64) { + StateStore::set(self, status_history_count_key(account_id), encoded(&count)); + } + + async fn status_history( + &self, + account_id: &str, + sequence: u64, + ) -> Result, CostsError> { + match StateStore::get(self, &status_history_key(account_id, sequence)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes).map(Some), + None => Ok(None), + } + } + + fn set_status_history(&mut self, account_id: &str, entry: StatusHistoryEntry) { + StateStore::set(self, status_history_key(account_id, entry.sequence), encoded(&entry)); + } + + async fn writer(&self, role: WriterRole, writer: &Address) -> Result { + match StateStore::get(self, &writer_key(role, writer)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(false), + } + } + + fn set_writer(&mut self, role: WriterRole, writer: &Address, enabled: bool) { + StateStore::set(self, writer_key(role, writer), encoded(&enabled)); + } + + async fn account_writer( + &self, + role: WriterRole, + account_id: &str, + writer: &Address, + ) -> Result { + match StateStore::get(self, &account_writer_key(role, account_id, writer)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(false), + } + } + + fn set_account_writer(&mut self, role: WriterRole, account_id: &str, writer: &Address, enabled: bool) { + StateStore::set(self, account_writer_key(role, account_id, writer), encoded(&enabled)); + } + + async fn onboarding_account(&self, external_ref: &str) -> Result, CostsError> { + match StateStore::get(self, &external_ref_key(external_ref)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decode_fingerprint(&bytes).map(Some), + None => Ok(None), + } + } + + fn set_onboarding_account(&mut self, external_ref: &str, account_id: &str) { + StateStore::set(self, external_ref_key(external_ref), account_id.as_bytes().to_vec()); + } + + async fn account_registry_count(&self) -> Result { + match StateStore::get(self, &account_registry_count_key()) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_account_registry_count(&mut self, count: u64) { + StateStore::set(self, account_registry_count_key(), encoded(&count)); + } + + async fn account_registry_account(&self, sequence: u64) -> Result, CostsError> { + match StateStore::get(self, &account_registry_key(sequence)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => String::from_utf8(bytes) + .map(Some) + .map_err(|_| CostsError::Storage("invalid account registry entry".to_string())), + None => Ok(None), + } + } + + fn set_account_registry_account(&mut self, sequence: u64, account_id: &str) { + StateStore::set(self, account_registry_key(sequence), account_id.as_bytes().to_vec()); + } + + async fn global_rate_registry_count(&self) -> Result { + match StateStore::get(self, &global_rate_registry_count_key()).await.map_err(|err| CostsError::Storage(err.to_string()))? { + Some(bytes) => decoded(&bytes), None => Ok(0), + } + } + + fn set_global_rate_registry_count(&mut self, count: u64) { + StateStore::set(self, global_rate_registry_count_key(), encoded(&count)); + } + + async fn global_rate_registry_entry(&self, sequence: u64) -> Result, CostsError> { + match StateStore::get(self, &global_rate_registry_key(sequence)).await.map_err(|err| CostsError::Storage(err.to_string()))? { + Some(bytes) => decoded(&bytes).map(Some), None => Ok(None), + } + } + + fn set_global_rate_registry_entry(&mut self, sequence: u64, entry: RateCardEntry) { + StateStore::set(self, global_rate_registry_key(sequence), encoded(&entry)); + } + + async fn global_rate_materialized(&self, account_id: &str, event_category: &str, task_key: &str) -> Result { + match StateStore::get(self, &global_rate_materialization_key(account_id, event_category, task_key)).await.map_err(|err| CostsError::Storage(err.to_string()))? { + Some(bytes) => decoded(&bytes), None => Ok(false), + } + } + + fn set_global_rate_materialized(&mut self, account_id: &str, event_category: &str, task_key: &str, materialized: bool) { + StateStore::set(self, global_rate_materialization_key(account_id, event_category, task_key), encoded(&materialized)); + } + + async fn event_fingerprint(&self, event_id: &str) -> Result, CostsError> { + match StateStore::get(self, &event_key(event_id)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decode_fingerprint(&bytes).map(Some), + None => Ok(None), + } + } + + fn mark_event(&mut self, event_id: &str, fingerprint: &str) { + StateStore::set(self, event_key(event_id), fingerprint.as_bytes().to_vec()); + } + + async fn rail_fingerprint(&self, rail_ref: &str) -> Result, CostsError> { + match StateStore::get(self, &rail_key(rail_ref)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decode_fingerprint(&bytes).map(Some), + None => Ok(None), + } + } + + fn mark_rail(&mut self, rail_ref: &str, fingerprint: &str) { + StateStore::set(self, rail_key(rail_ref), fingerprint.as_bytes().to_vec()); + } + + async fn reservation(&self, reservation_id: &str) -> Result, CostsError> { + match StateStore::get(self, &reservation_key(reservation_id)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes).map(Some), + None => Ok(None), + } + } + + fn set_reservation(&mut self, reservation: Reservation) { + StateStore::set(self, reservation_key(&reservation.reservation_id), encoded(&reservation)); + } + + async fn untracked_source(&self, source_id: &str) -> Result, CostsError> { + match StateStore::get(self, &untracked_source_key(source_id)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes).map(Some), + None => Ok(None), + } + } + + fn set_untracked_source(&mut self, source: UntrackedSourceV1) { + StateStore::set(self, untracked_source_key(&source.source_id), encoded(&source)); + } + + async fn active_rate( + &self, + account_id: &str, + event_category: &str, + task_key: &str, + ) -> Result, CostsError> { + match StateStore::get(self, &active_rate_key(account_id, event_category, task_key)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes).map(Some), + None => Ok(None), + } + } + + fn set_active_rate(&mut self, entry: RateCardEntry) { + StateStore::set( + self, + active_rate_key(&entry.account_id, &entry.event_category, &entry.task_key), + encoded(&entry), + ); + } + + async fn rate_history_count(&self, account_id: &str, event_category: &str, task_key: &str) -> Result { + match StateStore::get(self, &rate_history_count_key(account_id, event_category, task_key)).await.map_err(|err| CostsError::Storage(err.to_string()))? { + Some(bytes) => decoded(&bytes), None => Ok(0), + } + } + + fn set_rate_history_count(&mut self, account_id: &str, event_category: &str, task_key: &str, count: u64) { + StateStore::set(self, rate_history_count_key(account_id, event_category, task_key), encoded(&count)); + } + + async fn rate_history_entry(&self, account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Result, CostsError> { + match StateStore::get(self, &rate_history_key(account_id, event_category, task_key, sequence)).await.map_err(|err| CostsError::Storage(err.to_string()))? { + Some(bytes) => decoded(&bytes).map(Some), None => Ok(None), + } + } + + fn set_rate_history_entry(&mut self, entry: RateCardEntry, sequence: u64) { + StateStore::set(self, rate_history_key(&entry.account_id, &entry.event_category, &entry.task_key, sequence), encoded(&entry)); + } + + async fn rate_history_global_materialization(&self, account_id: &str, event_category: &str, task_key: &str, sequence: u64) -> Result { + match StateStore::get(self, &rate_history_global_materialization_key(account_id, event_category, task_key, sequence)).await.map_err(|err| CostsError::Storage(err.to_string()))? { + Some(bytes) => decoded(&bytes), None => Ok(false), + } + } + + fn set_rate_history_global_materialization(&mut self, account_id: &str, event_category: &str, task_key: &str, sequence: u64, materialized: bool) { + StateStore::set(self, rate_history_global_materialization_key(account_id, event_category, task_key, sequence), encoded(&materialized)); + } + + async fn staged_rate( + &self, + change_set_id: &str, + entry: &RateCardEntry, + ) -> Result, CostsError> { + match StateStore::get(self, &staged_rate_key(change_set_id, entry)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes).map(Some), + None => Ok(None), + } + } + + fn set_staged_rate(&mut self, change_set_id: &str, entry: RateCardEntry) { + StateStore::set(self, staged_rate_key(change_set_id, &entry), encoded(&entry)); + } + + async fn rate_change_set( + &self, + change_set_id: &str, + ) -> Result, CostsError> { + match StateStore::get(self, &rate_change_set_key(change_set_id)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes).map(Some), + None => Ok(None), + } + } + + fn set_rate_change_set(&mut self, change_set: RateCardChangeSet) { + StateStore::set( + self, + rate_change_set_key(&change_set.change_set_id), + encoded(&change_set), + ); + } + + async fn activation_epoch_high_watermark(&self) -> Result { + match StateStore::get(self, &activation_epoch_key()).await.map_err(|err| CostsError::Storage(err.to_string()))? { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_activation_epoch_high_watermark(&mut self, epoch: u64) { + StateStore::set(self, activation_epoch_key(), encoded(&epoch)); + } + + async fn journal_count(&self) -> Result { + match StateStore::get(self, &journal_count_key()) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes), + None => Ok(0), + } + } + + fn set_journal_count(&mut self, count: u64) { + StateStore::set(self, journal_count_key(), encoded(&count)); + } + + async fn journal_entry(&self, sequence: u64) -> Result, CostsError> { + match StateStore::get(self, &journal_key(sequence)) + .await + .map_err(|err| CostsError::Storage(err.to_string()))? + { + Some(bytes) => decoded(&bytes).map(Some), + None => Ok(None), + } + } + + fn set_journal_entry(&mut self, entry: LedgerMutationV1) { + StateStore::set(self, journal_key(entry.sequence), encoded(&entry)); + } + + async fn stored_value_ledger(&self) -> Result { + match StateStore::get(self, &stored_value_ledger_key()).await.map_err(|err| CostsError::Storage(err.to_string()))? { + Some(bytes) => serde_json::from_slice(&bytes).map_err(|err| CostsError::Storage(err.to_string())), + None => Ok(StoredValueLedger::default()), + } + } + + fn set_stored_value_ledger(&mut self, ledger: StoredValueLedger) { + let bytes = serde_json::to_vec(&ledger).expect("stored value ledger serializes"); + StateStore::set(self, stored_value_ledger_key(), bytes); + } +} diff --git a/costs/src/genesis.rs b/costs/src/genesis.rs new file mode 100644 index 0000000..b4850bd --- /dev/null +++ b/costs/src/genesis.rs @@ -0,0 +1,34 @@ +use commonware_codec::DecodeExt; +use commonware_formatting::from_hex; +use nunchi_common::Address; +use serde::{Deserialize, Serialize}; + +use crate::{CostsDB, CostsError, CostsLedger, WriterRole}; + +/// JSON-facing bootstrap configuration. Only backend administrator keys enter +/// genesis; client accounts are registered through signed administrative commands. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct CostsGenesis { + #[serde(default)] + pub administrators: Vec, +} + +impl CostsLedger { + /// Seed the initial administrator allowlist from chain genesis. + pub async fn apply_genesis(&mut self, genesis: &CostsGenesis) -> Result<(), CostsError> { + for administrator in &genesis.administrators { + let address = decode_hex::
(administrator, "administrator")?; + self.db.set_writer(WriterRole::Admin, &address, true); + } + Ok(()) + } +} + +fn decode_hex(value: &str, what: &'static str) -> Result +where + T: DecodeExt<()>, +{ + let bytes = from_hex(value).ok_or_else(|| CostsError::Storage(format!("invalid {what}")))?; + T::decode(bytes.as_ref()) + .map_err(|err| CostsError::Storage(format!("invalid {what}: {err}"))) +} diff --git a/costs/src/grant.rs b/costs/src/grant.rs new file mode 100644 index 0000000..7986875 --- /dev/null +++ b/costs/src/grant.rs @@ -0,0 +1,110 @@ +//! Credit ingress taxonomy — shared `source_reason` constants and idempotency helpers. +//! +//! See ADR-0004: grants use `CreditAdjustmentV1` with `metadata.reason_code`; +//! paid top-ups use `CreditTopup` with `reason_code = topup` in the journal. + +use crate::{AdjustmentKind, AdjustmentMetadata, CostsOperation}; + +/// Application-defined included, trial, promotional, or goodwill credits. +pub const REASON_INCLUDED_CREDITS: &str = "included_credits"; +pub const REASON_TRIAL: &str = "trial"; +pub const REASON_PROMOTION: &str = "promotion"; +pub const REASON_GOODWILL: &str = "goodwill"; + +/// Paid rail journal marker (op is `CreditTopup`, not adjustment). +pub const REASON_TOPUP: &str = "topup"; + +/// All known grant reason codes for validation and downstream warehouse partitioning. +pub const ALL_GRANT_REASONS: &[&str] = &[ + REASON_INCLUDED_CREDITS, + REASON_TRIAL, + REASON_PROMOTION, + REASON_GOODWILL, +]; + +/// Returns true if `reason_code` is a recognized grant taxonomy value. +pub fn is_known_grant_reason(reason_code: &str) -> bool { + ALL_GRANT_REASONS.contains(&reason_code) +} + +/// Returns true if accounting export should exclude this grant from revenue recognition. +pub fn is_non_revenue_grant(reason_code: &str) -> bool { + matches!(reason_code, REASON_TRIAL | REASON_PROMOTION | REASON_GOODWILL) +} + +/// Idempotency key for an application's periodic grant. +pub fn periodic_grant_ref(account_id: &str, period: &str) -> String { + format!("grant_{account_id}_{period}") +} + +/// Idempotency key for a named campaign grant. +pub fn campaign_grant_ref(campaign_ref: &str, account_id: &str) -> String { + format!("grant_{campaign_ref}_{account_id}") +} + +/// Build metadata for a periodic included-credit grant. +pub fn periodic_grant_metadata( + account_id: &str, + period: &str, + reason_code: &str, + approval_ref: &str, +) -> AdjustmentMetadata { + AdjustmentMetadata { + reference: periodic_grant_ref(account_id, period), + reason_code: reason_code.to_string(), + period_ref: period.to_string(), + approval_ref: approval_ref.to_string(), + audit_ref: format!("periodic_grant:{period}"), + } +} + +/// Build grant metadata for a promotional credit. +pub fn campaign_grant_metadata( + campaign_ref: &str, + account_id: &str, + reason_code: &str, + approval_ref: &str, + audit_ref: &str, +) -> AdjustmentMetadata { + AdjustmentMetadata { + reference: campaign_grant_ref(campaign_ref, account_id), + reason_code: reason_code.to_string(), + period_ref: format!("campaign:{campaign_ref}"), + approval_ref: approval_ref.to_string(), + audit_ref: audit_ref.to_string(), + } +} + +/// Construct a signed-ready grant operation. +pub fn credit_grant_op( + account_id: String, + credits: u64, + metadata: AdjustmentMetadata, +) -> CostsOperation { + CostsOperation::CreditAdjustmentV1 { + kind: AdjustmentKind::Grant, + account_id, + credits, + metadata, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn periodic_grant_ref_is_stable() { + assert_eq!( + periodic_grant_ref("bravo", "2026-07"), + "grant_bravo_2026-07" + ); + } + + #[test] + fn non_revenue_classification_is_explicit() { + assert!(is_non_revenue_grant(REASON_TRIAL)); + assert!(is_non_revenue_grant(REASON_PROMOTION)); + assert!(!is_non_revenue_grant(REASON_INCLUDED_CREDITS)); + } +} diff --git a/costs/src/ledger.rs b/costs/src/ledger.rs new file mode 100644 index 0000000..ec5812d --- /dev/null +++ b/costs/src/ledger.rs @@ -0,0 +1,1719 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use commonware_codec::Write; +use commonware_cryptography::{Hasher, Sha256}; +use nunchi_common::Address; +use nunchi_crypto::SignatureError; +use thiserror::Error; + +use crate::{ + AccountReadV1, AdjustmentKind, AdjustmentMetadata, BalanceMutationDirection, CostsDB, CostsOperation, + LedgerMutationKind, LedgerMutationV1, QuoteRequestV1, QuoteV1, RateCardChangeSetV1, + RateCardCompletionV1, StoredValueAccountReadV1, StoredValueFinalityEventV1, + StoredValueFinalityPayloadV1, StoredValueLotReadV1, + Reservation, ReservationStatus, CreditAccount, AccountProfile, AccountStatus, StatusHistoryEntry, + StatusChangeMetadataV1, Transaction, UntrackedSourceV1, WriterRole, +}; + +/// Maximum normalized records accepted in one metering transaction. +pub const MAX_SPEND_RECORDS_PER_BATCH: usize = 150; +/// Maximum rate targets carried by one staged or activation command. +pub const MAX_RATE_ENTRIES_PER_COMMAND: usize = 64; +/// Bound control-plane fan-out and onboarding inheritance work in one +/// deterministic state transition. A production migration can raise this only +/// through an explicit storage and gas review. +pub const MAX_REGISTERED_SITES: u64 = 4_096; +pub const MAX_GLOBAL_RATE_REVISIONS: u64 = 4_096; + +/// Deterministic failures exposed by the costs state machine. +#[derive(Clone, Debug, Error, PartialEq)] +pub enum CostsError { + #[error("bad costs transaction signature: {0}")] + BadSignature(#[from] SignatureError), + #[error("nonce mismatch for {account:?}: expected {expected}, got {actual}")] + NonceMismatch { + account: Box
, + expected: u64, + actual: u64, + }, + #[error("nonce overflow")] + NonceOverflow, + #[error("unauthorized {role:?} writer {writer:?}")] + Unauthorized { role: WriterRole, writer: Box
}, + #[error("invalid {field}")] + InvalidField { field: &'static str }, + #[error("legacy operation {operation} is no longer accepted; use the V1 command")] + LegacyOperationRejected { operation: &'static str }, + #[error("account account {account_id} already exists")] + AccountAlreadyExists { account_id: String }, + #[error("account account {account_id} was not found")] + AccountNotFound { account_id: String }, + #[error("account account {account_id} is suspended")] + AccountSuspended { account_id: String }, + #[error("insufficient credits for {account_id}: available {available}, required {required}")] + InsufficientCredits { + account_id: String, + available: u64, + required: u64, + }, + #[error("spend batch contains {actual} records; maximum is {maximum}")] + BatchTooLarge { actual: usize, maximum: usize }, + #[error("credit balance overflow")] + CreditOverflow, + #[error("reserved credit balance underflow")] + ReservedCreditUnderflow, + #[error("reservation {reservation_id} already exists with different terms")] + ReservationConflict { reservation_id: String }, + #[error("reservation {reservation_id} was not found")] + ReservationNotFound { reservation_id: String }, + #[error("reservation {reservation_id} is not active")] + ReservationNotActive { reservation_id: String }, + #[error("rate change set {change_set_id} was not found")] + RateChangeSetNotFound { change_set_id: String }, + #[error("rate change set {change_set_id} conflicts with its approved manifest")] + RateChangeSetConflict { change_set_id: String }, + #[error("rate change set {change_set_id} is incomplete: staged {staged}, expected {expected}")] + RateChangeSetIncomplete { + change_set_id: String, + staged: u16, + expected: u16, + }, + #[error("rate command contains {actual} entries; maximum is {maximum}")] + RateCommandTooLarge { actual: usize, maximum: usize }, + #[error("registered account capacity {maximum} has been reached")] + AccountRegistryFull { maximum: u64 }, + #[error("global rate revision capacity {maximum} has been reached")] + GlobalRateRegistryFull { maximum: u64 }, + #[error("idempotency reference {reference} was replayed with different contents")] + IdempotencyConflict { reference: String }, + #[error("spend {event_id} does not match its pinned rate snapshot")] + PinnedRateMismatch { event_id: String }, + #[error("reservation {reservation_id} cannot settle while its account is suspended")] + ReservationAccountSuspended { reservation_id: String }, + #[error("reservation {reservation_id} expired at {expires_at}")] + ReservationExpired { reservation_id: String, expires_at: u64 }, + #[error("state storage error: {0}")] + Storage(String), +} + +/// Generic custodial ledger for client account accounts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CostsLedger { + pub(crate) db: D, +} + +/// The narrow set of state changes that are entitled to a persisted finality +/// mutation. Idempotency is a state-machine property: a successful replay +/// advances its signer nonce but must not manufacture a second ledger event. +#[derive(Clone, Debug)] +enum AppliedMutation { + None, + Onboarded, + OnboardedWithRates(Vec), + BalanceChanged, + Spend(Vec), + StatusChanged, + ReservationChanged, + UntrackedSourceRegistered, + RatesStaged(Vec), + RatesApplied(Vec), +} + +impl CostsLedger { + /// Wrap a database backend as a costs ledger. + pub fn new(db: D) -> Self { + Self { db } + } + + /// Borrow the underlying database. + pub fn db(&self) -> &D { + &self.db + } + + /// Consume the ledger, returning the underlying database. + pub fn into_inner(self) -> D { + self.db + } + + /// Read one opaque account account. + pub async fn account(&self, account_id: &str) -> Result, CostsError> { + self.db.account(account_id).await + } + + /// Read a long-running action's hold state. + pub async fn reservation( + &self, + reservation_id: &str, + ) -> Result, CostsError> { + self.db.reservation(reservation_id).await + } + + /// Read non-financial onboarding/status metadata for a account. This remains + /// a private backend/BFF surface; it is not a public chain RPC. + pub async fn profile(&self, account_id: &str) -> Result, CostsError> { + self.db.profile(account_id).await + } + + /// Private BFF-facing account read. Callers must enforce their own + /// account-scoped authorization before invoking this chain read. + pub async fn account_read(&self, account_id: &str) -> Result, CostsError> { + let Some(account) = self.db.account(account_id).await? else { + return Ok(None); + }; + let profile = self.db.profile(account_id).await?.ok_or_else(|| CostsError::Storage( + "account is missing its immutable profile".to_string(), + ))?; + Ok(Some(AccountReadV1 { account, profile })) + } + + /// Private BFF-facing, provenance-preserving stored-value read. The V2 + /// economic ledger is intentionally separate from aggregate metering + /// balances while the clean-state migration remains unapproved. + pub async fn stored_value_account_read( + &self, + account_id: &str, + now: u64, + period_ref: &str, + reset_at: u64, + ) -> Result, CostsError> { + if self.db.account(account_id).await?.is_none() { + return Ok(None); + } + self.db + .stored_value_ledger() + .await? + .account_read(account_id, now, period_ref, reset_at) + .map(Some) + .map_err(stored_value_error) + } + + /// Private sink/BFF lot history. Do not expose this from a public client + /// RPC: caller-side `account_id` authorization remains mandatory. + pub async fn stored_value_lots( + &self, + account_id: &str, + ) -> Result>, CostsError> { + if self.db.account(account_id).await?.is_none() { + return Ok(None); + } + self.db + .stored_value_ledger() + .await? + .lots_for_account(account_id) + .map(Some) + .map_err(stored_value_error) + } + + /// Read immutable V2 projection inputs. A finality adapter must consume + /// these only for finalized chain transactions, then dedupe using the + /// stored event identity before projecting to downstream warehouse or a client-safe BFF. + pub async fn stored_value_finality_events( + &self, + from_sequence: u64, + limit: u16, + ) -> Result, CostsError> { + Ok(self.db.stored_value_ledger().await?.finality_events(from_sequence, limit)) + } + + /// Convenience one-unit quote with a complete ID and snapshot hash. New + /// callers should prefer `quote_request` to supply an explicit expiry. + pub async fn quote( + &self, + account_id: &str, + event_category: &str, + task_key: &str, + quoted_at: u64, + ) -> Result, CostsError> { + let Some(rate) = self.active_rate(account_id, event_category, task_key, quoted_at).await? else { return Ok(None); }; + let expires_at = if rate.expires_at == 0 { quoted_at.checked_add(1).ok_or(CostsError::CreditOverflow)? } else { rate.expires_at }; + self.quote_request(QuoteRequestV1 { account_id: account_id.to_string(), event_category: event_category.to_string(), task_key: task_key.to_string(), quantity: 1, quoted_at, expires_at }).await + } + + /// Produce a fixed-price, bounded-lifetime quote snapshot for reservation. + pub async fn quote_request(&self, request: QuoteRequestV1) -> Result, CostsError> { + if request.quantity == 0 || request.expires_at <= request.quoted_at { return Err(CostsError::InvalidField { field: "quote_expiry_or_quantity" }); } + let Some(rate) = self.active_rate(&request.account_id, &request.event_category, &request.task_key, request.quoted_at).await? else { return Ok(None); }; + if rate.expires_at != 0 && request.expires_at > rate.expires_at { return Err(CostsError::InvalidField { field: "quote_expires_at" }); } + let total_credits = rate.credits.checked_mul(request.quantity).ok_or(CostsError::CreditOverflow)?; + let mut quote = QuoteV1 { quote_id: String::new(), snapshot_hash: String::new(), account_id: request.account_id, event_category: request.event_category, task_key: request.task_key, quoted_at: request.quoted_at, credits_per_unit: rate.credits, quantity: request.quantity, total_credits, policy_version: rate.policy_version, rate_version: rate.rate_version, expires_at: request.expires_at }; + quote.snapshot_hash = quote_snapshot_hash("e); + quote.quote_id = format!("quote:{}", "e.snapshot_hash[..32]); + Ok(Some(quote)) + } + + /// Read the append-only status history, oldest first. + pub async fn status_history( + &self, + account_id: &str, + ) -> Result, CostsError> { + let count = self.db.status_history_count(account_id).await?; + let mut result = Vec::with_capacity(count as usize); + for sequence in 0..count { + if let Some(entry) = self.db.status_history(account_id, sequence).await? { + result.push(entry); + } + } + Ok(result) + } + + /// Read append-only post-state mutation records, oldest first. This is a + /// private chain-read seam for the finality sink and reconciliation, never + /// a public client endpoint. + pub async fn journal(&self, from_sequence: u64, limit: u16) -> Result, CostsError> { + let count = self.db.journal_count().await?; + let end = from_sequence.saturating_add(u64::from(limit)).min(count); + let mut entries = Vec::with_capacity((end - from_sequence) as usize); + for sequence in from_sequence..end { + if let Some(entry) = self.db.journal_entry(sequence).await? { + entries.push(entry); + } + } + Ok(entries) + } + + /// Resolve the active scoped rate at `price_at` using account/task, account/category, + /// global/task, then global/category precedence. + pub async fn active_rate( + &self, + account_id: &str, + event_category: &str, + task_key: &str, + price_at: u64, + ) -> Result, CostsError> { + for (candidate_account, candidate_task) in [ + (account_id, task_key), + (account_id, ""), + ("", task_key), + ("", ""), + ] { + let count = self.db.rate_history_count(candidate_account, event_category, candidate_task).await?; + let mut selected: Option = None; + for sequence in 0..count { + if let Some(entry) = self.db.rate_history_entry(candidate_account, event_category, candidate_task, sequence).await? { + // Global defaults may be materialized for read-model and + // reconciliation purposes, but their stored account-shaped + // copy is not an explicit account rule. Skip it here so the + // declared scope order remains account/task > account/category + // > global/task > global/category. + if !candidate_account.is_empty() + && self.db.rate_history_global_materialization(candidate_account, event_category, candidate_task, sequence).await? { + continue; + } + let eligible = entry.effective_at <= price_at && (entry.expires_at == 0 || price_at < entry.expires_at); + let replaces = selected + .as_ref() + .is_none_or(|current| entry.effective_at >= current.effective_at); + if eligible && replaces { + // Equal effective timestamps are a deliberate revision; the + // last activated entry wins while the earlier entry stays + // queryable in history for reconciliation. + selected = Some(entry); + } + } + } + if selected.is_some() { + return Ok(selected); + } + } + Ok(None) + } + + /// Apply one signed costs transaction. + pub async fn apply_transaction(&mut self, tx: &Transaction) -> Result<(), CostsError> { + self.apply_transaction_with_outcomes(tx).await.map(|_| ()) + } + + /// Apply one transaction and return the ledger-generated, persisted + /// post-state outcomes. Adapters may publish them only after chain finality. + pub async fn apply_transaction_with_outcomes( + &mut self, + tx: &Transaction, + ) -> Result, CostsError> { + tx.verify()?; + + let expected = self.db.nonce(&tx.account_id).await?; + if tx.payload.nonce != expected { + return Err(CostsError::NonceMismatch { + account: Box::new(tx.account_id.clone()), + expected, + actual: tx.payload.nonce, + }); + } + + let stored_value_transaction_id = format!("{:?}", tx.digest()); + let applied = match &tx.payload.operation { + CostsOperation::RegisterAccount { .. } => return Err(CostsError::LegacyOperationRejected { operation: "RegisterAccount" }), + CostsOperation::CreateAccount { account_id, external_ref, policy_ref, cohort_ref, created_at } => { + validate_identifier(account_id, "account_id")?; + validate_identifier(external_ref, "external_ref")?; + validate_identifier(policy_ref, "policy_ref")?; + if !cohort_ref.is_empty() { validate_identifier(cohort_ref, "cohort_ref")?; } + self.require_writer(WriterRole::Admin, &tx.account_id).await?; + if let Some(existing_account) = self.db.onboarding_account(external_ref).await? { + if existing_account != *account_id { + return Err(CostsError::IdempotencyConflict { reference: external_ref.clone() }); + } + } + let requested = AccountProfile { + account_id: account_id.clone(), external_ref: external_ref.clone(), + policy_ref: policy_ref.clone(), cohort_ref: cohort_ref.clone(), created_at: *created_at, + status_reason: "onboarded".to_string(), status_changed_at: *created_at, + }; + match (self.db.account(account_id).await?, self.db.profile(account_id).await?) { + (None, None) => { + self.preflight_account_registry_append(account_id).await?; + self.db.set_account(account_id, CreditAccount::active()); + let mut stored_value = self.db.stored_value_ledger().await?; + stored_value.onboard(account_id).map_err(stored_value_error)?; + self.db.set_stored_value_ledger(stored_value); + self.db.set_profile(requested); + self.db.set_onboarding_account(external_ref, account_id); + self.append_account_to_registry(account_id).await?; + self.append_status_history(account_id, AccountStatus::Active, "onboarded", *created_at, "onboarded", "onboarded").await?; + let inherited = self.materialize_active_global_rates(account_id, *created_at).await?; + if inherited.is_empty() { AppliedMutation::Onboarded } else { AppliedMutation::OnboardedWithRates(inherited) } + } + (Some(_), Some(existing)) if existing == requested => AppliedMutation::None, + _ => return Err(CostsError::IdempotencyConflict { reference: external_ref.clone() }), + } + } + CostsOperation::SetWriter { .. } => return Err(CostsError::LegacyOperationRejected { operation: "SetWriter" }), + CostsOperation::SetAccountWriter { account_id, role, writer, enabled } => { + validate_identifier(account_id, "account_id")?; + self.require_writer(WriterRole::Admin, &tx.account_id).await?; + self.require_account(account_id).await?; + self.db.set_account_writer(*role, account_id, writer, *enabled); + AppliedMutation::None + } + CostsOperation::RotateAdmin { replacement } => { + self.require_writer(WriterRole::Admin, &tx.account_id).await?; + if replacement == &tx.account_id { + return Err(CostsError::InvalidField { field: "replacement" }); + } + self.db.set_writer(WriterRole::Admin, replacement, true); + self.db.set_writer(WriterRole::Admin, &tx.account_id, false); + AppliedMutation::None + } + CostsOperation::CreditTopup { + account_id, + credits, + rail_ref, + } => { + validate_identifier(account_id, "account_id")?; + validate_identifier(rail_ref, "rail_ref")?; + if *credits == 0 { + return Err(CostsError::InvalidField { field: "credits" }); + } + self.require_writer_for_account(WriterRole::Billing, account_id, &tx.account_id).await?; + let fingerprint = topup_fingerprint(account_id, *credits); + if let Some(existing) = self.db.rail_fingerprint(rail_ref).await? { + if existing == fingerprint { + self.advance_nonce(&tx.account_id, expected)?; + return Ok(Vec::new()); + } + return Err(CostsError::IdempotencyConflict { + reference: rail_ref.clone(), + }); + } + let mut account = self.require_account(account_id).await?; + account.available_credits = account + .available_credits + .checked_add(*credits) + .ok_or(CostsError::CreditOverflow)?; + self.db.set_account(account_id, account); + self.db.mark_rail(rail_ref, &fingerprint); + AppliedMutation::BalanceChanged + } + CostsOperation::StoredValueTopupV2 { topup } => { + self.require_writer_for_account(WriterRole::Billing, &topup.account_id, &tx.account_id).await?; + self.require_account(&topup.account_id).await?; + let mut stored_value = self.db.stored_value_ledger().await?; + stored_value.credit_topup(topup.clone()).map_err(stored_value_error)?; + stored_value.append_finality_event( + format!("topup:{}", topup.rail_ref), stored_value_transaction_id.clone(), topup.account_id.clone(), + StoredValueFinalityPayloadV1::Topup(topup.clone()), + ).map_err(stored_value_error)?; + self.db.set_stored_value_ledger(stored_value); + AppliedMutation::None + } + CostsOperation::StoredValueGrantV2 { grant } => { + self.require_writer_for_account(WriterRole::Adjustment, &grant.account_id, &tx.account_id).await?; + self.require_account(&grant.account_id).await?; + let mut stored_value = self.db.stored_value_ledger().await?; + stored_value.credit_grant(grant.clone()).map_err(stored_value_error)?; + stored_value.append_finality_event( + format!("grant:{}", grant.reference), stored_value_transaction_id.clone(), grant.account_id.clone(), + StoredValueFinalityPayloadV1::Grant(grant.clone()), + ).map_err(stored_value_error)?; + self.db.set_stored_value_ledger(stored_value); + AppliedMutation::None + } + CostsOperation::StoredValueSpendV2 { spend } => { + self.require_writer_for_account(WriterRole::Ingest, &spend.account_id, &tx.account_id).await?; + let account = self.require_account(&spend.account_id).await?; + if account.status == AccountStatus::Suspended { + return Err(CostsError::AccountSuspended { account_id: spend.account_id.clone() }); + } + let mut stored_value = self.db.stored_value_ledger().await?; + let allocations = stored_value.record_spend(spend.clone()).map_err(stored_value_error)?; + stored_value.append_finality_event( + format!("spend:{}", spend.event_id), stored_value_transaction_id.clone(), spend.account_id.clone(), + StoredValueFinalityPayloadV1::Spend { spend: spend.clone(), allocations }, + ).map_err(stored_value_error)?; + self.db.set_stored_value_ledger(stored_value); + AppliedMutation::None + } + CostsOperation::RefundPaidLotV1 { refund } => { + self.require_writer_for_account(WriterRole::Adjustment, &refund.account_id, &tx.account_id).await?; + self.require_account(&refund.account_id).await?; + let mut stored_value = self.db.stored_value_ledger().await?; + stored_value.refund_paid_lot(refund.clone()).map_err(stored_value_error)?; + stored_value.append_finality_event( + format!("refund:{}", refund.refund_rail_ref), stored_value_transaction_id.clone(), refund.account_id.clone(), + StoredValueFinalityPayloadV1::Refund(refund.clone()), + ).map_err(stored_value_error)?; + self.db.set_stored_value_ledger(stored_value); + AppliedMutation::None + } + CostsOperation::ReserveStoredValueV2 { reservation_id, account_id, credits, expires_at, reserved_at } => { + self.require_writer_for_account(WriterRole::Ingest, account_id, &tx.account_id).await?; + let account = self.require_account(account_id).await?; + if account.status == AccountStatus::Suspended { + return Err(CostsError::AccountSuspended { account_id: account_id.clone() }); + } + let mut stored_value = self.db.stored_value_ledger().await?; + stored_value.reserve(reservation_id, account_id, *credits, *expires_at, *reserved_at).map_err(stored_value_error)?; + self.db.set_stored_value_ledger(stored_value); + AppliedMutation::None + } + CostsOperation::ReleaseStoredValueReservationV2 { reservation_id, released_at } => { + let mut stored_value = self.db.stored_value_ledger().await?; + let account_id = stored_value.reservation(reservation_id) + .ok_or_else(|| stored_value_error(crate::StoredValueError::ReservationNotFound(reservation_id.clone())))? + .account_id.clone(); + self.require_writer_for_account(WriterRole::Ingest, &account_id, &tx.account_id).await?; + stored_value.release_reservation(reservation_id, *released_at).map_err(stored_value_error)?; + self.db.set_stored_value_ledger(stored_value); + AppliedMutation::None + } + CostsOperation::ExpireStoredValueReservationV2 { reservation_id, expired_at } => { + let mut stored_value = self.db.stored_value_ledger().await?; + let account_id = stored_value.reservation(reservation_id) + .ok_or_else(|| stored_value_error(crate::StoredValueError::ReservationNotFound(reservation_id.clone())))? + .account_id.clone(); + self.require_writer_for_account(WriterRole::Ingest, &account_id, &tx.account_id).await?; + stored_value.expire_reservation(reservation_id, *expired_at).map_err(stored_value_error)?; + self.db.set_stored_value_ledger(stored_value); + AppliedMutation::None + } + CostsOperation::SettleStoredValueReservationV2 { reservation_id, spend } => { + let mut stored_value = self.db.stored_value_ledger().await?; + let account_id = stored_value.reservation(reservation_id) + .ok_or_else(|| stored_value_error(crate::StoredValueError::ReservationNotFound(reservation_id.clone())))? + .account_id.clone(); + if account_id != spend.account_id { + return Err(CostsError::InvalidField { field: "stored_value_reservation_account" }); + } + self.require_writer_for_account(WriterRole::Ingest, &account_id, &tx.account_id).await?; + let account = self.require_account(&account_id).await?; + if account.status == AccountStatus::Suspended { + return Err(CostsError::AccountSuspended { account_id }); + } + let allocations = stored_value.settle_reservation(reservation_id, spend.clone()).map_err(stored_value_error)?; + stored_value.append_finality_event( + format!("spend:{}", spend.event_id), stored_value_transaction_id.clone(), spend.account_id.clone(), + StoredValueFinalityPayloadV1::Spend { spend: spend.clone(), allocations }, + ).map_err(stored_value_error)?; + self.db.set_stored_value_ledger(stored_value); + AppliedMutation::None + } + CostsOperation::RecordSpendBatch { records } => { + AppliedMutation::Spend(self.apply_spend_batch(records, &tx.account_id).await?) + } + CostsOperation::SetAccountStatus { .. } => return Err(CostsError::LegacyOperationRejected { operation: "SetAccountStatus" }), + CostsOperation::SetAccountStatusV1 { account_id, status, metadata } => { + self.require_writer(WriterRole::Admin, &tx.account_id).await?; + self.set_account_status_v1(account_id, *status, metadata).await? + } + CostsOperation::CreditGrant { .. } => return Err(CostsError::LegacyOperationRejected { operation: "CreditGrant" }), + CostsOperation::CreditReversal { .. } => return Err(CostsError::LegacyOperationRejected { operation: "CreditReversal" }), + CostsOperation::ReserveCredits { .. } => return Err(CostsError::LegacyOperationRejected { operation: "ReserveCredits" }), + CostsOperation::ReserveCreditsV1 { reservation_id, quote, lineage_ref, reserved_at } => { + self.require_writer_for_account(WriterRole::Ingest, "e.account_id, &tx.account_id).await?; + if self.reserve_v1(reservation_id, quote, lineage_ref, *reserved_at).await? { AppliedMutation::ReservationChanged } else { AppliedMutation::None } + } + CostsOperation::ReleaseReservation { .. } => return Err(CostsError::LegacyOperationRejected { operation: "ReleaseReservation" }), + CostsOperation::ExpireReservation { .. } => return Err(CostsError::LegacyOperationRejected { operation: "ExpireReservation" }), + CostsOperation::ReleaseReservationV1 { reservation_id, metadata } => { + let reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| CostsError::ReservationNotFound { reservation_id: reservation_id.clone() })?; + self.require_writer_for_account(WriterRole::Ingest, &reservation.account_id, &tx.account_id).await?; + validate_reservation_metadata(metadata)?; + if self.release_reservation(reservation_id).await? { AppliedMutation::ReservationChanged } else { AppliedMutation::None } + } + CostsOperation::ExpireReservationV1 { reservation_id, metadata } => { + let reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| CostsError::ReservationNotFound { reservation_id: reservation_id.clone() })?; + self.require_writer_for_account(WriterRole::Ingest, &reservation.account_id, &tx.account_id).await?; + validate_reservation_metadata(metadata)?; + if self.expire_reservation(reservation_id, metadata.occurred_at).await? { AppliedMutation::ReservationChanged } else { AppliedMutation::None } + } + CostsOperation::SettleSpend { .. } => return Err(CostsError::LegacyOperationRejected { operation: "SettleSpend" }), + CostsOperation::SettleSpendV1 { reservation_id, event_id, event_category, task_key, quote_id, snapshot_hash, lineage_ref, metadata } => { + let reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| CostsError::ReservationNotFound { reservation_id: reservation_id.clone() })?; + self.require_writer_for_account(WriterRole::Ingest, &reservation.account_id, &tx.account_id).await?; + validate_reservation_metadata(metadata)?; + if self.settle_reservation_v1(reservation_id, event_id, event_category, task_key, quote_id, snapshot_hash, lineage_ref, metadata.occurred_at).await? { AppliedMutation::ReservationChanged } else { AppliedMutation::None } + } + CostsOperation::RegisterUntrackedSource { .. } => return Err(CostsError::LegacyOperationRejected { operation: "RegisterUntrackedSource" }), + CostsOperation::RegisterUntrackedSourceV1 { source } => { + self.require_writer(WriterRole::Admin, &tx.account_id).await?; + validate_untracked_source(source)?; + match self.db.untracked_source(&source.source_id).await? { + Some(existing) if existing == *source => AppliedMutation::None, + Some(_) => return Err(CostsError::IdempotencyConflict { reference: source.source_id.clone() }), + None => { self.db.set_untracked_source(source.clone()); AppliedMutation::UntrackedSourceRegistered } + } + } + CostsOperation::CreditAdjustmentV1 { kind, account_id, credits, metadata } => { + if self.apply_adjustment_v1(*kind, account_id, *credits, metadata, &tx.account_id).await? { AppliedMutation::BalanceChanged } else { AppliedMutation::None } + } + CostsOperation::StageRateCardEntries { .. } => return Err(CostsError::LegacyOperationRejected { operation: "StageRateCardEntries" }), + CostsOperation::ApplyRateCardChangeSet { .. } => return Err(CostsError::LegacyOperationRejected { operation: "ApplyRateCardChangeSet" }), + CostsOperation::StageRateCardChangeSetV1 { change_set, entries } => { + self.require_writer(WriterRole::Admin, &tx.account_id).await?; + AppliedMutation::RatesStaged(self.stage_rate_entries_v1(change_set, entries).await?) + } + CostsOperation::ApplyRateCardChangeSetV1 { change_set, entries } => { + self.require_writer(WriterRole::Admin, &tx.account_id).await?; + let applied = self.apply_rate_change_set_v1(change_set, entries).await?; + if applied.is_empty() { AppliedMutation::None } else { AppliedMutation::RatesApplied(applied) } + } + }; + + self.advance_nonce(&tx.account_id, expected)?; + self.append_mutation_outcomes(tx, applied).await + } + + async fn append_mutation_outcomes( + &mut self, + tx: &Transaction, applied: AppliedMutation, + ) -> Result, CostsError> { + let transaction_id = format!("{:?}", tx.digest()); + let mut entries = Vec::new(); + match (applied, &tx.payload.operation) { + (AppliedMutation::Onboarded, CostsOperation::CreateAccount { account_id, cohort_ref, .. }) => { + entries.push(self.outcome_for_account(&transaction_id, LedgerMutationKind::AccountOnboarded, account_id, cohort_ref, "").await?); + } + (AppliedMutation::OnboardedWithRates(rates), CostsOperation::CreateAccount { account_id, cohort_ref, .. }) => { + entries.push(self.outcome_for_account(&transaction_id, LedgerMutationKind::AccountOnboarded, account_id, cohort_ref, "").await?); + for rate in rates { + let mut entry = self.outcome_for_rate(&transaction_id, LedgerMutationKind::RateCardApplied, "onboarding_inheritance", &rate).await?; + entry.has_rate = true; + entry.reason_code = "global_rate_inherited".to_string(); + entries.push(entry); + } + } + (AppliedMutation::BalanceChanged, CostsOperation::CreditTopup { account_id, rail_ref, credits }) => { + let mut entry = self.outcome_for_account(&transaction_id, LedgerMutationKind::BalanceChanged, account_id, "", rail_ref).await?; + entry.balance_direction = BalanceMutationDirection::Credit; + entry.credit_delta = *credits; + entry.reason_code = "topup".to_string(); + entries.push(entry); + } + (AppliedMutation::BalanceChanged, CostsOperation::CreditAdjustmentV1 { kind, account_id, credits, metadata }) => { + let mut entry = self.outcome_for_account(&transaction_id, LedgerMutationKind::BalanceChanged, account_id, "", &metadata.reference).await?; + entry.balance_direction = match kind { AdjustmentKind::Grant => BalanceMutationDirection::Credit, AdjustmentKind::Reversal => BalanceMutationDirection::Debit }; + entry.credit_delta = *credits; + entry.reason_code = metadata.reason_code.clone(); + entry.period_ref = metadata.period_ref.clone(); + entry.approval_ref = metadata.approval_ref.clone(); + entry.audit_ref = metadata.audit_ref.clone(); + entries.push(entry); + } + (AppliedMutation::Spend(records), CostsOperation::RecordSpendBatch { .. }) => { + for record in &records { + entries.push(self.outcome_for_account( + &transaction_id, LedgerMutationKind::SpendRecorded, &record.account_id, + &record.cohort_ref, &record.source_ref, + ).await?); + } + } + (AppliedMutation::StatusChanged, CostsOperation::SetAccountStatusV1 { account_id, metadata, .. }) => { + let mut entry = self.outcome_for_account(&transaction_id, LedgerMutationKind::AccountStatusChanged, account_id, "", "").await?; + entry.reason_code = metadata.reason_code.clone(); + entry.occurred_at = metadata.changed_at; + entry.approval_ref = metadata.approval_ref.clone(); + entry.audit_ref = metadata.audit_ref.clone(); + entries.push(entry); + } + (AppliedMutation::ReservationChanged, CostsOperation::ReserveCreditsV1 { reservation_id, .. }) + | (AppliedMutation::ReservationChanged, CostsOperation::ReleaseReservationV1 { reservation_id, .. }) + | (AppliedMutation::ReservationChanged, CostsOperation::ExpireReservationV1 { reservation_id, .. }) + | (AppliedMutation::ReservationChanged, CostsOperation::SettleSpendV1 { reservation_id, .. }) => { + let reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| CostsError::ReservationNotFound { reservation_id: reservation_id.clone() })?; + let profile = self.db.profile(&reservation.account_id).await?; + let mut entry = self.outcome_for_account( + &transaction_id, LedgerMutationKind::ReservationChanged, &reservation.account_id, + profile.as_ref().map_or("", |value| value.cohort_ref.as_str()), "", + ).await?; + entry.has_reservation = true; + entry.reservation = reservation; + match &tx.payload.operation { + CostsOperation::ReleaseReservationV1 { metadata, .. } + | CostsOperation::ExpireReservationV1 { metadata, .. } + | CostsOperation::SettleSpendV1 { metadata, .. } => { + entry.reason_code = metadata.reason_code.clone(); + entry.occurred_at = metadata.occurred_at; + entry.approval_ref = metadata.approval_ref.clone(); + entry.audit_ref = metadata.audit_ref.clone(); + } + CostsOperation::ReserveCreditsV1 { reserved_at, .. } => entry.occurred_at = *reserved_at, + _ => {} + } + entries.push(entry); + } + (AppliedMutation::UntrackedSourceRegistered, CostsOperation::RegisterUntrackedSourceV1 { source }) => { + let stored = self.db.untracked_source(&source.source_id).await?.ok_or_else(|| CostsError::Storage("untracked source was not persisted".to_string()))?; + let mut entry = empty_mutation(&transaction_id, LedgerMutationKind::UntrackedSourceRegistered); + entry.source_ref = stored.provenance_ref.clone(); + entry.cohort_ref = stored.cohort_ref.clone(); + entry.has_untracked_source = true; + entry.untracked_source = stored; + entries.push(entry); + } + (AppliedMutation::RatesStaged(rate_entries), CostsOperation::StageRateCardChangeSetV1 { change_set, .. }) => { + for rate in &rate_entries { + let mut entry = self.outcome_for_rate(&transaction_id, LedgerMutationKind::RateCardStaged, &change_set.change_set_id, rate).await?; + entry.has_rate = true; + entry.approval_ref = change_set.approval_ref.clone(); + entry.audit_ref = change_set.audit_ref.clone(); + entries.push(entry); + } + } + (AppliedMutation::RatesApplied(rate_entries), CostsOperation::ApplyRateCardChangeSetV1 { change_set, .. }) => { + for rate in &rate_entries { + let kind = if rate.account_id.is_empty() { LedgerMutationKind::RateCardGlobalApplied } else { LedgerMutationKind::RateCardApplied }; + let mut entry = self.outcome_for_rate(&transaction_id, kind, &change_set.change_set_id, rate).await?; + entry.has_rate = true; + entry.approval_ref = change_set.approval_ref.clone(); + entry.audit_ref = change_set.audit_ref.clone(); + entries.push(entry); + } + let mut completion = empty_mutation(&transaction_id, LedgerMutationKind::RateCardCompleted); + completion.rate_change_set_id = change_set.change_set_id.clone(); + completion.reason_code = "rate_card_applied".to_string(); + completion.occurred_at = change_set.activation_epoch; + completion.approval_ref = change_set.approval_ref.clone(); + completion.audit_ref = change_set.audit_ref.clone(); + completion.has_rate_card_completion = true; + completion.rate_card_completion = RateCardCompletionV1 { + change_set_id: change_set.change_set_id.clone(), manifest_hash: change_set.manifest_hash.clone(), + entry_count: change_set.expected_entry_count, target_count: change_set.expected_target_count, + activation_epoch: change_set.activation_epoch, approval_ref: change_set.approval_ref.clone(), + audit_ref: change_set.audit_ref.clone(), + affected_rates: rate_entries.clone(), + }; + entries.push(completion); + } + // Writer capability changes and rejected legacy commands cannot + // change a custodial balance, status, reservation, rate, or source. + _ => {} + } + let mut committed = Vec::with_capacity(entries.len()); + for mut entry in entries { + let sequence = self.db.journal_count().await?; + entry.sequence = sequence; + self.db.set_journal_entry(entry.clone()); + self.db.set_journal_count(sequence.checked_add(1).ok_or(CostsError::NonceOverflow)?); + committed.push(entry); + } + Ok(committed) + } + + async fn outcome_for_account( + &self, transaction_id: &str, kind: LedgerMutationKind, account_id: &str, + cohort_ref: &str, source_ref: &str, + ) -> Result { + let mut entry = empty_mutation(transaction_id, kind); + entry.account_id = account_id.to_string(); + entry.has_account = true; + entry.account = self.require_account(account_id).await?; + entry.cohort_ref = if cohort_ref.is_empty() { + self.db.profile(account_id).await?.map_or_else(String::new, |profile| profile.cohort_ref) + } else { cohort_ref.to_string() }; + entry.source_ref = source_ref.to_string(); + Ok(entry) + } + + async fn outcome_for_rate( + &self, transaction_id: &str, kind: LedgerMutationKind, change_set_id: &str, + rate: &crate::RateCardEntry, + ) -> Result { + let mut entry = empty_mutation(transaction_id, kind); + entry.rate_change_set_id = change_set_id.to_string(); + entry.account_id = rate.account_id.clone(); + entry.has_rate = true; + entry.rate = rate.clone(); + if !rate.account_id.is_empty() { + entry.has_account = self.db.account(&rate.account_id).await?.is_some(); + if entry.has_account { + entry.account = self.require_account(&rate.account_id).await?; + entry.cohort_ref = self.db.profile(&rate.account_id).await?.map_or_else(String::new, |profile| profile.cohort_ref); + } + } + Ok(entry) + } + + async fn require_writer(&self, role: WriterRole, writer: &Address) -> Result<(), CostsError> { + if self.db.writer(role, writer).await? { + Ok(()) + } else { + Err(CostsError::Unauthorized { + role, + writer: Box::new(writer.clone()), + }) + } + } + + async fn require_writer_for_account( + &self, + role: WriterRole, + account_id: &str, + writer: &Address, + ) -> Result<(), CostsError> { + if self.db.writer(role, writer).await? || self.db.account_writer(role, account_id, writer).await? { + Ok(()) + } else { + Err(CostsError::Unauthorized { role, writer: Box::new(writer.clone()) }) + } + } + + async fn require_account(&self, account_id: &str) -> Result { + self.db + .account(account_id) + .await? + .ok_or_else(|| CostsError::AccountNotFound { + account_id: account_id.to_string(), + }) + } + + async fn append_status_history( + &mut self, + account_id: &str, + status: AccountStatus, + reason_code: &str, + changed_at: u64, + approval_ref: &str, + audit_ref: &str, + ) -> Result<(), CostsError> { + let sequence = self.db.status_history_count(account_id).await?; + self.db.set_status_history( + account_id, + StatusHistoryEntry { + sequence, + status, + reason_code: reason_code.to_string(), + changed_at, + approval_ref: approval_ref.to_string(), audit_ref: audit_ref.to_string(), + }, + ); + self.db.set_status_history_count( + account_id, + sequence.checked_add(1).ok_or(CostsError::NonceOverflow)?, + ); + Ok(()) + } + + async fn update_profile_status( + &mut self, + account_id: &str, + status: AccountStatus, + reason_code: &str, + changed_at: u64, approval_ref: &str, audit_ref: &str, + ) -> Result<(), CostsError> { + let mut profile = self.db.profile(account_id).await?.unwrap_or(AccountProfile { + account_id: account_id.to_string(), + external_ref: format!("legacy:{account_id}"), + policy_ref: "legacy".to_string(), + created_at: 0, + cohort_ref: String::new(), + status_reason: String::new(), + status_changed_at: 0, + }); + profile.status_reason = reason_code.to_string(); + profile.status_changed_at = changed_at; + self.db.set_profile(profile); + self.append_status_history(account_id, status, reason_code, changed_at, approval_ref, audit_ref) + .await + } + + async fn set_account_status_v1(&mut self, account_id: &str, status: AccountStatus, metadata: &StatusChangeMetadataV1) -> Result { + validate_identifier(account_id, "account_id")?; validate_identifier(&metadata.reason_code, "reason_code")?; validate_identifier(&metadata.approval_ref, "approval_ref")?; validate_identifier(&metadata.audit_ref, "audit_ref")?; + if metadata.changed_at == 0 { return Err(CostsError::InvalidField { field: "changed_at" }); } + let mut account = self.require_account(account_id).await?; + let profile = self.db.profile(account_id).await?.ok_or_else(|| CostsError::AccountNotFound { account_id: account_id.to_string() })?; + if account.status == status && profile.status_reason == metadata.reason_code && profile.status_changed_at == metadata.changed_at { return Ok(AppliedMutation::None); } + account.status = status; self.db.set_account(account_id, account); + self.update_profile_status(account_id, status, &metadata.reason_code, metadata.changed_at, &metadata.approval_ref, &metadata.audit_ref).await?; + Ok(AppliedMutation::StatusChanged) + } + + async fn verify_pinned_rate(&self, record: &crate::SpendRecordV1) -> Result<(), CostsError> { + if !record.task_key.is_empty() { + validate_identifier(&record.task_key, "task_key")?; + } + validate_identifier(&record.policy_version, "policy_version")?; + validate_identifier(&record.rate_version, "rate_version")?; + validate_identifier(&record.source_ref, "source_ref")?; + validate_identifier(&record.lineage_ref, "lineage_ref")?; + if !record.cohort_ref.is_empty() { + validate_identifier(&record.cohort_ref, "cohort_ref")?; + } + if record.quantity == 0 || record.observed_at == 0 { + return Err(CostsError::PinnedRateMismatch { + event_id: record.event_id.clone(), + }); + } + let profile = self.db.profile(&record.account_id).await?.ok_or_else(|| CostsError::AccountNotFound { + account_id: record.account_id.clone(), + })?; + if profile.cohort_ref != record.cohort_ref { + return Err(CostsError::PinnedRateMismatch { + event_id: record.event_id.clone(), + }); + } + let Some(rate) = self + .active_rate( + &record.account_id, + &record.event_category, + &record.task_key, + record.observed_at, + ) + .await? + else { + return Err(CostsError::PinnedRateMismatch { + event_id: record.event_id.clone(), + }); + }; + let expected = rate + .credits + .checked_mul(record.quantity) + .ok_or(CostsError::CreditOverflow)?; + if rate.rate_version != record.rate_version + || rate.policy_version != record.policy_version + || expected != record.credits + { + return Err(CostsError::PinnedRateMismatch { + event_id: record.event_id.clone(), + }); + } + Ok(()) + } + + async fn reserve_v1(&mut self, reservation_id: &str, quote: &QuoteV1, lineage_ref: &str, reserved_at: u64) -> Result { + validate_identifier(reservation_id, "reservation_id")?; validate_quote(quote)?; + validate_identifier(lineage_ref, "lineage_ref")?; + if reserved_at < quote.quoted_at || reserved_at >= quote.expires_at { return Err(CostsError::ReservationExpired { reservation_id: reservation_id.to_string(), expires_at: quote.expires_at }); } + let Some(rate) = self.active_rate("e.account_id, "e.event_category, "e.task_key, quote.quoted_at).await? else { return Err(CostsError::PinnedRateMismatch { event_id: quote.quote_id.clone() }); }; + if rate.credits != quote.credits_per_unit || rate.policy_version != quote.policy_version || rate.rate_version != quote.rate_version || quote.total_credits != quote.credits_per_unit.checked_mul(quote.quantity).ok_or(CostsError::CreditOverflow)? || quote.snapshot_hash != quote_snapshot_hash(quote) { return Err(CostsError::PinnedRateMismatch { event_id: quote.quote_id.clone() }); } + let requested = Reservation { reservation_id: reservation_id.to_string(), account_id: quote.account_id.clone(), quote: quote.clone(), lineage_ref: lineage_ref.to_string(), credits: quote.total_credits, expires_at: quote.expires_at, status: ReservationStatus::Active }; + if let Some(existing) = self.db.reservation(reservation_id).await? { return if existing == requested { Ok(false) } else { Err(CostsError::ReservationConflict { reservation_id: reservation_id.to_string() }) }; } + let mut account = self.require_account("e.account_id).await?; if account.status == AccountStatus::Suspended { return Err(CostsError::AccountSuspended { account_id: quote.account_id.clone() }); } + let available = account.available_credits; account.available_credits = available.checked_sub(quote.total_credits).ok_or_else(|| CostsError::InsufficientCredits { account_id: quote.account_id.clone(), available, required: quote.total_credits })?; account.reserved_credits = account.reserved_credits.checked_add(quote.total_credits).ok_or(CostsError::CreditOverflow)?; + self.db.set_account("e.account_id, account); self.db.set_reservation(requested); Ok(true) + } + + async fn release_reservation(&mut self, reservation_id: &str) -> Result { + validate_identifier(reservation_id, "reservation_id")?; + let mut reservation = self + .db + .reservation(reservation_id) + .await? + .ok_or_else(|| CostsError::ReservationNotFound { + reservation_id: reservation_id.to_string(), + })?; + if reservation.status == ReservationStatus::Released { + return Ok(false); + } + if reservation.status != ReservationStatus::Active { + return Err(CostsError::ReservationNotActive { + reservation_id: reservation_id.to_string(), + }); + } + let mut account = self.require_account(&reservation.account_id).await?; + account.reserved_credits = account + .reserved_credits + .checked_sub(reservation.credits) + .ok_or(CostsError::ReservedCreditUnderflow)?; + account.available_credits = account + .available_credits + .checked_add(reservation.credits) + .ok_or(CostsError::CreditOverflow)?; + reservation.status = ReservationStatus::Released; + self.db.set_account(&reservation.account_id, account); + self.db.set_reservation(reservation); + Ok(true) + } + + async fn expire_reservation( + &mut self, + reservation_id: &str, + expired_at: u64, + ) -> Result { + validate_identifier(reservation_id, "reservation_id")?; + let mut reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| { + CostsError::ReservationNotFound { reservation_id: reservation_id.to_string() } + })?; + if reservation.status == ReservationStatus::Expired { return Ok(false); } + if reservation.status != ReservationStatus::Active { + return Err(CostsError::ReservationNotActive { reservation_id: reservation_id.to_string() }); + } + if expired_at < reservation.expires_at { + return Err(CostsError::ReservationExpired { reservation_id: reservation_id.to_string(), expires_at: reservation.expires_at }); + } + let mut account = self.require_account(&reservation.account_id).await?; + account.reserved_credits = account.reserved_credits.checked_sub(reservation.credits) + .ok_or(CostsError::ReservedCreditUnderflow)?; + account.available_credits = account.available_credits.checked_add(reservation.credits) + .ok_or(CostsError::CreditOverflow)?; + reservation.status = ReservationStatus::Expired; + self.db.set_account(&reservation.account_id, account); + self.db.set_reservation(reservation); + Ok(true) + } + + async fn apply_adjustment_v1( + &mut self, + kind: AdjustmentKind, + account_id: &str, + credits: u64, + metadata: &AdjustmentMetadata, + writer: &Address, + ) -> Result { + validate_identifier(account_id, "account_id")?; + validate_identifier(&metadata.reference, "adjustment_reference")?; + validate_identifier(&metadata.reason_code, "reason_code")?; + validate_identifier(&metadata.period_ref, "period_ref")?; + if credits == 0 { return Err(CostsError::InvalidField { field: "credits" }); } + self.require_writer_for_account(WriterRole::Adjustment, account_id, writer).await?; + validate_identifier(&metadata.approval_ref, "approval_ref")?; + validate_identifier(&metadata.audit_ref, "audit_ref")?; + let fingerprint = adjustment_fingerprint(kind, account_id, credits, metadata); + if let Some(existing) = self.db.rail_fingerprint(&metadata.reference).await? { + if existing == fingerprint { return Ok(false); } + return Err(CostsError::IdempotencyConflict { reference: metadata.reference.clone() }); + } + let mut account = self.require_account(account_id).await?; + match kind { + AdjustmentKind::Grant => account.available_credits = account.available_credits.checked_add(credits).ok_or(CostsError::CreditOverflow)?, + AdjustmentKind::Reversal => { + let available = account.available_credits; + account.available_credits = available.checked_sub(credits).ok_or_else(|| CostsError::InsufficientCredits { + account_id: account_id.to_string(), available, required: credits, + })?; + } + } + self.db.set_account(account_id, account); + self.db.mark_rail(&metadata.reference, &fingerprint); + Ok(true) + } + + async fn settle_reservation( + &mut self, + reservation_id: &str, + event_id: &str, + event_category: &str, + ) -> Result { + validate_identifier(reservation_id, "reservation_id")?; + validate_identifier(event_id, "event_id")?; + validate_identifier(event_category, "event_category")?; + let mut reservation = self + .db + .reservation(reservation_id) + .await? + .ok_or_else(|| CostsError::ReservationNotFound { + reservation_id: reservation_id.to_string(), + })?; + let fingerprint = reservation_event_fingerprint(reservation_id, event_id, event_category); + if let Some(existing) = self.db.event_fingerprint(event_id).await? { + if existing == fingerprint && reservation.status == ReservationStatus::Settled { + return Ok(false); + } + return Err(CostsError::IdempotencyConflict { + reference: event_id.to_string(), + }); + } + if reservation.status != ReservationStatus::Active { + return Err(CostsError::ReservationNotActive { + reservation_id: reservation_id.to_string(), + }); + } + let mut account = self.require_account(&reservation.account_id).await?; + if account.status == AccountStatus::Suspended { + return Err(CostsError::ReservationAccountSuspended { + reservation_id: reservation_id.to_string(), + }); + } + account.reserved_credits = account + .reserved_credits + .checked_sub(reservation.credits) + .ok_or(CostsError::ReservedCreditUnderflow)?; + reservation.status = ReservationStatus::Settled; + self.db.set_account(&reservation.account_id, account); + self.db.set_reservation(reservation); + self.db.mark_event(event_id, &fingerprint); + Ok(true) + } + + #[allow(clippy::too_many_arguments)] + async fn settle_reservation_v1(&mut self, reservation_id: &str, event_id: &str, event_category: &str, task_key: &str, quote_id: &str, snapshot_hash: &str, lineage_ref: &str, settled_at: u64) -> Result { + validate_identifier(quote_id, "quote_id")?; validate_identifier(snapshot_hash, "snapshot_hash")?; validate_identifier(lineage_ref, "lineage_ref")?; + if !task_key.is_empty() { validate_identifier(task_key, "task_key")?; } + let reservation = self.db.reservation(reservation_id).await?.ok_or_else(|| CostsError::ReservationNotFound { reservation_id: reservation_id.to_string() })?; + if reservation.quote.quote_id != quote_id || reservation.quote.snapshot_hash != snapshot_hash + || reservation.quote.event_category != event_category || reservation.quote.task_key != task_key + || reservation.lineage_ref != lineage_ref { return Err(CostsError::PinnedRateMismatch { event_id: event_id.to_string() }); } + if settled_at >= reservation.expires_at { return Err(CostsError::ReservationExpired { reservation_id: reservation_id.to_string(), expires_at: reservation.expires_at }); } + self.settle_reservation(reservation_id, event_id, event_category).await + } + + #[allow(dead_code)] + async fn stage_rate_entries( + &mut self, + change_set_id: &str, + expected_entry_count: u16, + manifest_hash: &str, + entries: &[crate::RateCardEntry], + ) -> Result, CostsError> { + validate_identifier(change_set_id, "change_set_id")?; + validate_identifier(manifest_hash, "manifest_hash")?; + if expected_entry_count == 0 { + return Err(CostsError::InvalidField { + field: "expected_entry_count", + }); + } + validate_rate_entries(entries)?; + let mut change_set = match self.db.rate_change_set(change_set_id).await? { + Some(change_set) => { + if change_set.expected_entry_count != expected_entry_count + || change_set.manifest_hash != manifest_hash + { + return Err(CostsError::RateChangeSetConflict { + change_set_id: change_set_id.to_string(), + }); + } + change_set + } + None => crate::RateCardChangeSet { + change_set_id: change_set_id.to_string(), + expected_entry_count, + target_account_ids: Vec::new(), + expected_target_count: 0, + manifest_hash: manifest_hash.to_string(), + staged_entry_count: 0, + // The current command format predates the explicit V1 control + // plane envelope. Keep its approved manifest correlation + // deterministic until callers move to that envelope. + approval_ref: format!("approved:{manifest_hash}"), + audit_ref: "legacy".to_string(), + activation_epoch: 0, + applied: false, + }, + }; + + let mut new_entries = Vec::new(); + for entry in entries { + match self.db.staged_rate(change_set_id, entry).await? { + Some(existing) if existing == *entry => {} + Some(_) => { + return Err(CostsError::RateChangeSetConflict { + change_set_id: change_set_id.to_string(), + }) + } + None => new_entries.push(entry.clone()), + } + } + let new_count = u16::try_from(new_entries.len()).map_err(|_| CostsError::RateCommandTooLarge { + actual: new_entries.len(), + maximum: MAX_RATE_ENTRIES_PER_COMMAND, + })?; + change_set.staged_entry_count = change_set + .staged_entry_count + .checked_add(new_count) + .ok_or(CostsError::CreditOverflow)?; + if change_set.staged_entry_count > expected_entry_count { + return Err(CostsError::RateChangeSetConflict { + change_set_id: change_set_id.to_string(), + }); + } + for entry in &new_entries { + self.db.set_staged_rate(change_set_id, entry.clone()); + } + self.db.set_rate_change_set(change_set); + Ok(new_entries) + } + + async fn stage_rate_entries_v1(&mut self, envelope: &RateCardChangeSetV1, entries: &[crate::RateCardEntry]) -> Result, CostsError> { + validate_rate_change_set_v1(envelope)?; + validate_rate_entries(entries)?; + // A change set may be staged in bounded chunks; its target manifest is + // verified atomically at apply once every approved entry is present. + if entries.len() == envelope.expected_entry_count as usize { + self.validate_target_manifest(envelope, entries).await?; + self.validate_canonical_manifest(envelope, entries)?; + self.validate_activation_boundary(envelope, entries)?; + } + let high_watermark = self.db.activation_epoch_high_watermark().await?; + let mut change_set = match self.db.rate_change_set(&envelope.change_set_id).await? { + Some(existing) => { + if !same_rate_change_set_envelope(&existing, envelope) { + return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() }); + } + existing + } + None => { + if envelope.activation_epoch <= high_watermark || envelope.applied { + return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() }); + } + let mut staged = envelope.clone(); + staged.staged_entry_count = 0; + staged.applied = false; + staged + } + }; + let mut new_entries = Vec::new(); + for entry in entries { + match self.db.staged_rate(&envelope.change_set_id, entry).await? { + Some(existing) if existing == *entry => {} + Some(_) => return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() }), + None => new_entries.push(entry.clone()), + } + } + let added = u16::try_from(new_entries.len()).map_err(|_| CostsError::RateCommandTooLarge { actual: new_entries.len(), maximum: MAX_RATE_ENTRIES_PER_COMMAND })?; + change_set.staged_entry_count = change_set.staged_entry_count.checked_add(added).ok_or(CostsError::CreditOverflow)?; + if change_set.staged_entry_count > change_set.expected_entry_count { + return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() }); + } + for entry in &new_entries { self.db.set_staged_rate(&envelope.change_set_id, entry.clone()); } + self.db.set_rate_change_set(change_set); + Ok(new_entries) + } + + async fn apply_rate_change_set( + &mut self, + change_set_id: &str, + manifest_hash: &str, + entries: &[crate::RateCardEntry], + ) -> Result, CostsError> { + validate_identifier(change_set_id, "change_set_id")?; + validate_identifier(manifest_hash, "manifest_hash")?; + validate_rate_entries(entries)?; + let change_set = self + .db + .rate_change_set(change_set_id) + .await? + .ok_or_else(|| CostsError::RateChangeSetNotFound { + change_set_id: change_set_id.to_string(), + })?; + if change_set.manifest_hash != manifest_hash + || entries.len() != change_set.expected_entry_count as usize + { + return Err(CostsError::RateChangeSetConflict { + change_set_id: change_set_id.to_string(), + }); + } + if change_set.staged_entry_count != change_set.expected_entry_count { + return Err(CostsError::RateChangeSetIncomplete { + change_set_id: change_set_id.to_string(), + staged: change_set.staged_entry_count, + expected: change_set.expected_entry_count, + }); + } + if change_set.applied { + return Ok(Vec::new()); + } + let global_entries = entries.iter().filter(|entry| entry.account_id.is_empty()).count() as u64; + let global_count = self.db.global_rate_registry_count().await?; + if global_count.checked_add(global_entries).is_none_or(|count| count > MAX_GLOBAL_RATE_REVISIONS) { + return Err(CostsError::GlobalRateRegistryFull { maximum: MAX_GLOBAL_RATE_REVISIONS }); + } + for entry in entries { + if self.db.staged_rate(change_set_id, entry).await? != Some(entry.clone()) { + return Err(CostsError::RateChangeSetConflict { + change_set_id: change_set_id.to_string(), + }); + } + } + for entry in entries { + self.db.set_active_rate(entry.clone()); + self.append_rate_history(entry.clone(), false).await?; + if entry.account_id.is_empty() { + self.append_global_rate_revision(entry.clone()).await?; + } else { + self.db.set_global_rate_materialized(&entry.account_id, &entry.event_category, &entry.task_key, false); + } + } + let mut applied = change_set; + applied.applied = true; + self.db.set_rate_change_set(applied); + let mut materialized = entries.to_vec(); + for entry in entries.iter().filter(|entry| entry.account_id.is_empty()) { + materialized.extend(self.propagate_global_rate(entry).await?); + } + Ok(materialized) + } + + async fn apply_rate_change_set_v1(&mut self, envelope: &RateCardChangeSetV1, entries: &[crate::RateCardEntry]) -> Result, CostsError> { + validate_rate_change_set_v1(envelope)?; + validate_rate_entries(entries)?; + self.validate_target_manifest(envelope, entries).await?; + self.validate_canonical_manifest(envelope, entries)?; + self.validate_activation_boundary(envelope, entries)?; + let stored = self.db.rate_change_set(&envelope.change_set_id).await?.ok_or_else(|| CostsError::RateChangeSetNotFound { change_set_id: envelope.change_set_id.clone() })?; + if !same_rate_change_set_envelope(&stored, envelope) { return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() }); } + if stored.applied { return Ok(Vec::new()); } + if envelope.applied || envelope.activation_epoch <= self.db.activation_epoch_high_watermark().await? { return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() }); } + let applied = self.apply_rate_change_set(&envelope.change_set_id, &envelope.manifest_hash, entries).await?; + self.db.set_activation_epoch_high_watermark(envelope.activation_epoch); + Ok(applied) + } + + fn validate_canonical_manifest(&self, envelope: &RateCardChangeSetV1, entries: &[crate::RateCardEntry]) -> Result<(), CostsError> { + if envelope.manifest_hash != envelope.expected_manifest_hash(entries) { + return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() }); + } + Ok(()) + } + + fn validate_activation_boundary(&self, envelope: &RateCardChangeSetV1, entries: &[crate::RateCardEntry]) -> Result<(), CostsError> { + // `activation_epoch` is a monotonic control-plane boundary, not wall + // clock time. Runtime/finality time validation belongs at the + // operator service integration boundary; this only prevents a command from + // declaring a rate before its own approved logical epoch. + if entries.iter().any(|entry| entry.effective_at < envelope.activation_epoch) { + return Err(CostsError::InvalidField { field: "rate_effective_at_before_activation_epoch" }); + } + Ok(()) + } + + async fn append_rate_history(&mut self, entry: crate::RateCardEntry, global_materialization: bool) -> Result<(), CostsError> { + let count = self.db.rate_history_count(&entry.account_id, &entry.event_category, &entry.task_key).await?; + // Exact replay is normally caught by the changeset applied marker; the + // defensive scan also prevents a caller from duplicating a revision + // through a distinct change set. + for sequence in 0..count { + if self.db.rate_history_entry(&entry.account_id, &entry.event_category, &entry.task_key, sequence).await? == Some(entry.clone()) + && self.db.rate_history_global_materialization(&entry.account_id, &entry.event_category, &entry.task_key, sequence).await? == global_materialization { + return Ok(()); + } + } + self.db.set_rate_history_entry(entry.clone(), count); + self.db.set_rate_history_global_materialization(&entry.account_id, &entry.event_category, &entry.task_key, count, global_materialization); + self.db.set_rate_history_count(&entry.account_id, &entry.event_category, &entry.task_key, count.checked_add(1).ok_or(CostsError::NonceOverflow)?); + Ok(()) + } + + async fn validate_target_manifest(&self, envelope: &RateCardChangeSetV1, entries: &[crate::RateCardEntry]) -> Result<(), CostsError> { + let mut expected = BTreeSet::new(); + let explicit = entries.iter().filter(|entry| !entry.account_id.is_empty()).map(|entry| (entry.account_id.clone(), entry.event_category.clone(), entry.task_key.clone())).collect::>(); + for entry in entries { + if !entry.account_id.is_empty() { expected.insert(entry.account_id.clone()); continue; } + let count = self.db.account_registry_count().await?; + if count > MAX_REGISTERED_SITES { return Err(CostsError::AccountRegistryFull { maximum: MAX_REGISTERED_SITES }); } + for sequence in 0..count { + let Some(account_id) = self.db.account_registry_account(sequence).await? else { continue }; + if explicit.contains(&(account_id.clone(), entry.event_category.clone(), entry.task_key.clone())) { continue; } + if self.global_rate_applies_to_account(&account_id, entry, entry.effective_at).await? { expected.insert(account_id); } + } + } + let supplied = envelope.target_account_ids.iter().cloned().collect::>(); + if supplied.len() != envelope.target_account_ids.len() || supplied != expected || envelope.expected_target_count as usize != expected.len() { + return Err(CostsError::RateChangeSetConflict { change_set_id: envelope.change_set_id.clone() }); + } + Ok(()) + } + + async fn append_account_to_registry(&mut self, account_id: &str) -> Result<(), CostsError> { + self.preflight_account_registry_append(account_id).await?; + let count = self.db.account_registry_count().await?; + for sequence in 0..count { + if self.db.account_registry_account(sequence).await?.as_deref() == Some(account_id) { + return Ok(()); + } + } + self.db.set_account_registry_account(count, account_id); + self.db.set_account_registry_count(count + 1); + Ok(()) + } + + async fn preflight_account_registry_append(&self, account_id: &str) -> Result<(), CostsError> { + let count = self.db.account_registry_count().await?; + for sequence in 0..count { + if self.db.account_registry_account(sequence).await?.as_deref() == Some(account_id) { return Ok(()); } + } + if count >= MAX_REGISTERED_SITES { return Err(CostsError::AccountRegistryFull { maximum: MAX_REGISTERED_SITES }); } + Ok(()) + } + + async fn append_global_rate_revision(&mut self, entry: crate::RateCardEntry) -> Result<(), CostsError> { + let count = self.db.global_rate_registry_count().await?; + if count >= MAX_GLOBAL_RATE_REVISIONS { return Err(CostsError::GlobalRateRegistryFull { maximum: MAX_GLOBAL_RATE_REVISIONS }); } + self.db.set_global_rate_registry_entry(count, entry); + self.db.set_global_rate_registry_count(count + 1); + Ok(()) + } + + /// Materialize a newly-approved global key to every current account which + /// does not own an exact account/key override. The derived local history is + /// marked so later global updates replace it, while an explicit account rate + /// clears that marker and remains authoritative. + async fn propagate_global_rate(&mut self, global: &crate::RateCardEntry) -> Result, CostsError> { + let mut materialized = Vec::new(); + let count = self.db.account_registry_count().await?; + if count > MAX_REGISTERED_SITES { return Err(CostsError::AccountRegistryFull { maximum: MAX_REGISTERED_SITES }); } + for sequence in 0..count { + let Some(account_id) = self.db.account_registry_account(sequence).await? else { continue }; + if !self.global_rate_applies_to_account(&account_id, global, global.effective_at).await? { continue; } + let derived = crate::RateCardEntry { account_id: account_id.clone(), ..global.clone() }; + self.db.set_active_rate(derived.clone()); + self.append_rate_history(derived.clone(), true).await?; + self.db.set_global_rate_materialized(&account_id, &global.event_category, &global.task_key, true); + materialized.push(derived); + } + Ok(materialized) + } + + async fn global_rate_applies_to_account(&self, account_id: &str, global: &crate::RateCardEntry, at: u64) -> Result { + // An explicit account/category is more specific than a global task/SKU; + // avoid even materializing the lower-priority global target, so the + // finality stream never falsely claims it became effective there. + if !global.task_key.is_empty() + && self.account_has_explicit_active_rate(account_id, &global.event_category, "", at).await? { + return Ok(false); + } + Ok(!self.account_has_explicit_active_rate(account_id, &global.event_category, &global.task_key, at).await?) + } + + async fn account_has_explicit_active_rate(&self, account_id: &str, event_category: &str, task_key: &str, at: u64) -> Result { + let count = self.db.rate_history_count(account_id, event_category, task_key).await?; + for sequence in 0..count { + let Some(entry) = self.db.rate_history_entry(account_id, event_category, task_key, sequence).await? else { continue }; + if self.db.rate_history_global_materialization(account_id, event_category, task_key, sequence).await? { continue; } + if entry.effective_at <= at && (entry.expires_at == 0 || at < entry.expires_at) { return Ok(true); } + } + Ok(false) + } + + /// New account accounts inherit all global defaults effective at onboarding. + /// This writes local snapshots and emits rate mutation records, so clients + /// can reconcile account creation without querying global internals. + async fn materialize_active_global_rates(&mut self, account_id: &str, at: u64) -> Result, CostsError> { + let count = self.db.global_rate_registry_count().await?; + if count > MAX_GLOBAL_RATE_REVISIONS { return Err(CostsError::GlobalRateRegistryFull { maximum: MAX_GLOBAL_RATE_REVISIONS }); } + let mut selected = BTreeMap::<(String, String), crate::RateCardEntry>::new(); + for sequence in 0..count { + let Some(entry) = self.db.global_rate_registry_entry(sequence).await? else { continue }; + if entry.effective_at > at || (entry.expires_at != 0 && at >= entry.expires_at) { continue; } + let key = (entry.event_category.clone(), entry.task_key.clone()); + if selected.get(&key).is_none_or(|current| entry.effective_at >= current.effective_at) { selected.insert(key, entry); } + } + let mut materialized = Vec::with_capacity(selected.len()); + for (_, global) in selected { + let derived = crate::RateCardEntry { account_id: account_id.to_string(), ..global }; + self.db.set_active_rate(derived.clone()); + self.append_rate_history(derived.clone(), true).await?; + self.db.set_global_rate_materialized(account_id, &derived.event_category, &derived.task_key, true); + materialized.push(derived); + } + Ok(materialized) + } + + fn advance_nonce(&mut self, account: &Address, expected: u64) -> Result<(), CostsError> { + let next = expected.checked_add(1).ok_or(CostsError::NonceOverflow)?; + self.db.set_nonce(account, next); + Ok(()) + } + + async fn apply_spend_batch( + &mut self, + records: &[crate::SpendRecordV1], + writer: &Address, + ) -> Result, CostsError> { + if records.is_empty() || records.len() > MAX_SPEND_RECORDS_PER_BATCH { + return Err(CostsError::BatchTooLarge { + actual: records.len(), + maximum: MAX_SPEND_RECORDS_PER_BATCH, + }); + } + + let mut accounts = BTreeMap::::new(); + let mut unseen_events = BTreeMap::::new(); + let mut new_records = Vec::new(); + + for record in records { + validate_identifier(&record.event_id, "event_id")?; + validate_identifier(&record.account_id, "account_id")?; + validate_identifier(&record.event_category, "event_category")?; + if record.credits == 0 { + return Err(CostsError::InvalidField { field: "credits" }); + } + let fingerprint = spend_fingerprint(record); + if let Some(existing) = self.db.event_fingerprint(&record.event_id).await? { + if existing != fingerprint { + return Err(CostsError::IdempotencyConflict { + reference: record.event_id.clone(), + }); + } + continue; + } + if let Some(existing) = unseen_events.insert(record.event_id.clone(), fingerprint.clone()) { + if existing != fingerprint { + return Err(CostsError::IdempotencyConflict { + reference: record.event_id.clone(), + }); + } + continue; + } + + self.require_writer_for_account(WriterRole::Ingest, &record.account_id, writer).await?; + + self.verify_pinned_rate(record).await?; + + let account = match accounts.get(&record.account_id) { + Some(account) => account.clone(), + None => self.require_account(&record.account_id).await?, + }; + if account.status == AccountStatus::Suspended { + return Err(CostsError::AccountSuspended { + account_id: record.account_id.clone(), + }); + } + let available = account.available_credits; + let next = available.checked_sub(record.credits).ok_or_else(|| { + CostsError::InsufficientCredits { + account_id: record.account_id.clone(), + available, + required: record.credits, + } + })?; + accounts.insert( + record.account_id.clone(), + CreditAccount { + available_credits: next, + ..account + }, + ); + new_records.push(record); + } + + for (account_id, account) in accounts { + self.db.set_account(&account_id, account); + } + let applied_records = new_records.iter().map(|record| (*record).clone()).collect(); + for record in new_records { + self.db.mark_event(&record.event_id, &spend_fingerprint(record)); + } + Ok(applied_records) + } +} + +fn validate_identifier(value: &str, field: &'static str) -> Result<(), CostsError> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':' | b'/')) + { + return Err(CostsError::InvalidField { field }); + } + Ok(()) +} + +fn stored_value_error(error: crate::StoredValueError) -> CostsError { + CostsError::Storage(format!("stored value: {error}")) +} + +fn empty_mutation(transaction_id: &str, kind: LedgerMutationKind) -> LedgerMutationV1 { + LedgerMutationV1 { + sequence: 0, + transaction_id: transaction_id.to_string(), + kind, + account_id: String::new(), + has_account: false, + account: CreditAccount::active(), + cohort_ref: String::new(), + source_ref: String::new(), + balance_direction: BalanceMutationDirection::None, + credit_delta: 0, + period_ref: String::new(), + reason_code: String::new(), + occurred_at: 0, + approval_ref: String::new(), + audit_ref: String::new(), + has_reservation: false, + reservation: Reservation { + reservation_id: String::new(), account_id: String::new(), lineage_ref: String::new(), quote: QuoteV1 { quote_id: String::new(), snapshot_hash: String::new(), account_id: String::new(), event_category: String::new(), task_key: String::new(), quoted_at: 0, credits_per_unit: 0, quantity: 0, total_credits: 0, policy_version: String::new(), rate_version: String::new(), expires_at: 0 }, credits: 0, + expires_at: 0, status: ReservationStatus::Active, + }, + rate_change_set_id: String::new(), + has_rate: false, + rate: crate::RateCardEntry { + account_id: String::new(), event_category: String::new(), task_key: String::new(), + credits: 0, effective_at: 0, expires_at: 0, + policy_version: String::new(), rate_version: String::new(), + }, + has_untracked_source: false, + untracked_source: UntrackedSourceV1 { + source_id: String::new(), reason_code: String::new(), owner_ref: String::new(), + period_ref: String::new(), provenance_ref: String::new(), coverage_code: String::new(), + confidence_code: String::new(), evidence_ref: String::new(), cohort_ref: String::new(), + }, + has_rate_card_completion: false, + rate_card_completion: RateCardCompletionV1 { + change_set_id: String::new(), manifest_hash: String::new(), entry_count: 0, target_count: 0, + activation_epoch: 0, approval_ref: String::new(), audit_ref: String::new(), affected_rates: Vec::new(), + }, + } +} + +fn validate_rate_entries(entries: &[crate::RateCardEntry]) -> Result<(), CostsError> { + if entries.is_empty() || entries.len() > MAX_RATE_ENTRIES_PER_COMMAND { + return Err(CostsError::RateCommandTooLarge { + actual: entries.len(), + maximum: MAX_RATE_ENTRIES_PER_COMMAND, + }); + } + let mut targets = BTreeSet::new(); + for entry in entries { + if !entry.account_id.is_empty() { + validate_identifier(&entry.account_id, "account_id")?; + } + validate_identifier(&entry.event_category, "event_category")?; + if !entry.task_key.is_empty() { + validate_identifier(&entry.task_key, "task_key")?; + } + validate_identifier(&entry.policy_version, "policy_version")?; + validate_identifier(&entry.rate_version, "rate_version")?; + if entry.credits == 0 { + return Err(CostsError::InvalidField { field: "credits" }); + } + if entry.expires_at != 0 && entry.expires_at <= entry.effective_at { + return Err(CostsError::InvalidField { field: "expires_at" }); + } + let target = format!( + "{}\u{0}{}\u{0}{}", + entry.account_id, entry.event_category, entry.task_key + ); + if !targets.insert(target) { + return Err(CostsError::RateChangeSetConflict { + change_set_id: "duplicate_rate_target".to_string(), + }); + } + } + Ok(()) +} + +fn validate_rate_change_set_v1(change_set: &RateCardChangeSetV1) -> Result<(), CostsError> { + validate_identifier(&change_set.change_set_id, "change_set_id")?; validate_identifier(&change_set.manifest_hash, "manifest_hash")?; + validate_identifier(&change_set.approval_ref, "approval_ref")?; validate_identifier(&change_set.audit_ref, "audit_ref")?; + if change_set.activation_epoch == 0 || change_set.expected_entry_count == 0 || change_set.staged_entry_count != 0 || change_set.applied { + return Err(CostsError::InvalidField { field: "activation_epoch_or_entry_count" }); + } + if change_set.expected_target_count as usize != change_set.target_account_ids.len() + || change_set.target_account_ids.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(CostsError::InvalidField { field: "target_account_ids" }); + } + for account_id in &change_set.target_account_ids { validate_identifier(account_id, "target_account_id")?; } + Ok(()) +} + +fn same_rate_change_set_envelope(stored: &RateCardChangeSetV1, supplied: &RateCardChangeSetV1) -> bool { + stored.change_set_id == supplied.change_set_id + && stored.expected_entry_count == supplied.expected_entry_count + && stored.target_account_ids == supplied.target_account_ids + && stored.expected_target_count == supplied.expected_target_count + && stored.manifest_hash == supplied.manifest_hash + && stored.approval_ref == supplied.approval_ref + && stored.audit_ref == supplied.audit_ref + && stored.activation_epoch == supplied.activation_epoch +} + +fn validate_reservation_metadata(metadata: &crate::ReservationActionMetadataV1) -> Result<(), CostsError> { + validate_identifier(&metadata.reason_code, "reason_code")?; + validate_identifier(&metadata.approval_ref, "approval_ref")?; + validate_identifier(&metadata.audit_ref, "audit_ref")?; + if metadata.occurred_at == 0 { return Err(CostsError::InvalidField { field: "occurred_at" }); } + Ok(()) +} + +fn validate_quote(quote: &QuoteV1) -> Result<(), CostsError> { + validate_identifier("e.quote_id, "quote_id")?; validate_identifier("e.snapshot_hash, "snapshot_hash")?; validate_identifier("e.account_id, "account_id")?; validate_identifier("e.event_category, "event_category")?; if !quote.task_key.is_empty() { validate_identifier("e.task_key, "task_key")?; } + validate_identifier("e.policy_version, "policy_version")?; validate_identifier("e.rate_version, "rate_version")?; + if quote.quantity == 0 || quote.credits_per_unit == 0 || quote.quoted_at == 0 || quote.expires_at <= quote.quoted_at { return Err(CostsError::InvalidField { field: "quote" }); } + Ok(()) +} + +fn quote_snapshot_hash(quote: &QuoteV1) -> String { + fingerprint(b"nunchi-costs/quote/v1", |buf| { + crate::types::write_identifier("e.account_id, buf); crate::types::write_identifier("e.event_category, buf); crate::types::write_identifier("e.task_key, buf); quote.quoted_at.write(buf); quote.credits_per_unit.write(buf); quote.quantity.write(buf); quote.total_credits.write(buf); crate::types::write_identifier("e.policy_version, buf); crate::types::write_identifier("e.rate_version, buf); quote.expires_at.write(buf); + }) +} + +fn fingerprint(domain: &[u8], write: impl FnOnce(&mut Vec)) -> String { + let mut bytes = Vec::with_capacity(domain.len() + 128); + (domain.len() as u16).write(&mut bytes); + bytes.extend_from_slice(domain); + write(&mut bytes); + Sha256::hash(&bytes).to_string() +} + +fn topup_fingerprint(account_id: &str, credits: u64) -> String { + fingerprint(b"nunchi-costs/topup/v1", |buf| { + crate::types::write_identifier(account_id, buf); + credits.write(buf); + }) +} + +fn adjustment_fingerprint( + kind: AdjustmentKind, + account_id: &str, + credits: u64, + metadata: &AdjustmentMetadata, +) -> String { + fingerprint(b"nunchi-costs/adjustment/v1", |buf| { + kind.write(buf); + crate::types::write_identifier(account_id, buf); + credits.write(buf); + metadata.write(buf); + }) +} + +fn reservation_event_fingerprint( + reservation_id: &str, + event_id: &str, + event_category: &str, +) -> String { + fingerprint(b"nunchi-costs/reservation-settlement/v1", |buf| { + crate::types::write_identifier(reservation_id, buf); + crate::types::write_identifier(event_id, buf); + crate::types::write_identifier(event_category, buf); + }) +} + +fn spend_fingerprint(record: &crate::SpendRecordV1) -> String { + fingerprint(b"nunchi-costs/spend/v1", |buf| record.write(buf)) +} + +fn validate_untracked_source(source: &UntrackedSourceV1) -> Result<(), CostsError> { + validate_identifier(&source.source_id, "source_id")?; + validate_identifier(&source.reason_code, "reason_code")?; + validate_identifier(&source.owner_ref, "owner_ref")?; + validate_identifier(&source.period_ref, "period_ref")?; + validate_identifier(&source.provenance_ref, "provenance_ref")?; + validate_identifier(&source.coverage_code, "coverage_code")?; + validate_identifier(&source.confidence_code, "confidence_code")?; + validate_identifier(&source.evidence_ref, "evidence_ref")?; + if !source.cohort_ref.is_empty() { + validate_identifier(&source.cohort_ref, "cohort_ref")?; + } + Ok(()) +} diff --git a/costs/src/lib.rs b/costs/src/lib.rs new file mode 100644 index 0000000..3f02c11 --- /dev/null +++ b/costs/src/lib.rs @@ -0,0 +1,46 @@ +//! Custodial, account-scoped credit accounting for Nunchi chains. +//! +//! The module is intentionally generic: it manages opaque credit accounts, +//! allowlisted backend writers, credits, and metered spend. Product-specific +//! event decoding and billing rails remain off-chain. + +commonware_macros::stability_scope!(ALPHA { +mod db; +mod genesis; +mod grant; +mod ledger; +mod outcome; +pub mod stored_value; +#[cfg(feature = "rpc")] +pub mod rpc; +mod transaction; +mod types; +#[cfg(test)] +mod tests; + +pub use db::CostsDB; +pub use genesis::CostsGenesis; +pub use grant::{ + campaign_grant_metadata, campaign_grant_ref, credit_grant_op, is_known_grant_reason, + is_non_revenue_grant, periodic_grant_metadata, periodic_grant_ref, ALL_GRANT_REASONS, + REASON_GOODWILL, REASON_INCLUDED_CREDITS, REASON_PROMOTION, REASON_TOPUP, REASON_TRIAL, +}; +pub use ledger::{CostsError, CostsLedger}; +pub use outcome::{FinalizedOutcomeKind, FinalizedOutcomeV1}; +pub use stored_value::{ + CreditGrantV2, CreditLotKind, CreditTopupV2, LotAllocationV1, RefundPaidLotV1, + StoredValueAccountReadV1, StoredValueError, StoredValueFinalityEventV1, + StoredValueFinalityPayloadV1, StoredValueLedger, StoredValueLotReadV1, + StoredValueReservationV1, StoredValueSpendV2, +}; +pub use transaction::{CostsOperation, Transaction, TransactionPayload}; +pub use types::{ + AccountReadV1, AdjustmentKind, AdjustmentMetadata, BalanceMutationDirection, LedgerMutationKind, LedgerMutationV1, + QuoteRequestV1, QuoteV1, RateCardChangeSet, RateCardChangeSetV1, RateCardCompletionV1, RateCardEntry, + Reservation, ReservationActionMetadataV1, ReservationStatus, CreditAccount, AccountProfile, AccountStatus, SpendRecordV1, + StatusChangeMetadataV1, StatusHistoryEntry, UntrackedSourceV1, WriterRole, +}; + +/// Domain separator used for costs transaction signatures and state keys. +pub const COSTS_NAMESPACE: &[u8] = b"_NUNCHI_COSTS"; +}); diff --git a/costs/src/outcome.rs b/costs/src/outcome.rs new file mode 100644 index 0000000..d80f0e8 --- /dev/null +++ b/costs/src/outcome.rs @@ -0,0 +1,292 @@ +use commonware_cryptography::sha256::Digest; + +use crate::{CostsOperation, LedgerMutationKind, LedgerMutationV1, RateCardCompletionV1, Transaction}; + +/// A normalized state-transition result for a post-finality outbox. This is a +/// read-model contract, not a transaction payload and contains no raw source +/// data or client PII. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FinalizedOutcomeV1 { + /// Stable outbox envelope schema; consumers dedupe on `outbox_event_id`. + pub schema_version: u16, + pub outbox_event_id: String, + pub event_type: &'static str, + pub transaction_id: Digest, + /// Hash of the immutable finalized transaction payload envelope. + pub payload_hash: Digest, + pub finalized_at: u64, + pub kind: FinalizedOutcomeKind, + pub account_id: Option, + pub source_ref: Option, + pub rate_change_set_id: Option, + /// Present only for the one durable completion mutation. The finality + /// outbox retains exact affected targets and policy/rate versions so + /// `pricing.rate_card_updated` never depends on reconstructing siblings. + pub rate_card_completion: Option, +} + +/// Machine-readable category used by the finality consumer for idempotent +/// projections and reconciliation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FinalizedOutcomeKind { + AccountRegistered, + AccountOnboarded, + WriterChanged, + CreditsAdded, + SpendRecorded, + AccountStatusChanged, + ReservationCreated, + ReservationReleased, + ReservationExpired, + ReservationSettled, + UntrackedSourceRegistered, + CreditAdjustmentApplied, + RateCardEntriesStaged, + RateCardApplied, + RateCardCompleted, +} + +impl FinalizedOutcomeV1 { + /// Build a publishable event from a persisted ledger mutation after the + /// containing chain transaction is final. A successful idempotent replay + /// writes no mutation, therefore it cannot produce an outbox event. + pub fn from_finalized_mutation( + mutation: &LedgerMutationV1, + transaction_id: Digest, + finalized_at: u64, + ) -> Self { + let kind = FinalizedOutcomeKind::from_mutation_kind(mutation.kind); + Self { + schema_version: 1, + outbox_event_id: format!("ledger:{transaction_id}:{}", mutation.sequence), + event_type: kind.event_type(), + transaction_id, + payload_hash: transaction_id, + finalized_at, + kind, + account_id: (!mutation.account_id.is_empty()).then(|| mutation.account_id.clone()), + source_ref: (!mutation.source_ref.is_empty()).then(|| mutation.source_ref.clone()), + rate_change_set_id: (!mutation.rate_change_set_id.is_empty()).then(|| mutation.rate_change_set_id.clone()), + rate_card_completion: mutation.has_rate_card_completion.then(|| mutation.rate_card_completion.clone()), + } + } + + /// Derive a finality-safe envelope only after the containing transaction has + /// been finalized by the chain runtime. + #[deprecated(note = "derive outbox events from persisted LedgerMutationV1 after finality")] + pub fn from_finalized_transaction(tx: &Transaction, finalized_at: u64) -> Self { + let (kind, account_id, source_ref, rate_change_set_id) = match &tx.payload.operation { + CostsOperation::RegisterAccount { account_id } => ( + FinalizedOutcomeKind::AccountRegistered, + Some(account_id.clone()), + None, + None, + ), + CostsOperation::CreateAccount { account_id, external_ref, .. } => ( + FinalizedOutcomeKind::AccountOnboarded, + Some(account_id.clone()), + Some(external_ref.clone()), + None, + ), + CostsOperation::SetWriter { .. } => { + (FinalizedOutcomeKind::WriterChanged, None, None, None) + } + CostsOperation::SetAccountWriter { account_id, .. } => { + (FinalizedOutcomeKind::WriterChanged, Some(account_id.clone()), None, None) + } + CostsOperation::RotateAdmin { .. } => { + (FinalizedOutcomeKind::WriterChanged, None, None, None) + } + CostsOperation::CreditTopup { account_id, rail_ref, .. } => ( + FinalizedOutcomeKind::CreditsAdded, + Some(account_id.clone()), + Some(rail_ref.clone()), + None, + ), + CostsOperation::StoredValueTopupV2 { topup } => ( + FinalizedOutcomeKind::CreditsAdded, + Some(topup.account_id.clone()), + Some(topup.rail_ref.clone()), + None, + ), + CostsOperation::StoredValueGrantV2 { grant } => ( + FinalizedOutcomeKind::CreditAdjustmentApplied, + Some(grant.account_id.clone()), + Some(grant.reference.clone()), + None, + ), + CostsOperation::StoredValueSpendV2 { spend } => ( + FinalizedOutcomeKind::SpendRecorded, + Some(spend.account_id.clone()), + Some(spend.event_id.clone()), + None, + ), + CostsOperation::RefundPaidLotV1 { refund } => ( + FinalizedOutcomeKind::CreditAdjustmentApplied, + Some(refund.account_id.clone()), + Some(refund.refund_rail_ref.clone()), + None, + ), + CostsOperation::ReserveStoredValueV2 { reservation_id, account_id, .. } => ( + FinalizedOutcomeKind::ReservationCreated, + Some(account_id.clone()), + Some(reservation_id.clone()), + None, + ), + CostsOperation::ReleaseStoredValueReservationV2 { reservation_id, .. } => ( + FinalizedOutcomeKind::ReservationReleased, + None, + Some(reservation_id.clone()), + None, + ), + CostsOperation::ExpireStoredValueReservationV2 { reservation_id, .. } => ( + FinalizedOutcomeKind::ReservationExpired, + None, + Some(reservation_id.clone()), + None, + ), + CostsOperation::SettleStoredValueReservationV2 { reservation_id, spend } => ( + FinalizedOutcomeKind::ReservationSettled, + Some(spend.account_id.clone()), + Some(format!("{reservation_id}:{}", spend.event_id)), + None, + ), + CostsOperation::CreditGrant { account_id, grant_ref, .. } => ( + FinalizedOutcomeKind::CreditsAdded, + Some(account_id.clone()), + Some(grant_ref.clone()), + None, + ), + CostsOperation::CreditReversal { account_id, reversal_ref, .. } => ( + FinalizedOutcomeKind::CreditsAdded, + Some(account_id.clone()), + Some(reversal_ref.clone()), + None, + ), + CostsOperation::RecordSpendBatch { .. } => { + (FinalizedOutcomeKind::SpendRecorded, None, None, None) + } + CostsOperation::SetAccountStatus { account_id, .. } => ( + FinalizedOutcomeKind::AccountStatusChanged, + Some(account_id.clone()), + None, + None, + ), + CostsOperation::SetAccountStatusV1 { account_id, .. } => (FinalizedOutcomeKind::AccountStatusChanged, Some(account_id.clone()), None, None), + CostsOperation::ReserveCredits { reservation_id, account_id, .. } => ( + FinalizedOutcomeKind::ReservationCreated, + Some(account_id.clone()), + Some(reservation_id.clone()), + None, + ), + CostsOperation::ReserveCreditsV1 { reservation_id, quote, .. } => (FinalizedOutcomeKind::ReservationCreated, Some(quote.account_id.clone()), Some(reservation_id.clone()), None), + CostsOperation::ReleaseReservation { reservation_id } => ( + FinalizedOutcomeKind::ReservationReleased, + None, + Some(reservation_id.clone()), + None, + ), + CostsOperation::ExpireReservation { reservation_id, .. } => ( + FinalizedOutcomeKind::ReservationExpired, + None, + Some(reservation_id.clone()), + None, + ), + CostsOperation::ReleaseReservationV1 { reservation_id, .. } => ( + FinalizedOutcomeKind::ReservationReleased, None, Some(reservation_id.clone()), None, + ), + CostsOperation::ExpireReservationV1 { reservation_id, .. } => ( + FinalizedOutcomeKind::ReservationExpired, None, Some(reservation_id.clone()), None, + ), + CostsOperation::SettleSpend { reservation_id, event_id, .. } => ( + FinalizedOutcomeKind::ReservationSettled, + None, + Some(format!("{reservation_id}:{event_id}")), + None, + ), + CostsOperation::SettleSpendV1 { reservation_id, event_id, .. } => (FinalizedOutcomeKind::ReservationSettled, None, Some(format!("{reservation_id}:{event_id}")), None), + CostsOperation::RegisterUntrackedSource { source_id, .. } => ( + FinalizedOutcomeKind::UntrackedSourceRegistered, + None, + Some(source_id.clone()), + None, + ), + CostsOperation::RegisterUntrackedSourceV1 { source } => ( + FinalizedOutcomeKind::UntrackedSourceRegistered, + None, + Some(source.source_id.clone()), + None, + ), + CostsOperation::CreditAdjustmentV1 { account_id, metadata, .. } => ( + FinalizedOutcomeKind::CreditAdjustmentApplied, + Some(account_id.clone()), + Some(metadata.reference.clone()), + None, + ), + CostsOperation::StageRateCardEntries { change_set_id, .. } => ( + FinalizedOutcomeKind::RateCardEntriesStaged, + None, + None, + Some(change_set_id.clone()), + ), + CostsOperation::ApplyRateCardChangeSet { change_set_id, .. } => ( + FinalizedOutcomeKind::RateCardApplied, + None, + None, + Some(change_set_id.clone()), + ), + CostsOperation::StageRateCardChangeSetV1 { change_set, .. } => (FinalizedOutcomeKind::RateCardEntriesStaged, None, Some(change_set.approval_ref.clone()), Some(change_set.change_set_id.clone())), + CostsOperation::ApplyRateCardChangeSetV1 { change_set, .. } => (FinalizedOutcomeKind::RateCardApplied, None, Some(change_set.approval_ref.clone()), Some(change_set.change_set_id.clone())), + }; + let transaction_id = tx.digest(); + Self { + schema_version: 1, + outbox_event_id: format!("ledger:{transaction_id}"), + event_type: kind.event_type(), + transaction_id, + payload_hash: transaction_id, + finalized_at, + kind, + account_id, + source_ref, + rate_change_set_id, + rate_card_completion: None, + } + } +} + +impl FinalizedOutcomeKind { + pub const fn from_mutation_kind(kind: LedgerMutationKind) -> Self { + match kind { + LedgerMutationKind::AccountOnboarded => Self::AccountOnboarded, + LedgerMutationKind::BalanceChanged => Self::CreditAdjustmentApplied, + LedgerMutationKind::SpendRecorded => Self::SpendRecorded, + LedgerMutationKind::AccountStatusChanged => Self::AccountStatusChanged, + LedgerMutationKind::ReservationChanged => Self::ReservationCreated, + LedgerMutationKind::RateCardStaged => Self::RateCardEntriesStaged, + LedgerMutationKind::RateCardApplied => Self::RateCardApplied, + LedgerMutationKind::RateCardGlobalApplied => Self::RateCardApplied, + LedgerMutationKind::RateCardCompleted => Self::RateCardCompleted, + LedgerMutationKind::UntrackedSourceRegistered => Self::UntrackedSourceRegistered, + } + } + /// Stable event-bus type for post-finality consumers. This output cannot + /// authorize a debit and is intentionally distinct from chain payloads. + pub const fn event_type(self) -> &'static str { + match self { + Self::AccountRegistered | Self::AccountOnboarded => "ledger.account_registered", + Self::WriterChanged => "ledger.writer_changed", + Self::CreditsAdded | Self::CreditAdjustmentApplied => "ledger.balance_changed", + Self::SpendRecorded => "ledger.spend_recorded", + Self::AccountStatusChanged => "ledger.account_status_changed", + Self::ReservationCreated => "ledger.reservation_created", + Self::ReservationReleased => "ledger.reservation_released", + Self::ReservationExpired => "ledger.reservation_expired", + Self::ReservationSettled => "ledger.reservation_settled", + Self::UntrackedSourceRegistered => "ledger.untracked_source_registered", + Self::RateCardEntriesStaged => "ledger.rate_card_staged", + Self::RateCardApplied => "ledger.rate_card_applied", + Self::RateCardCompleted => "pricing.rate_card_updated", + } + } +} diff --git a/costs/src/rpc.rs b/costs/src/rpc.rs new file mode 100644 index 0000000..effa273 --- /dev/null +++ b/costs/src/rpc.rs @@ -0,0 +1,74 @@ +//! Bounded, server-only read surface for authorized costs projections. +//! +//! This trait intentionally exposes no transaction, signer, writer, or +//! mutation capability. A BFF must authorize a account before calling it. + +use jsonrpsee::{core::{async_trait, RegisterMethodError, RpcResult}, proc_macros::rpc}; +use nunchi_rpc::{module_error, RpcRouter}; +use serde::{Deserialize, Serialize}; + +use crate::{AccountReadV1, CostsError, LedgerMutationV1, QuoteV1, RateCardEntry, Reservation, AccountProfile}; + +pub const MAX_READ_PAGE: u16 = 100; + +#[async_trait] +pub trait CostsQuery: Clone + Send + Sync + 'static { + async fn account_read(&self, account_id: String) -> Result, CostsError>; + async fn profile(&self, account_id: String) -> Result, CostsError>; + async fn quote(&self, account_id: String, event_category: String, task_key: String, quantity: u64, quoted_at: u64, expires_at: u64) -> Result, CostsError>; + async fn reservation(&self, reservation_id: String) -> Result, CostsError>; + async fn journal(&self, from_sequence: u64, limit: u16) -> Result, CostsError>; + async fn active_rate(&self, account_id: String, event_category: String, task_key: String, price_at: u64) -> Result, CostsError>; +} + +#[derive(Clone)] +pub struct CostsRpc { query: Q } +impl CostsRpc { pub fn new(query: Q) -> Self { Self { query } } } + +#[rpc(server, namespace = "costs", namespace_separator = ".")] +pub trait Costs { + #[method(name = "account_read", param_kind = map)] + async fn account_read(&self, account_id: String) -> RpcResult>; + #[method(name = "profile", param_kind = map)] + async fn profile(&self, account_id: String) -> RpcResult>; + #[method(name = "quote", param_kind = map)] + async fn quote(&self, account_id: String, event_category: String, task_key: String, quantity: u64, quoted_at: u64, expires_at: u64) -> RpcResult>; + #[method(name = "reservation", param_kind = map)] + async fn reservation(&self, reservation_id: String) -> RpcResult>; + #[method(name = "journal", param_kind = map)] + async fn journal(&self, from_sequence: u64, limit: u16) -> RpcResult>; + #[method(name = "active_rate", param_kind = map)] + async fn active_rate(&self, account_id: String, event_category: String, task_key: String, price_at: u64) -> RpcResult>; +} + +#[async_trait] +impl CostsServer for CostsRpc { + async fn account_read(&self, account_id: String) -> RpcResult> { Ok(self.query.account_read(account_id).await.map_err(rpc_error)?.map(Into::into)) } + async fn profile(&self, account_id: String) -> RpcResult> { Ok(self.query.profile(account_id).await.map_err(rpc_error)?.map(Into::into)) } + async fn quote(&self, account_id: String, event_category: String, task_key: String, quantity: u64, quoted_at: u64, expires_at: u64) -> RpcResult> { Ok(self.query.quote(account_id, event_category, task_key, quantity, quoted_at, expires_at).await.map_err(rpc_error)?.map(Into::into)) } + async fn reservation(&self, reservation_id: String) -> RpcResult> { Ok(self.query.reservation(reservation_id).await.map_err(rpc_error)?.map(Into::into)) } + async fn journal(&self, from_sequence: u64, limit: u16) -> RpcResult> { if limit == 0 || limit > MAX_READ_PAGE { return Err(module_error("invalid journal limit")); } Ok(self.query.journal(from_sequence, limit).await.map_err(rpc_error)?.into_iter().map(Into::into).collect()) } + async fn active_rate(&self, account_id: String, event_category: String, task_key: String, price_at: u64) -> RpcResult> { Ok(self.query.active_rate(account_id, event_category, task_key, price_at).await.map_err(rpc_error)?.map(Into::into)) } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct AccountReadResponse { pub status: String, pub available_credits: u64, pub reserved_credits: u64, pub policy_ref: String, pub cohort_ref: String } +impl From for AccountReadResponse { fn from(v: AccountReadV1) -> Self { Self { status: format!("{:?}", v.account.status).to_lowercase(), available_credits: v.account.available_credits, reserved_credits: v.account.reserved_credits, policy_ref: v.profile.policy_ref, cohort_ref: v.profile.cohort_ref } } } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ProfileResponse { pub account_id: String, pub external_ref: String, pub policy_ref: String, pub cohort_ref: String, pub created_at: u64, pub status_reason: String, pub status_changed_at: u64 } +impl From for ProfileResponse { fn from(v: AccountProfile) -> Self { Self { account_id: v.account_id, external_ref: v.external_ref, policy_ref: v.policy_ref, cohort_ref: v.cohort_ref, created_at: v.created_at, status_reason: v.status_reason, status_changed_at: v.status_changed_at } } } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct QuoteResponse { pub quote_id: String, pub snapshot_hash: String, pub account_id: String, pub event_category: String, pub task_key: String, pub total_credits: u64, pub quantity: u64, pub expires_at: u64, pub policy_version: String, pub rate_version: String } +impl From for QuoteResponse { fn from(v: QuoteV1) -> Self { Self { quote_id: v.quote_id, snapshot_hash: v.snapshot_hash, account_id: v.account_id, event_category: v.event_category, task_key: v.task_key, total_credits: v.total_credits, quantity: v.quantity, expires_at: v.expires_at, policy_version: v.policy_version, rate_version: v.rate_version } } } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ReservationResponse { pub reservation_id: String, pub account_id: String, pub quote_id: String, pub snapshot_hash: String, pub lineage_ref: String, pub credits: u64, pub expires_at: u64, pub status: String } +impl From for ReservationResponse { fn from(v: Reservation) -> Self { Self { reservation_id: v.reservation_id, account_id: v.account_id, quote_id: v.quote.quote_id, snapshot_hash: v.quote.snapshot_hash, lineage_ref: v.lineage_ref, credits: v.credits, expires_at: v.expires_at, status: format!("{:?}", v.status).to_lowercase() } } } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RateResponse { pub account_id: String, pub event_category: String, pub task_key: String, pub credits: u64, pub effective_at: u64, pub expires_at: u64, pub policy_version: String, pub rate_version: String } +impl From for RateResponse { fn from(v: RateCardEntry) -> Self { Self { account_id: v.account_id, event_category: v.event_category, task_key: v.task_key, credits: v.credits, effective_at: v.effective_at, expires_at: v.expires_at, policy_version: v.policy_version, rate_version: v.rate_version } } } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct JournalResponse { pub sequence: u64, pub transaction_id: String, pub kind: String, pub account_id: String, pub reason_code: String, pub occurred_at: u64, pub approval_ref: String, pub audit_ref: String } +impl From for JournalResponse { fn from(v: LedgerMutationV1) -> Self { Self { sequence: v.sequence, transaction_id: v.transaction_id, kind: format!("{:?}", v.kind), account_id: v.account_id, reason_code: v.reason_code, occurred_at: v.occurred_at, approval_ref: v.approval_ref, audit_ref: v.audit_ref } } } + +pub fn register(router: &mut RpcRouter, rpc: CostsRpc) -> Result<(), RegisterMethodError> where Q: CostsQuery { router.merge(rpc.into_rpc()) } +fn rpc_error(error: CostsError) -> jsonrpsee::types::ErrorObjectOwned { module_error(error.to_string()) } diff --git a/costs/src/stored_value.rs b/costs/src/stored_value.rs new file mode 100644 index 0000000..61e419c --- /dev/null +++ b/costs/src/stored_value.rs @@ -0,0 +1,245 @@ +//! Clean-state V2 provenance ledger for refundable B2B stored value. +//! +//! Paid lots refund only to their original rail; grant lots are non-refundable; +//! spend deterministically consumes grants before paid credit. + +use std::collections::BTreeMap; +use commonware_codec::{EncodeSize, Error as CodecError, Read, ReadExt, Write}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use crate::types::{identifier_encode_size, read_identifier, write_identifier}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub enum CreditLotKind { Grant, Paid } + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct CreditLotV2 { lot_id: String, account_id: String, kind: CreditLotKind, remaining: u64, reserved: u64, issued_at: u64, expires_at: u64, period_ref: String, amount_usd_cents: u64, base_credits: u64, bonus_credits: u64, terms_version: String, reason_code: String } + +/// Immutable paid purchase. `rail_ref` is both idempotency key and lot ID. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CreditTopupV2 { pub account_id: String, pub rail_ref: String, pub amount_usd_cents: u64, pub base_credits: u64, pub bonus_credits: u64, pub purchased_at: u64, pub terms_version: String } + +/// Immutable non-refundable periodic program/campaign grant with a policy-defined expiry. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CreditGrantV2 { pub account_id: String, pub reference: String, pub credits: u64, pub reason_code: String, pub period_ref: String, pub issued_at: u64, pub expires_at: u64, pub approval_ref: String, pub audit_ref: String } + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct StoredValueSpendV2 { pub event_id: String, pub account_id: String, pub credits: u64, pub occurred_at: u64 } + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LotAllocationV1 { pub lot_id: String, pub kind: CreditLotKind, pub credits: u64 } + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum StoredValueReservationStatus { Active, Released, Settled, Expired } + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct StoredValueReservationV1 { pub reservation_id: String, pub account_id: String, pub credits: u64, pub expires_at: u64, pub allocations: Vec, pub status: StoredValueReservationStatus } + +/// Support-mediated reversal of unused paid credit to the original rail. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RefundPaidLotV1 { pub account_id: String, pub rail_ref: String, pub refund_rail_ref: String, pub credits: u64, pub requested_at: u64, pub reason_code: String, pub approval_ref: String, pub audit_ref: String } + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct StoredValueAccountReadV1 { pub account_id: String, pub paid_available_credits: u64, pub grant_available_credits: u64, pub reserved_credits: u64, pub refundable_paid_credits: u64, pub included_period_consumed: u64, pub reset_at: u64 } + +/// Immutable ledger payload retained in chain state for a finality adapter. +/// It is never a transaction input and is published only after the containing +/// transaction reaches chain finality. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum StoredValueFinalityPayloadV1 { + Topup(CreditTopupV2), + Grant(CreditGrantV2), + Spend { spend: StoredValueSpendV2, allocations: Vec }, + Refund(RefundPaidLotV1), +} + +/// Append-only bridge from the V2 chain ledger to post-finality downstream warehouse, accounting export, +/// and client-safe projections. This is state output, not a chain command. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct StoredValueFinalityEventV1 { + pub sequence: u64, + pub event_key: String, + pub transaction_id: String, + pub account_id: String, + pub payload: StoredValueFinalityPayloadV1, +} + +/// Private BFF/sink lot history. It contains no payment credentials or PII. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoredValueLotReadV1 { pub lot_id: String, pub account_id: String, pub kind: CreditLotKind, pub amount_usd_cents: u64, pub base_credits: u64, pub bonus_credits: u64, pub remaining_credits: u64, pub reserved_credits: u64, pub issued_at: u64, pub expires_at: u64, pub terms_version: String, pub reason_code: String, pub period_ref: String } + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum StoredValueError { + #[error("invalid {0}")] Invalid(&'static str), + #[error("account {0} was not initialized for stored value")] AccountNotFound(String), + #[error("idempotency conflict for {0}")] IdempotencyConflict(String), + #[error("insufficient credits: available {available}, required {required}")] InsufficientCredits { available: u64, required: u64 }, + #[error("paid lot {0} was not found")] PaidLotNotFound(String), + #[error("paid lot {0} has reserved credits")] PaidLotReserved(String), + #[error("refund {refund_id} exceeds unused paid credit in {rail_ref}")] RefundExceedsUnused { refund_id: String, rail_ref: String }, + #[error("reservation {0} was not found")] ReservationNotFound(String), + #[error("reservation {0} is not active")] ReservationNotActive(String), + #[error("reservation {0} expired")] ReservationExpired(String), + #[error("arithmetic overflow")] Overflow, +} + +/// Deterministic V2 economic state. Network, signer, downstream warehouse, and UI adapters are +/// deliberately outside this state machine. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct StoredValueLedger { + accounts: BTreeMap, lots: BTreeMap, + spends: BTreeMap)>, + reservations: BTreeMap, + refunds: BTreeMap, consumed_by_lot: BTreeMap, + finality_events: Vec, +} + +impl Write for CreditTopupV2 { fn write(&self, buf: &mut impl bytes::BufMut) { write_identifier(&self.account_id, buf); write_identifier(&self.rail_ref, buf); self.amount_usd_cents.write(buf); self.base_credits.write(buf); self.bonus_credits.write(buf); self.purchased_at.write(buf); write_identifier(&self.terms_version, buf); } } +impl Read for CreditTopupV2 { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { account_id: read_identifier(buf)?, rail_ref: read_identifier(buf)?, amount_usd_cents: u64::read(buf)?, base_credits: u64::read(buf)?, bonus_credits: u64::read(buf)?, purchased_at: u64::read(buf)?, terms_version: read_identifier(buf)? }) } } +impl EncodeSize for CreditTopupV2 { fn encode_size(&self) -> usize { identifier_encode_size(&self.account_id) + identifier_encode_size(&self.rail_ref) + self.amount_usd_cents.encode_size() + self.base_credits.encode_size() + self.bonus_credits.encode_size() + self.purchased_at.encode_size() + identifier_encode_size(&self.terms_version) } } + +impl Write for CreditGrantV2 { fn write(&self, buf: &mut impl bytes::BufMut) { write_identifier(&self.account_id, buf); write_identifier(&self.reference, buf); self.credits.write(buf); write_identifier(&self.reason_code, buf); write_identifier(&self.period_ref, buf); self.issued_at.write(buf); self.expires_at.write(buf); write_identifier(&self.approval_ref, buf); write_identifier(&self.audit_ref, buf); } } +impl Read for CreditGrantV2 { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { account_id: read_identifier(buf)?, reference: read_identifier(buf)?, credits: u64::read(buf)?, reason_code: read_identifier(buf)?, period_ref: read_identifier(buf)?, issued_at: u64::read(buf)?, expires_at: u64::read(buf)?, approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)? }) } } +impl EncodeSize for CreditGrantV2 { fn encode_size(&self) -> usize { identifier_encode_size(&self.account_id) + identifier_encode_size(&self.reference) + self.credits.encode_size() + identifier_encode_size(&self.reason_code) + identifier_encode_size(&self.period_ref) + self.issued_at.encode_size() + self.expires_at.encode_size() + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref) } } + +impl Write for StoredValueSpendV2 { fn write(&self, buf: &mut impl bytes::BufMut) { write_identifier(&self.event_id, buf); write_identifier(&self.account_id, buf); self.credits.write(buf); self.occurred_at.write(buf); } } +impl Read for StoredValueSpendV2 { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { event_id: read_identifier(buf)?, account_id: read_identifier(buf)?, credits: u64::read(buf)?, occurred_at: u64::read(buf)? }) } } +impl EncodeSize for StoredValueSpendV2 { fn encode_size(&self) -> usize { identifier_encode_size(&self.event_id) + identifier_encode_size(&self.account_id) + self.credits.encode_size() + self.occurred_at.encode_size() } } + +impl Write for RefundPaidLotV1 { fn write(&self, buf: &mut impl bytes::BufMut) { write_identifier(&self.account_id, buf); write_identifier(&self.rail_ref, buf); write_identifier(&self.refund_rail_ref, buf); self.credits.write(buf); self.requested_at.write(buf); write_identifier(&self.reason_code, buf); write_identifier(&self.approval_ref, buf); write_identifier(&self.audit_ref, buf); } } +impl Read for RefundPaidLotV1 { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { Ok(Self { account_id: read_identifier(buf)?, rail_ref: read_identifier(buf)?, refund_rail_ref: read_identifier(buf)?, credits: u64::read(buf)?, requested_at: u64::read(buf)?, reason_code: read_identifier(buf)?, approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)? }) } } +impl EncodeSize for RefundPaidLotV1 { fn encode_size(&self) -> usize { identifier_encode_size(&self.account_id) + identifier_encode_size(&self.rail_ref) + identifier_encode_size(&self.refund_rail_ref) + self.credits.encode_size() + self.requested_at.encode_size() + identifier_encode_size(&self.reason_code) + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref) } } + +impl StoredValueLedger { + pub fn onboard(&mut self, account_id: &str) -> Result<(), StoredValueError> { validate(account_id, "account_id")?; self.accounts.insert(account_id.into(), ()); Ok(()) } + + pub fn credit_topup(&mut self, topup: CreditTopupV2) -> Result<(), StoredValueError> { + self.require_account(&topup.account_id)?; validate(&topup.rail_ref, "rail_ref")?; validate(&topup.terms_version, "terms_version")?; + if topup.amount_usd_cents == 0 || topup.purchased_at == 0 { return Err(StoredValueError::Invalid("purchase_amount_or_time")); } + let total = topup.base_credits.checked_add(topup.bonus_credits).ok_or(StoredValueError::Overflow)?; + if total == 0 { return Err(StoredValueError::Invalid("total_credits")); } + self.insert_lot(topup.rail_ref.clone(), CreditLotV2 { lot_id: topup.rail_ref, account_id: topup.account_id, kind: CreditLotKind::Paid, remaining: total, reserved: 0, issued_at: topup.purchased_at, expires_at: 0, period_ref: String::new(), amount_usd_cents: topup.amount_usd_cents, base_credits: topup.base_credits, bonus_credits: topup.bonus_credits, terms_version: topup.terms_version, reason_code: "topup".into() }) + } + + pub fn credit_grant(&mut self, grant: CreditGrantV2) -> Result<(), StoredValueError> { + self.require_account(&grant.account_id)?; + for (value, field) in [(&grant.reference, "grant_reference"), (&grant.reason_code, "grant_reason"), (&grant.period_ref, "grant_period"), (&grant.approval_ref, "approval_ref"), (&grant.audit_ref, "audit_ref")] { validate(value, field)?; } + if grant.credits == 0 || grant.issued_at == 0 || grant.expires_at <= grant.issued_at { return Err(StoredValueError::Invalid("grant_credits_or_expiry")); } + self.insert_lot(grant.reference.clone(), CreditLotV2 { lot_id: grant.reference, account_id: grant.account_id, kind: CreditLotKind::Grant, remaining: grant.credits, reserved: 0, issued_at: grant.issued_at, expires_at: grant.expires_at, period_ref: grant.period_ref, amount_usd_cents: 0, base_credits: grant.credits, bonus_credits: 0, terms_version: String::new(), reason_code: grant.reason_code }) + } + + pub fn record_spend(&mut self, spend: StoredValueSpendV2) -> Result, StoredValueError> { + self.require_account(&spend.account_id)?; validate(&spend.event_id, "event_id")?; + if spend.credits == 0 || spend.occurred_at == 0 { return Err(StoredValueError::Invalid("spend_credits_or_time")); } + if let Some((existing, allocations)) = self.spends.get(&spend.event_id) { return if existing == &spend { Ok(allocations.clone()) } else { Err(StoredValueError::IdempotencyConflict(spend.event_id)) }; } + let allocations = self.allocate(&spend.account_id, spend.credits, spend.occurred_at)?; + self.consume(&allocations)?; self.spends.insert(spend.event_id.clone(), (spend, allocations.clone())); Ok(allocations) + } + + pub fn reserve(&mut self, reservation_id: &str, account_id: &str, credits: u64, expires_at: u64, now: u64) -> Result { + self.require_account(account_id)?; validate(reservation_id, "reservation_id")?; + if credits == 0 || expires_at <= now { return Err(StoredValueError::Invalid("reservation_credits_or_expiry")); } + if let Some(existing) = self.reservations.get(reservation_id) { return if existing.account_id == account_id && existing.credits == credits && existing.expires_at == expires_at { Ok(existing.clone()) } else { Err(StoredValueError::IdempotencyConflict(reservation_id.into())) }; } + let allocations = self.allocate(account_id, credits, now)?; + for allocation in &allocations { let lot = self.lots.get_mut(&allocation.lot_id).expect("allocated lot"); lot.remaining -= allocation.credits; lot.reserved += allocation.credits; } + let reservation = StoredValueReservationV1 { reservation_id: reservation_id.into(), account_id: account_id.into(), credits, expires_at, allocations, status: StoredValueReservationStatus::Active }; + self.reservations.insert(reservation_id.into(), reservation.clone()); Ok(reservation) + } + + pub fn release_reservation(&mut self, reservation_id: &str, now: u64) -> Result<(), StoredValueError> { + let reservation = self.reservations.get(reservation_id).cloned().ok_or_else(|| StoredValueError::ReservationNotFound(reservation_id.into()))?; + if reservation.status != StoredValueReservationStatus::Active { return Err(StoredValueError::ReservationNotActive(reservation_id.into())); } + self.return_reserved(&reservation)?; + self.reservations.get_mut(reservation_id).expect("reservation").status = if now >= reservation.expires_at { StoredValueReservationStatus::Expired } else { StoredValueReservationStatus::Released }; Ok(()) + } + + /// Deterministically expire a still-active reservation. A clock caller + /// cannot release a valid hold early by calling the expiry operation. + pub fn expire_reservation(&mut self, reservation_id: &str, now: u64) -> Result<(), StoredValueError> { + let reservation = self.reservations.get(reservation_id).cloned().ok_or_else(|| StoredValueError::ReservationNotFound(reservation_id.into()))?; + if reservation.status == StoredValueReservationStatus::Expired { return Ok(()); } + if reservation.status != StoredValueReservationStatus::Active { return Err(StoredValueError::ReservationNotActive(reservation_id.into())); } + if now < reservation.expires_at { return Err(StoredValueError::ReservationExpired(reservation_id.into())); } + self.return_reserved(&reservation)?; + self.reservations.get_mut(reservation_id).expect("reservation").status = StoredValueReservationStatus::Expired; + Ok(()) + } + + pub fn settle_reservation(&mut self, reservation_id: &str, spend: StoredValueSpendV2) -> Result, StoredValueError> { + let reservation = self.reservations.get(reservation_id).cloned().ok_or_else(|| StoredValueError::ReservationNotFound(reservation_id.into()))?; + if reservation.status != StoredValueReservationStatus::Active { return Err(StoredValueError::ReservationNotActive(reservation_id.into())); } + if spend.account_id != reservation.account_id || spend.credits != reservation.credits { return Err(StoredValueError::Invalid("reservation_spend_terms")); } + if spend.occurred_at >= reservation.expires_at { return Err(StoredValueError::ReservationExpired(reservation_id.into())); } + if let Some((existing, allocations)) = self.spends.get(&spend.event_id) { return if existing == &spend { Ok(allocations.clone()) } else { Err(StoredValueError::IdempotencyConflict(spend.event_id)) }; } + for allocation in &reservation.allocations { let lot = self.lots.get_mut(&allocation.lot_id).expect("reserved lot"); lot.reserved -= allocation.credits; *self.consumed_by_lot.entry(allocation.lot_id.clone()).or_default() += allocation.credits; } + self.spends.insert(spend.event_id.clone(), (spend, reservation.allocations.clone())); self.reservations.get_mut(reservation_id).expect("reservation").status = StoredValueReservationStatus::Settled; Ok(reservation.allocations) + } + + pub fn refund_paid_lot(&mut self, refund: RefundPaidLotV1) -> Result<(), StoredValueError> { + self.require_account(&refund.account_id)?; + for (value, field) in [(&refund.refund_rail_ref, "refund_rail_ref"), (&refund.rail_ref, "rail_ref"), (&refund.reason_code, "refund_reason"), (&refund.approval_ref, "approval_ref"), (&refund.audit_ref, "audit_ref")] { validate(value, field)?; } + if refund.credits == 0 || refund.requested_at == 0 { return Err(StoredValueError::Invalid("refund_credits_or_time")); } + if let Some(existing) = self.refunds.get(&refund.refund_rail_ref) { return if existing == &refund { Ok(()) } else { Err(StoredValueError::IdempotencyConflict(refund.refund_rail_ref)) }; } + let lot = self.lots.get_mut(&refund.rail_ref).ok_or_else(|| StoredValueError::PaidLotNotFound(refund.rail_ref.clone()))?; + if lot.kind != CreditLotKind::Paid || lot.account_id != refund.account_id { return Err(StoredValueError::PaidLotNotFound(refund.rail_ref)); } + if lot.reserved != 0 { return Err(StoredValueError::PaidLotReserved(lot.lot_id.clone())); } + if lot.remaining < refund.credits { return Err(StoredValueError::RefundExceedsUnused { refund_id: refund.refund_rail_ref.clone(), rail_ref: refund.rail_ref.clone() }); } + lot.remaining -= refund.credits; self.refunds.insert(refund.refund_rail_ref.clone(), refund); Ok(()) + } + + pub fn account_read(&self, account_id: &str, now: u64, period_ref: &str, reset_at: u64) -> Result { + self.require_account(account_id)?; let mut paid: u64 = 0; let mut grant: u64 = 0; let mut reserved: u64 = 0; let mut included: u64 = 0; + for lot in self.lots.values().filter(|lot| lot.account_id == account_id) { reserved = reserved.checked_add(lot.reserved).ok_or(StoredValueError::Overflow)?; match lot.kind { CreditLotKind::Paid => paid = paid.checked_add(lot.remaining).ok_or(StoredValueError::Overflow)?, CreditLotKind::Grant if lot.expires_at > now => { grant = grant.checked_add(lot.remaining).ok_or(StoredValueError::Overflow)?; if lot.period_ref == period_ref { included = included.checked_add(*self.consumed_by_lot.get(&lot.lot_id).unwrap_or(&0)).ok_or(StoredValueError::Overflow)?; } }, CreditLotKind::Grant => {} } } + Ok(StoredValueAccountReadV1 { account_id: account_id.into(), paid_available_credits: paid, grant_available_credits: grant, reserved_credits: reserved, refundable_paid_credits: paid, included_period_consumed: included, reset_at }) + } + + pub fn lots_for_account(&self, account_id: &str) -> Result, StoredValueError> { + self.require_account(account_id)?; + Ok(self.lots.values().filter(|lot| lot.account_id == account_id).map(|lot| StoredValueLotReadV1 { lot_id: lot.lot_id.clone(), account_id: lot.account_id.clone(), kind: lot.kind, amount_usd_cents: lot.amount_usd_cents, base_credits: lot.base_credits, bonus_credits: lot.bonus_credits, remaining_credits: lot.remaining, reserved_credits: lot.reserved, issued_at: lot.issued_at, expires_at: lot.expires_at, terms_version: lot.terms_version.clone(), reason_code: lot.reason_code.clone(), period_ref: lot.period_ref.clone() }).collect()) + } + + pub fn reservation(&self, reservation_id: &str) -> Option<&StoredValueReservationV1> { + self.reservations.get(reservation_id) + } + + /// Persist exactly one post-state projection input for an idempotent V2 + /// operation. The caller must publish it only after chain finality. + pub fn append_finality_event(&mut self, event_key: String, transaction_id: String, account_id: String, payload: StoredValueFinalityPayloadV1) -> Result<(), StoredValueError> { + validate(&event_key, "finality_event_key")?; + validate(&transaction_id, "transaction_id")?; + self.require_account(&account_id)?; + if let Some(existing) = self.finality_events.iter().find(|event| event.event_key == event_key) { + if existing.account_id == account_id && existing.payload == payload { return Ok(()); } + return Err(StoredValueError::IdempotencyConflict(event_key)); + } + let sequence = u64::try_from(self.finality_events.len()).map_err(|_| StoredValueError::Overflow)?; + self.finality_events.push(StoredValueFinalityEventV1 { sequence, event_key, transaction_id, account_id, payload }); + Ok(()) + } + + pub fn finality_events(&self, from_sequence: u64, limit: u16) -> Vec { + let end = from_sequence.saturating_add(u64::from(limit)).min(self.finality_events.len() as u64); + self.finality_events[from_sequence as usize..end as usize].to_vec() + } + + fn insert_lot(&mut self, key: String, lot: CreditLotV2) -> Result<(), StoredValueError> { match self.lots.get(&key) { None => { self.lots.insert(key, lot); Ok(()) }, Some(existing) if existing == &lot => Ok(()), Some(_) => Err(StoredValueError::IdempotencyConflict(key)) } } + fn allocate(&self, account_id: &str, credits: u64, now: u64) -> Result, StoredValueError> { let mut lots = self.lots.values().filter(|lot| lot.account_id == account_id && (lot.kind == CreditLotKind::Paid || lot.expires_at > now)).collect::>(); lots.sort_by_key(|lot| (lot.kind, if lot.expires_at == 0 { u64::MAX } else { lot.expires_at }, lot.issued_at, lot.lot_id.clone())); let available = lots.iter().try_fold(0u64, |total, lot| total.checked_add(lot.remaining).ok_or(StoredValueError::Overflow))?; if available < credits { return Err(StoredValueError::InsufficientCredits { available, required: credits }); } let mut left = credits; let mut allocations = Vec::new(); for lot in lots { if left == 0 { break; } let used = left.min(lot.remaining); if used > 0 { allocations.push(LotAllocationV1 { lot_id: lot.lot_id.clone(), kind: lot.kind, credits: used }); left -= used; } } Ok(allocations) } + fn consume(&mut self, allocations: &[LotAllocationV1]) -> Result<(), StoredValueError> { for allocation in allocations { let lot = self.lots.get_mut(&allocation.lot_id).expect("allocated lot"); lot.remaining = lot.remaining.checked_sub(allocation.credits).ok_or(StoredValueError::Overflow)?; *self.consumed_by_lot.entry(allocation.lot_id.clone()).or_default() += allocation.credits; } Ok(()) } + fn return_reserved(&mut self, reservation: &StoredValueReservationV1) -> Result<(), StoredValueError> { for allocation in &reservation.allocations { let lot = self.lots.get_mut(&allocation.lot_id).expect("reserved lot"); lot.reserved = lot.reserved.checked_sub(allocation.credits).ok_or(StoredValueError::Overflow)?; lot.remaining = lot.remaining.checked_add(allocation.credits).ok_or(StoredValueError::Overflow)?; } Ok(()) } + fn require_account(&self, account_id: &str) -> Result<(), StoredValueError> { if self.accounts.contains_key(account_id) { Ok(()) } else { Err(StoredValueError::AccountNotFound(account_id.into())) } } +} + +fn validate(value: &str, field: &'static str) -> Result<(), StoredValueError> { if value.is_empty() || value.len() > 128 || !value.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':' | b'/')) { Err(StoredValueError::Invalid(field)) } else { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + fn topup() -> CreditTopupV2 { CreditTopupV2 { account_id: "account_demo".into(), rail_ref: "pi_paid_1".into(), amount_usd_cents: 100_000, base_credits: 8_000, bonus_credits: 888, purchased_at: 100, terms_version: "terms_v1".into() } } + fn grant() -> CreditGrantV2 { CreditGrantV2 { account_id: "account_demo".into(), reference: "grant_account_demo_2026_07".into(), credits: 3_000, reason_code: "included_credits".into(), period_ref: "2026-07".into(), issued_at: 100, expires_at: 200, approval_ref: "policy_auto".into(), audit_ref: "audit_1".into() } } + fn ready() -> StoredValueLedger { let mut ledger = StoredValueLedger::default(); ledger.onboard("account_demo").unwrap(); ledger.credit_topup(topup()).unwrap(); ledger.credit_grant(grant()).unwrap(); ledger } + fn refund(credits: u64, id: &str) -> RefundPaidLotV1 { RefundPaidLotV1 { account_id: "account_demo".into(), rail_ref: "pi_paid_1".into(), refund_rail_ref: id.into(), credits, requested_at: 150, reason_code: "charge.refunded".into(), approval_ref: "support_1".into(), audit_ref: "audit_2".into() } } + #[test] fn grant_first_spend_preserves_paid_value() { let mut ledger = ready(); let allocations = ledger.record_spend(StoredValueSpendV2 { event_id: "event_1".into(), account_id: "account_demo".into(), credits: 3_100, occurred_at: 150 }).unwrap(); assert_eq!(allocations[0].kind, CreditLotKind::Grant); assert_eq!(allocations[1].kind, CreditLotKind::Paid); assert_eq!(ledger.account_read("account_demo", 150, "2026-07", 200).unwrap().refundable_paid_credits, 8_788); } + #[test] fn reservation_prevents_refund_until_release() { let mut ledger = ready(); ledger.record_spend(StoredValueSpendV2 { event_id: "event_1".into(), account_id: "account_demo".into(), credits: 3_000, occurred_at: 150 }).unwrap(); ledger.reserve("res_1", "account_demo", 100, 180, 151).unwrap(); assert!(matches!(ledger.refund_paid_lot(refund(100, "re_1")), Err(StoredValueError::PaidLotReserved(_)))); ledger.release_reservation("res_1", 152).unwrap(); } + #[test] fn partial_refund_is_idempotent_and_lot_bound() { let mut ledger = ready(); ledger.refund_paid_lot(refund(888, "re_1")).unwrap(); ledger.refund_paid_lot(refund(888, "re_1")).unwrap(); assert_eq!(ledger.account_read("account_demo", 150, "2026-07", 200).unwrap().paid_available_credits, 8_000); let mut bad = refund(1, "re_bad"); bad.rail_ref = "grant_account_demo_2026_07".into(); assert!(matches!(ledger.refund_paid_lot(bad), Err(StoredValueError::PaidLotNotFound(_)))); } + #[test] fn expired_grant_does_not_fund_spend() { let mut ledger = ready(); assert!(matches!(ledger.record_spend(StoredValueSpendV2 { event_id: "event_expired".into(), account_id: "account_demo".into(), credits: 9_000, occurred_at: 201 }), Err(StoredValueError::InsufficientCredits { .. }))); } +} diff --git a/costs/src/tests.rs b/costs/src/tests.rs new file mode 100644 index 0000000..3f27b87 --- /dev/null +++ b/costs/src/tests.rs @@ -0,0 +1,1390 @@ +#![allow(clippy::cloned_ref_to_slice_refs)] // keeps rate-card fixture setup readable. + +use std::collections::BTreeMap; + +use commonware_cryptography::sha256::Digest; +use nunchi_common::{Address, StateError, StateStore}; +use nunchi_crypto::PrivateKey; + +use crate::{ + AdjustmentKind, AdjustmentMetadata, CostsDB, CostsError, CostsLedger, CostsOperation, + CreditGrantV2, CreditTopupV2, FinalizedOutcomeKind, FinalizedOutcomeV1, LedgerMutationKind, + RateCardEntry, RefundPaidLotV1, AccountStatus, ReservationActionMetadataV1, SpendRecordV1, + StatusChangeMetadataV1, StoredValueSpendV2, Transaction, UntrackedSourceV1, WriterRole, +}; + +#[derive(Default)] +struct MemoryState { + values: BTreeMap>, +} + +fn canonical_changeset(mut change_set: crate::RateCardChangeSetV1, entries: &[RateCardEntry]) -> crate::RateCardChangeSetV1 { + change_set.manifest_hash = change_set.expected_manifest_hash(entries); + change_set +} + +#[test] +fn stored_value_v2_commands_are_signed_scoped_and_persisted() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(701); + let billing = PrivateKey::ed25519_from_seed(702); + let adjustment = PrivateKey::ed25519_from_seed(703); + let ingest = PrivateKey::ed25519_from_seed(704); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + state.set_writer(WriterRole::Adjustment, &address(&adjustment), true); + state.set_writer(WriterRole::Ingest, &address(&ingest), true); + + { + let mut ledger = CostsLedger::new(&mut state); + ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_stored"))).await.unwrap(); + let topup = CreditTopupV2 { + account_id: "account_stored".to_string(), rail_ref: "pi_stored_1".to_string(), + amount_usd_cents: 10_000, base_credits: 800, bonus_credits: 88, + purchased_at: 100, terms_version: "terms_v1".to_string(), + }; + ledger.apply_transaction(&Transaction::sign(&billing, 0, CostsOperation::StoredValueTopupV2 { topup: topup.clone() })).await.unwrap(); + // An idempotent replay advances only its signer nonce; it cannot + // make a second paid lot. + ledger.apply_transaction(&Transaction::sign(&billing, 1, CostsOperation::StoredValueTopupV2 { topup })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&adjustment, 0, CostsOperation::StoredValueGrantV2 { + grant: CreditGrantV2 { + account_id: "account_stored".to_string(), reference: "grant_account_stored_2026_07".to_string(), + credits: 300, reason_code: "included_credits".to_string(), period_ref: "2026-07".to_string(), + issued_at: 100, expires_at: 200, approval_ref: "approval_grant_1".to_string(), audit_ref: "audit_grant_1".to_string(), + }, + })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::StoredValueSpendV2 { + spend: StoredValueSpendV2 { event_id: "event_stored_1".to_string(), account_id: "account_stored".to_string(), credits: 350, occurred_at: 150 }, + })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&adjustment, 1, CostsOperation::RefundPaidLotV1 { + refund: RefundPaidLotV1 { + account_id: "account_stored".to_string(), rail_ref: "pi_stored_1".to_string(), + refund_rail_ref: "re_stored_1".to_string(), credits: 100, requested_at: 160, + reason_code: "charge.refunded".to_string(), approval_ref: "approval_refund_1".to_string(), audit_ref: "audit_refund_1".to_string(), + }, + })).await.unwrap(); + // V2 is deliberately independent from the legacy aggregate path. + assert_eq!(ledger.account("account_stored").await.unwrap().unwrap().available_credits, 0); + } + + let ledger = CostsLedger::new(&mut state); + let read = ledger.stored_value_account_read("account_stored", 160, "2026-07", 200).await.unwrap().unwrap(); + assert_eq!(read.paid_available_credits, 738); + assert_eq!(read.refundable_paid_credits, 738); + assert_eq!(read.grant_available_credits, 0); + assert_eq!(read.included_period_consumed, 300); + assert_eq!(ledger.stored_value_lots("account_stored").await.unwrap().unwrap().len(), 2); + let events = ledger.stored_value_finality_events(0, 10).await.unwrap(); + assert_eq!(events.len(), 4); + assert_eq!(events[0].event_key, "topup:pi_stored_1"); + assert_eq!(events[2].event_key, "spend:event_stored_1"); + assert_eq!(events[3].event_key, "refund:re_stored_1"); + }); +} + +#[test] +fn stored_value_v2_reservations_hold_lot_allocations_until_settlement() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(711); + let billing = PrivateKey::ed25519_from_seed(712); + let adjustment = PrivateKey::ed25519_from_seed(713); + let ingest = PrivateKey::ed25519_from_seed(714); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + state.set_writer(WriterRole::Adjustment, &address(&adjustment), true); + state.set_writer(WriterRole::Ingest, &address(&ingest), true); + let mut ledger = CostsLedger::new(&mut state); + ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_stored_reservation"))).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&billing, 0, CostsOperation::StoredValueTopupV2 { + topup: CreditTopupV2 { + account_id: "account_stored_reservation".to_string(), rail_ref: "pi_reservation_1".to_string(), + amount_usd_cents: 1_000, base_credits: 100, bonus_credits: 0, + purchased_at: 100, terms_version: "terms_v1".to_string(), + }, + })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&adjustment, 0, CostsOperation::StoredValueGrantV2 { + grant: CreditGrantV2 { + account_id: "account_stored_reservation".to_string(), reference: "grant_reservation_1".to_string(), + credits: 100, reason_code: "included_credits".to_string(), period_ref: "2026-07".to_string(), + issued_at: 100, expires_at: 200, approval_ref: "approval_reservation_1".to_string(), audit_ref: "audit_reservation_1".to_string(), + }, + })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::ReserveStoredValueV2 { + reservation_id: "sv_reservation_1".to_string(), account_id: "account_stored_reservation".to_string(), + credits: 150, expires_at: 200, reserved_at: 110, + })).await.unwrap(); + let refund_while_reserved = ledger.apply_transaction(&Transaction::sign(&adjustment, 1, CostsOperation::RefundPaidLotV1 { + refund: RefundPaidLotV1 { + account_id: "account_stored_reservation".to_string(), rail_ref: "pi_reservation_1".to_string(), + refund_rail_ref: "re_reserved_1".to_string(), credits: 1, requested_at: 111, + reason_code: "charge.refunded".to_string(), approval_ref: "approval_refund_reserved".to_string(), audit_ref: "audit_refund_reserved".to_string(), + }, + })).await.unwrap_err(); + assert!(matches!(refund_while_reserved, CostsError::Storage(_))); + ledger.apply_transaction(&Transaction::sign(&ingest, 1, CostsOperation::SettleStoredValueReservationV2 { + reservation_id: "sv_reservation_1".to_string(), + spend: StoredValueSpendV2 { event_id: "event_reserved_1".to_string(), account_id: "account_stored_reservation".to_string(), credits: 150, occurred_at: 120 }, + })).await.unwrap(); + let read = ledger.stored_value_account_read("account_stored_reservation", 120, "2026-07", 200).await.unwrap().unwrap(); + assert_eq!(read.reserved_credits, 0); + assert_eq!(read.grant_available_credits, 0); + assert_eq!(read.paid_available_credits, 50); + }); +} + +#[test] +fn v1_control_envelopes_bind_approval_status_and_quote_reservation() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(121); + let billing = PrivateKey::ed25519_from_seed(122); + let ingest = PrivateKey::ed25519_from_seed(123); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + state.set_writer(WriterRole::Ingest, &address(&ingest), true); + let mut ledger = CostsLedger::new(&mut state); + ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_v1"))).await.unwrap(); + let rate = RateCardEntry { account_id: String::new(), event_category: "creative.render".to_string(), task_key: String::new(), credits: 7, effective_at: 10, expires_at: 0, policy_version: "policy_v1".to_string(), rate_version: "rate_v1".to_string() }; + let envelope = crate::RateCardChangeSetV1 { change_set_id: "cs_v1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_v1".to_string()], expected_target_count: 1, manifest_hash: "manifest_v1".to_string(), staged_entry_count: 0, approval_ref: "approval_v1".to_string(), audit_ref: "audit_v1".to_string(), activation_epoch: 1, applied: false }; + let envelope = canonical_changeset(envelope, &[rate.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: envelope.clone(), entries: vec![rate.clone()] })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: envelope.clone(), entries: vec![rate] })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&billing, 0, CostsOperation::CreditTopup { account_id: "account_v1".to_string(), credits: 20, rail_ref: "rail_v1".to_string() })).await.unwrap(); + let quote = ledger.quote_request(crate::QuoteRequestV1 { account_id: "account_v1".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 2, quoted_at: 11, expires_at: 20 }).await.unwrap().unwrap(); + ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::ReserveCreditsV1 { reservation_id: "reservation_v1".to_string(), quote: quote.clone(), lineage_ref: "lineage_v1".to_string(), reserved_at: 12 })).await.unwrap(); + let settlement_metadata = ReservationActionMetadataV1 { reason_code: "settled".to_string(), occurred_at: 13, approval_ref: "approval_settle".to_string(), audit_ref: "audit_settle".to_string() }; + let bad = ledger.apply_transaction(&Transaction::sign(&ingest, 1, CostsOperation::SettleSpendV1 { reservation_id: "reservation_v1".to_string(), event_id: "evt_v1".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quote_id: quote.quote_id.clone(), snapshot_hash: "wrong_hash".to_string(), lineage_ref: "lineage_v1".to_string(), metadata: settlement_metadata.clone() })).await.unwrap_err(); + assert!(matches!(bad, CostsError::PinnedRateMismatch { .. })); + ledger.apply_transaction(&Transaction::sign(&ingest, 1, CostsOperation::SettleSpendV1 { reservation_id: "reservation_v1".to_string(), event_id: "evt_v1".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quote_id: quote.quote_id.clone(), snapshot_hash: quote.snapshot_hash.clone(), lineage_ref: "lineage_v1".to_string(), metadata: settlement_metadata })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&admin, 3, CostsOperation::SetAccountStatusV1 { account_id: "account_v1".to_string(), status: AccountStatus::Suspended, metadata: StatusChangeMetadataV1 { reason_code: "close_block".to_string(), changed_at: 14, approval_ref: "approval_status".to_string(), audit_ref: "audit_status".to_string() } })).await.unwrap(); + let history = ledger.status_history("account_v1").await.unwrap(); + assert_eq!(history.last().unwrap().approval_ref, "approval_status"); + assert_eq!(ledger.account("account_v1").await.unwrap().unwrap().reserved_credits, 0); + let reservation = ledger.reservation("reservation_v1").await.unwrap().unwrap(); + assert_eq!(reservation.quote, quote); + assert_eq!(reservation.lineage_ref, "lineage_v1"); + let status = ledger.journal(0, 100).await.unwrap().pop().unwrap(); + assert_eq!(status.reason_code, "close_block"); + assert_eq!(status.occurred_at, 14); + assert_eq!(status.approval_ref, "approval_status"); + assert_eq!(status.audit_ref, "audit_status"); + }); +} + +#[test] +fn global_rates_propagate_to_existing_accounts_preserve_overrides_and_inherit_onboarding() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(201); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + let mut ledger = CostsLedger::new(&mut state); + ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_global_a"))).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&admin, 1, onboard("account_global_b"))).await.unwrap(); + let global = RateCardEntry { account_id: String::new(), event_category: "sms.send".to_string(), task_key: String::new(), credits: 2, effective_at: 10, expires_at: 0, policy_version: "policy_global".to_string(), rate_version: "global_v1".to_string() }; + let changeset = crate::RateCardChangeSetV1 { change_set_id: "global_rates_1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_global_a".to_string(), "account_global_b".to_string()], expected_target_count: 2, manifest_hash: "manifest_global_1".to_string(), staged_entry_count: 0, approval_ref: "approval_global_1".to_string(), audit_ref: "audit_global_1".to_string(), activation_epoch: 1, applied: false }; + let changeset = canonical_changeset(changeset, &[global.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![global.clone()] })).await.unwrap(); + let applied = ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 3, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![global.clone()] })).await.unwrap(); + assert_eq!(applied.iter().filter(|entry| entry.kind == LedgerMutationKind::RateCardApplied && entry.account_id.starts_with("account_global_")).count(), 2); + assert_eq!(ledger.quote("account_global_a", "sms.send", "", 11).await.unwrap().unwrap().credits_per_unit, 2); + + let scoped = RateCardEntry { account_id: "account_global_a".to_string(), credits: 9, rate_version: "account_v1".to_string(), ..global.clone() }; + let scoped_set = crate::RateCardChangeSetV1 { change_set_id: "account_override_1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_global_a".to_string()], expected_target_count: 1, manifest_hash: "manifest_override_1".to_string(), staged_entry_count: 0, approval_ref: "approval_override_1".to_string(), audit_ref: "audit_override_1".to_string(), activation_epoch: 2, applied: false }; + let scoped_set = canonical_changeset(scoped_set, &[scoped.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 4, CostsOperation::StageRateCardChangeSetV1 { change_set: scoped_set.clone(), entries: vec![scoped] })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&admin, 5, CostsOperation::ApplyRateCardChangeSetV1 { change_set: scoped_set, entries: vec![RateCardEntry { account_id: "account_global_a".to_string(), credits: 9, rate_version: "account_v1".to_string(), ..global.clone() }] })).await.unwrap(); + let global_v2 = RateCardEntry { credits: 4, effective_at: 20, rate_version: "global_v2".to_string(), ..global }; + let second = crate::RateCardChangeSetV1 { change_set_id: "global_rates_2".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_global_b".to_string()], expected_target_count: 1, manifest_hash: "manifest_global_2".to_string(), staged_entry_count: 0, approval_ref: "approval_global_2".to_string(), audit_ref: "audit_global_2".to_string(), activation_epoch: 3, applied: false }; + let second = canonical_changeset(second, &[global_v2.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 6, CostsOperation::StageRateCardChangeSetV1 { change_set: second.clone(), entries: vec![global_v2.clone()] })).await.unwrap(); + let updated = ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 7, CostsOperation::ApplyRateCardChangeSetV1 { change_set: second, entries: vec![global_v2] })).await.unwrap(); + assert!(updated.iter().any(|entry| entry.account_id == "account_global_b" && entry.rate.rate_version == "global_v2")); + assert!(!updated.iter().any(|entry| entry.account_id == "account_global_a" && entry.rate.rate_version == "global_v2")); + assert_eq!(ledger.quote("account_global_a", "sms.send", "", 21).await.unwrap().unwrap().credits_per_unit, 9); + assert_eq!(ledger.quote("account_global_b", "sms.send", "", 21).await.unwrap().unwrap().credits_per_unit, 4); + + let inherited = ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 8, CostsOperation::CreateAccount { + account_id: "account_global_future".to_string(), external_ref: "onboard_account_global_future".to_string(), + policy_ref: "policy_test_1".to_string(), cohort_ref: String::new(), created_at: 21, + })).await.unwrap(); + assert!(inherited.iter().any(|entry| entry.kind == LedgerMutationKind::RateCardApplied && entry.account_id == "account_global_future" && entry.reason_code == "global_rate_inherited")); + assert_eq!(ledger.quote("account_global_future", "sms.send", "", 21).await.unwrap().unwrap().credits_per_unit, 4); + }); +} + +impl StateStore for MemoryState { + async fn get(&self, key: &Digest) -> Result>, StateError> { + Ok(self.values.get(key).cloned()) + } + + fn set(&mut self, key: Digest, value: Vec) { + self.values.insert(key, value); + } + + fn remove(&mut self, key: Digest) { + self.values.remove(&key); + } +} + +fn address(key: &PrivateKey) -> Address { + Address::external(&key.public_key()) +} + +fn onboard(account_id: &str) -> CostsOperation { + CostsOperation::CreateAccount { + account_id: account_id.to_string(), + external_ref: format!("onboard_{account_id}"), + policy_ref: "policy_test_1".to_string(), + cohort_ref: String::new(), + created_at: 1, + } +} + +fn adjustment_metadata(reference: &str) -> AdjustmentMetadata { + AdjustmentMetadata { + reference: reference.to_string(), + reason_code: "manual_correction".to_string(), + period_ref: "period_2026_07".to_string(), + approval_ref: "approval_test_1".to_string(), + audit_ref: "audit_test_1".to_string(), + } +} + +#[test] +fn legacy_mutations_are_rejected_at_the_ledger_boundary() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(90); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + let mut ledger = CostsLedger::new(&mut state); + let error = ledger + .apply_transaction(&Transaction::sign( + &admin, + 0, + CostsOperation::RegisterAccount { account_id: "account_legacy".to_string() }, + )) + .await + .unwrap_err(); + assert!(matches!(error, CostsError::LegacyOperationRejected { operation: "RegisterAccount" })); + assert_eq!(ledger.db().nonce(&address(&admin)).await.unwrap(), 0); + let legacy_operations = vec![ + CostsOperation::SetAccountStatus { account_id: "account_legacy".to_string(), status: AccountStatus::Suspended }, + CostsOperation::ReserveCredits { reservation_id: "reservation_legacy".to_string(), account_id: "account_legacy".to_string(), credits: 1, expires_at: 2 }, + CostsOperation::ReleaseReservation { reservation_id: "reservation_legacy".to_string() }, + CostsOperation::ExpireReservation { reservation_id: "reservation_legacy".to_string(), expired_at: 2 }, + CostsOperation::SettleSpend { reservation_id: "reservation_legacy".to_string(), event_id: "event_legacy".to_string(), event_category: "sms.send".to_string() }, + CostsOperation::StageRateCardEntries { change_set_id: "changeset_legacy".to_string(), expected_entry_count: 1, manifest_hash: "manifest_legacy".to_string(), entries: Vec::new() }, + CostsOperation::ApplyRateCardChangeSet { change_set_id: "changeset_legacy".to_string(), manifest_hash: "manifest_legacy".to_string(), entries: Vec::new() }, + ]; + for operation in legacy_operations { + assert!(matches!(ledger.apply_transaction(&Transaction::sign(&admin, 0, operation)).await, Err(CostsError::LegacyOperationRejected { .. }))); + } + }); +} + +#[test] +fn account_writer_scope_onboarding_collision_and_cohort_binding_are_enforced() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(91); + let billing = PrivateKey::ed25519_from_seed(92); + let ingest = PrivateKey::ed25519_from_seed(93); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Ingest, &address(&ingest), true); + let mut ledger = CostsLedger::new(&mut state); + ledger.apply_transaction(&Transaction::sign(&admin, 0, CostsOperation::CreateAccount { + account_id: "account_scope_a".to_string(), external_ref: "onboard_scope_1".to_string(), + policy_ref: "policy_1".to_string(), cohort_ref: "cohort_a".to_string(), created_at: 10, + })).await.unwrap(); + let collision = ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::CreateAccount { + account_id: "account_scope_b".to_string(), external_ref: "onboard_scope_1".to_string(), + policy_ref: "policy_1".to_string(), cohort_ref: "cohort_b".to_string(), created_at: 10, + })).await.unwrap_err(); + assert!(matches!(collision, CostsError::IdempotencyConflict { .. })); + ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::SetAccountWriter { + account_id: "account_scope_a".to_string(), role: WriterRole::Billing, + writer: address(&billing), enabled: true, + })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&billing, 0, CostsOperation::CreditTopup { + account_id: "account_scope_a".to_string(), credits: 20, rail_ref: "rail_scope_a".to_string(), + })).await.unwrap(); + let wrong_account = ledger.apply_transaction(&Transaction::sign(&billing, 1, CostsOperation::CreditTopup { + account_id: "account_scope_b".to_string(), credits: 20, rail_ref: "rail_scope_b".to_string(), + })).await.unwrap_err(); + assert!(matches!(wrong_account, CostsError::Unauthorized { role: WriterRole::Billing, .. })); + let rate = RateCardEntry { account_id: String::new(), event_category: "creative.action".to_string(), task_key: String::new(), credits: 2, effective_at: 10, expires_at: 0, policy_version: "policy_a".to_string(), rate_version: "rate_a".to_string() }; + let changeset = crate::RateCardChangeSetV1 { change_set_id: "scope_rates".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_scope_a".to_string()], expected_target_count: 1, manifest_hash: "scope_manifest".to_string(), staged_entry_count: 0, approval_ref: "approval_scope".to_string(), audit_ref: "audit_scope".to_string(), activation_epoch: 1, applied: false }; + let changeset = canonical_changeset(changeset, &[rate.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![rate.clone()] })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&admin, 3, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![rate] })).await.unwrap(); + let mismatch = ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::RecordSpendBatch { records: vec![SpendRecordV1 { + event_id: "evt_cohort_mismatch".to_string(), account_id: "account_scope_a".to_string(), event_category: "creative.action".to_string(), task_key: String::new(), quantity: 1, credits: 2, observed_at: 10, policy_version: "policy_a".to_string(), rate_version: "rate_a".to_string(), source_ref: "source_a".to_string(), lineage_ref: "lineage_a".to_string(), cohort_ref: "cohort_b".to_string(), + }] })).await.unwrap_err(); + assert!(matches!(mismatch, CostsError::PinnedRateMismatch { .. })); + }); +} + +#[test] +fn untracked_sources_never_debit_and_journal_and_finality_envelopes_are_complete() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(94); + let billing = PrivateKey::ed25519_from_seed(95); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + let mut ledger = CostsLedger::new(&mut state); + ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_journal"))).await.unwrap(); + let topup = Transaction::sign(&billing, 0, CostsOperation::CreditTopup { account_id: "account_journal".to_string(), credits: 25, rail_ref: "rail_journal".to_string() }); + let outcomes = ledger.apply_transaction_with_outcomes(&topup).await.unwrap(); + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].kind, LedgerMutationKind::BalanceChanged); + assert!(outcomes[0].has_account); + assert_eq!(outcomes[0].account.available_credits, 25); + assert_eq!(ledger.journal(0, 10).await.unwrap().len(), 2); + let source = UntrackedSourceV1 { + source_id: "dark_complete_1".to_string(), reason_code: "shared_cost".to_string(), + owner_ref: "owner_finance".to_string(), period_ref: "period_2026_07".to_string(), + provenance_ref: "provider_invoice_1".to_string(), coverage_code: "cost_dark".to_string(), + confidence_code: "unverified".to_string(), evidence_ref: "evidence_invoice_1".to_string(), cohort_ref: "cohort_dark_1".to_string(), + }; + ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::RegisterUntrackedSourceV1 { source })).await.unwrap(); + assert_eq!(ledger.account("account_journal").await.unwrap().unwrap().available_credits, 25); + let journal = ledger.journal(0, 10).await.unwrap(); + assert!(journal.iter().any(|entry| entry.kind == LedgerMutationKind::UntrackedSourceRegistered && entry.has_untracked_source && entry.untracked_source.owner_ref == "owner_finance")); + let finality = FinalizedOutcomeV1::from_finalized_mutation(&outcomes[0], topup.digest(), 1234); + assert_eq!(finality.schema_version, 1); + assert_eq!(finality.event_type, "ledger.balance_changed"); + assert_eq!(finality.account_id.as_deref(), Some("account_journal")); + assert_eq!(finality.source_ref.as_deref(), Some("rail_journal")); + assert_eq!(finality.finalized_at, 1234); + assert!(finality.outbox_event_id.starts_with("ledger:")); + let replay = ledger.apply_transaction_with_outcomes(&Transaction::sign(&billing, 1, CostsOperation::CreditTopup { account_id: "account_journal".to_string(), credits: 25, rail_ref: "rail_journal".to_string() })).await.unwrap(); + assert!(replay.is_empty(), "an idempotent replay has no journal mutation and no publishable finality event"); + }); +} + +#[test] +fn credits_are_idempotent_and_spend_is_deduplicated() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(1); + let billing = PrivateKey::ed25519_from_seed(2); + let ingest = PrivateKey::ed25519_from_seed(3); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + state.set_writer(WriterRole::Ingest, &address(&ingest), true); + let mut ledger = CostsLedger::new(&mut state); + + ledger + .apply_transaction(&Transaction::sign( + &admin, + 0, + onboard("account_demo"), + )) + .await + .unwrap(); + let rate = RateCardEntry { + account_id: String::new(), + event_category: "creative.action".to_string(), + task_key: String::new(), + credits: 40, + effective_at: 1, + expires_at: 0, + policy_version: "policy_spend_1".to_string(), + rate_version: "rate_spend_1".to_string(), + }; + let changeset = crate::RateCardChangeSetV1 { change_set_id: "changeset_spend_1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_demo".to_string()], expected_target_count: 1, manifest_hash: "manifest_spend_1".to_string(), staged_entry_count: 0, approval_ref: "approval_spend_1".to_string(), audit_ref: "audit_spend_1".to_string(), activation_epoch: 1, applied: false }; + let changeset = canonical_changeset(changeset, &[rate.clone()]); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 1, + CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), + entries: vec![rate.clone()], + }, + )) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 2, + CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, + entries: vec![rate], + }, + )) + .await + .unwrap(); + let topup = CostsOperation::CreditTopup { + account_id: "account_demo".to_string(), + credits: 100, + rail_ref: "pi_demo_1".to_string(), + }; + ledger + .apply_transaction(&Transaction::sign(&billing, 0, topup.clone())) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign(&billing, 1, topup)) + .await + .unwrap(); + assert_eq!(ledger.account("account_demo").await.unwrap().unwrap().available_credits, 100); + + let spend = SpendRecordV1 { + event_id: "usage:evt_1".to_string(), + account_id: "account_demo".to_string(), + event_category: "creative.action".to_string(), + task_key: String::new(), + quantity: 1, + credits: 40, + observed_at: 1, + policy_version: "policy_spend_1".to_string(), + rate_version: "rate_spend_1".to_string(), + source_ref: "source_evt_1".to_string(), + lineage_ref: "lineage_evt_1".to_string(), + cohort_ref: String::new(), + }; + ledger + .apply_transaction(&Transaction::sign( + &ingest, + 0, + CostsOperation::RecordSpendBatch { + records: vec![spend.clone(), spend], + }, + )) + .await + .unwrap(); + assert_eq!(ledger.account("account_demo").await.unwrap().unwrap().available_credits, 60); + }); +} + +#[test] +fn suspended_account_rejects_spend_without_partial_debit() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(11); + let billing = PrivateKey::ed25519_from_seed(12); + let ingest = PrivateKey::ed25519_from_seed(13); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + state.set_writer(WriterRole::Ingest, &address(&ingest), true); + let mut ledger = CostsLedger::new(&mut state); + + ledger + .apply_transaction(&Transaction::sign( + &admin, + 0, + onboard("account_blocked"), + )) + .await + .unwrap(); + let rate = RateCardEntry { + account_id: String::new(), + event_category: "creative.action".to_string(), + task_key: String::new(), + credits: 1, + effective_at: 1, + expires_at: 0, + policy_version: "policy_blocked_1".to_string(), + rate_version: "rate_blocked_1".to_string(), + }; + let changeset = crate::RateCardChangeSetV1 { change_set_id: "changeset_blocked_1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_blocked".to_string()], expected_target_count: 1, manifest_hash: "manifest_blocked_1".to_string(), staged_entry_count: 0, approval_ref: "approval_blocked".to_string(), audit_ref: "audit_blocked".to_string(), activation_epoch: 1, applied: false }; + let changeset = canonical_changeset(changeset, &[rate.clone()]); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 1, + CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), + entries: vec![rate.clone()], + }, + )) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 2, + CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, + entries: vec![rate], + }, + )) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &billing, + 0, + CostsOperation::CreditTopup { + account_id: "account_blocked".to_string(), + credits: 20, + rail_ref: "pi_demo_2".to_string(), + }, + )) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 3, + CostsOperation::SetAccountStatusV1 { + account_id: "account_blocked".to_string(), + status: AccountStatus::Suspended, + metadata: StatusChangeMetadataV1 { reason_code: "suspended".to_string(), changed_at: 2, approval_ref: "approval_blocked".to_string(), audit_ref: "audit_blocked".to_string() }, + }, + )) + .await + .unwrap(); + let error = ledger + .apply_transaction(&Transaction::sign( + &ingest, + 0, + CostsOperation::RecordSpendBatch { + records: vec![SpendRecordV1 { + event_id: "usage:evt_2".to_string(), + account_id: "account_blocked".to_string(), + event_category: "creative.action".to_string(), + task_key: String::new(), + quantity: 1, + credits: 1, + observed_at: 1, + policy_version: "policy_blocked_1".to_string(), + rate_version: "rate_blocked_1".to_string(), + source_ref: "source_evt_2".to_string(), + lineage_ref: "lineage_evt_2".to_string(), + cohort_ref: String::new(), + }], + }, + )) + .await + .unwrap_err(); + assert!(matches!(error, CostsError::AccountSuspended { .. })); + assert_eq!(ledger.account("account_blocked").await.unwrap().unwrap().available_credits, 20); + }); +} + +#[test] +fn reservations_adjustments_and_untracked_sources_preserve_balance_invariants() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(21); + let billing = PrivateKey::ed25519_from_seed(22); + let ingest = PrivateKey::ed25519_from_seed(23); + let adjustment = PrivateKey::ed25519_from_seed(24); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + state.set_writer(WriterRole::Ingest, &address(&ingest), true); + state.set_writer(WriterRole::Adjustment, &address(&adjustment), true); + let mut ledger = CostsLedger::new(&mut state); + + ledger + .apply_transaction(&Transaction::sign( + &admin, + 0, + onboard("account_reserve"), + )) + .await + .unwrap(); + let rate = RateCardEntry { account_id: String::new(), event_category: "creative.render".to_string(), task_key: String::new(), credits: 1, effective_at: 1, expires_at: 0, policy_version: "policy_reserve".to_string(), rate_version: "rate_reserve".to_string() }; + let changeset = crate::RateCardChangeSetV1 { change_set_id: "reserve_rates".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_reserve".to_string()], expected_target_count: 1, manifest_hash: "reserve_manifest".to_string(), staged_entry_count: 0, approval_ref: "reserve_approval".to_string(), audit_ref: "reserve_audit".to_string(), activation_epoch: 1, applied: false }; + let changeset = canonical_changeset(changeset, &[rate.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![rate.clone()] })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![rate] })).await.unwrap(); + let quote_one = ledger.quote_request(crate::QuoteRequestV1 { account_id: "account_reserve".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 30, quoted_at: 2, expires_at: 1_800_000_000 }).await.unwrap().unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &billing, + 0, + CostsOperation::CreditTopup { + account_id: "account_reserve".to_string(), + credits: 100, + rail_ref: "pi_reserve_1".to_string(), + }, + )) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &ingest, + 0, + CostsOperation::ReserveCreditsV1 { + reservation_id: "reservation_1".to_string(), + quote: quote_one, + lineage_ref: "lineage_reserve_1".to_string(), reserved_at: 3, + }, + )) + .await + .unwrap(); + let account = ledger.account("account_reserve").await.unwrap().unwrap(); + assert_eq!((account.available_credits, account.reserved_credits), (70, 30)); + + ledger + .apply_transaction(&Transaction::sign( + &ingest, + 1, + CostsOperation::ReleaseReservationV1 { + reservation_id: "reservation_1".to_string(), + metadata: ReservationActionMetadataV1 { reason_code: "released".to_string(), occurred_at: 4, approval_ref: "reserve_approval".to_string(), audit_ref: "reserve_audit".to_string() }, + }, + )) + .await + .unwrap(); + let account = ledger.account("account_reserve").await.unwrap().unwrap(); + assert_eq!((account.available_credits, account.reserved_credits), (100, 0)); + + let quote_two = ledger.quote_request(crate::QuoteRequestV1 { account_id: "account_reserve".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 25, quoted_at: 5, expires_at: 1_800_000_001 }).await.unwrap().unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &ingest, + 2, + CostsOperation::ReserveCreditsV1 { + reservation_id: "reservation_2".to_string(), + quote: quote_two.clone(), lineage_ref: "lineage_reserve_2".to_string(), reserved_at: 6, + }, + )) + .await + .unwrap(); + let settle = CostsOperation::SettleSpendV1 { + reservation_id: "reservation_2".to_string(), + event_id: "usage:reserved_event_1".to_string(), + event_category: "creative.render".to_string(), + task_key: String::new(), quote_id: quote_two.quote_id.clone(), snapshot_hash: quote_two.snapshot_hash.clone(), lineage_ref: "lineage_reserve_2".to_string(), metadata: ReservationActionMetadataV1 { reason_code: "settled".to_string(), occurred_at: 7, approval_ref: "reserve_approval".to_string(), audit_ref: "reserve_audit".to_string() }, + }; + ledger + .apply_transaction(&Transaction::sign(&ingest, 3, settle.clone())) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign(&ingest, 4, settle)) + .await + .unwrap(); + let account = ledger.account("account_reserve").await.unwrap().unwrap(); + assert_eq!((account.available_credits, account.reserved_credits), (75, 0)); + + let grant = CostsOperation::CreditAdjustmentV1 { + kind: AdjustmentKind::Grant, + account_id: "account_reserve".to_string(), + credits: 10, + metadata: adjustment_metadata("grant_welcome_1"), + }; + ledger + .apply_transaction(&Transaction::sign(&adjustment, 0, grant.clone())) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign(&adjustment, 1, grant)) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &adjustment, + 2, + CostsOperation::CreditAdjustmentV1 { + kind: AdjustmentKind::Reversal, + account_id: "account_reserve".to_string(), + credits: 5, + metadata: adjustment_metadata("reversal_event_1"), + }, + )) + .await + .unwrap(); + assert_eq!(ledger.account("account_reserve").await.unwrap().unwrap().available_credits, 80); + + ledger + .apply_transaction(&Transaction::sign( + &admin, + 3, + CostsOperation::RegisterUntrackedSourceV1 { source: UntrackedSourceV1 { + source_id: "source_dark_1".to_string(), + reason_code: "shared_cost".to_string(), + owner_ref: "owner_finance_1".to_string(), period_ref: "period_2026_07".to_string(), + provenance_ref: "source_shared_1".to_string(), coverage_code: "cost_dark".to_string(), + confidence_code: "unverified".to_string(), evidence_ref: "evidence_1".to_string(), cohort_ref: String::new(), + } }, + )) + .await + .unwrap(); + assert_eq!(ledger.account("account_reserve").await.unwrap().unwrap().available_credits, 80); + }); +} + +#[test] +fn rate_change_sets_are_atomic_and_use_scoped_precedence() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(31); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + let mut ledger = CostsLedger::new(&mut state); + + let global_category = RateCardEntry { + account_id: String::new(), + event_category: "creative.render".to_string(), + task_key: String::new(), + credits: 2, + effective_at: 100, + expires_at: 0, + policy_version: "policy_1".to_string(), + rate_version: "rate_1".to_string(), + }; + let global_task = RateCardEntry { + task_key: "image_hd".to_string(), + credits: 5, + rate_version: "rate_2".to_string(), + ..global_category.clone() + }; + let account_category = RateCardEntry { + account_id: "account_priority".to_string(), + credits: 7, + rate_version: "rate_3".to_string(), + ..global_category.clone() + }; + let all_entries = vec![ + global_category.clone(), + global_task.clone(), + account_category.clone(), + ]; + let change_one = crate::RateCardChangeSetV1 { change_set_id: "changeset_1".to_string(), expected_entry_count: 3, target_account_ids: vec!["account_priority".to_string()], expected_target_count: 1, manifest_hash: "manifest_1".to_string(), staged_entry_count: 0, approval_ref: "approval_1".to_string(), audit_ref: "audit_1".to_string(), activation_epoch: 1, applied: false }; + let change_one = canonical_changeset(change_one, &all_entries); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 0, + CostsOperation::StageRateCardChangeSetV1 { change_set: change_one.clone(), + entries: vec![global_category.clone(), global_task.clone()], + }, + )) + .await + .unwrap(); + let error = ledger + .apply_transaction(&Transaction::sign( + &admin, + 1, + CostsOperation::ApplyRateCardChangeSetV1 { change_set: change_one.clone(), + entries: all_entries.clone(), + }, + )) + .await + .unwrap_err(); + assert!(matches!(error, CostsError::RateChangeSetIncomplete { .. })); + + ledger + .apply_transaction(&Transaction::sign( + &admin, + 1, + CostsOperation::StageRateCardChangeSetV1 { change_set: change_one.clone(), + entries: vec![account_category.clone()], + }, + )) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 2, + CostsOperation::ApplyRateCardChangeSetV1 { change_set: change_one, + entries: all_entries, + }, + )) + .await + .unwrap(); + + assert_eq!( + ledger + .active_rate("account_priority", "creative.render", "image_hd", 100) + .await + .unwrap() + .unwrap() + .credits, + 7 + ); + assert_eq!( + ledger + .active_rate("account_new", "creative.render", "image_hd", 100) + .await + .unwrap() + .unwrap() + .credits, + 5 + ); + + let account_task = RateCardEntry { + task_key: "image_hd".to_string(), + credits: 9, + rate_version: "rate_4".to_string(), + ..account_category + }; + let change_two = crate::RateCardChangeSetV1 { change_set_id: "changeset_2".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_priority".to_string()], expected_target_count: 1, manifest_hash: "manifest_2".to_string(), staged_entry_count: 0, approval_ref: "approval_2".to_string(), audit_ref: "audit_2".to_string(), activation_epoch: 2, applied: false }; + let change_two = canonical_changeset(change_two, &[account_task.clone()]); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 3, + CostsOperation::StageRateCardChangeSetV1 { change_set: change_two.clone(), + entries: vec![account_task.clone()], + }, + )) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 4, + CostsOperation::ApplyRateCardChangeSetV1 { change_set: change_two, + entries: vec![account_task], + }, + )) + .await + .unwrap(); + assert_eq!( + ledger + .active_rate("account_priority", "creative.render", "image_hd", 100) + .await + .unwrap() + .unwrap() + .credits, + 9 + ); + }); +} + +#[test] +fn suspension_blocks_a_preexisting_reservation_from_settling() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(51); + let billing = PrivateKey::ed25519_from_seed(52); + let ingest = PrivateKey::ed25519_from_seed(53); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + state.set_writer(WriterRole::Ingest, &address(&ingest), true); + let mut ledger = CostsLedger::new(&mut state); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 0, + onboard("account_hold"), + )) + .await + .unwrap(); + let rate = RateCardEntry { account_id: String::new(), event_category: "creative.render".to_string(), task_key: String::new(), credits: 1, effective_at: 1, expires_at: 0, policy_version: "policy_hold".to_string(), rate_version: "rate_hold".to_string() }; + let changeset = crate::RateCardChangeSetV1 { change_set_id: "hold_rates".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_hold".to_string()], expected_target_count: 1, manifest_hash: "hold_manifest".to_string(), staged_entry_count: 0, approval_ref: "hold_approval".to_string(), audit_ref: "hold_audit".to_string(), activation_epoch: 1, applied: false }; + let changeset = canonical_changeset(changeset, &[rate.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![rate.clone()] })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![rate] })).await.unwrap(); + let quote = ledger.quote_request(crate::QuoteRequestV1 { account_id: "account_hold".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 20, quoted_at: 2, expires_at: 1_900_000_000 }).await.unwrap().unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &billing, + 0, + CostsOperation::CreditTopup { + account_id: "account_hold".to_string(), + credits: 30, + rail_ref: "pi_hold_1".to_string(), + }, + )) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &ingest, + 0, + CostsOperation::ReserveCreditsV1 { + reservation_id: "hold_1".to_string(), + quote: quote.clone(), lineage_ref: "lineage_hold".to_string(), reserved_at: 3, + }, + )) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 3, + CostsOperation::SetAccountStatusV1 { + account_id: "account_hold".to_string(), + status: AccountStatus::Suspended, + metadata: StatusChangeMetadataV1 { reason_code: "suspended".to_string(), changed_at: 4, approval_ref: "hold_approval".to_string(), audit_ref: "hold_audit".to_string() }, + }, + )) + .await + .unwrap(); + let error = ledger + .apply_transaction(&Transaction::sign( + &ingest, + 1, + CostsOperation::SettleSpendV1 { + reservation_id: "hold_1".to_string(), + event_id: "usage:hold_1".to_string(), + event_category: "creative.render".to_string(), + task_key: String::new(), quote_id: quote.quote_id.clone(), snapshot_hash: quote.snapshot_hash.clone(), lineage_ref: "lineage_hold".to_string(), metadata: ReservationActionMetadataV1 { reason_code: "settled".to_string(), occurred_at: 5, approval_ref: "hold_approval".to_string(), audit_ref: "hold_audit".to_string() }, + }, + )) + .await + .unwrap_err(); + assert!(matches!(error, CostsError::ReservationAccountSuspended { .. })); + let account = ledger.account("account_hold").await.unwrap().unwrap(); + assert_eq!((account.available_credits, account.reserved_credits), (10, 20)); + assert_eq!( + ledger.reservation("hold_1").await.unwrap().unwrap().status, + crate::ReservationStatus::Active + ); + }); +} + +#[test] +fn changed_idempotency_replay_and_bad_pinned_rate_are_rejected() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(61); + let billing = PrivateKey::ed25519_from_seed(62); + let ingest = PrivateKey::ed25519_from_seed(63); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + state.set_writer(WriterRole::Ingest, &address(&ingest), true); + state.set_writer(WriterRole::Adjustment, &address(&admin), true); + let mut ledger = CostsLedger::new(&mut state); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 0, + onboard("account_pin"), + )) + .await + .unwrap(); + let rate = RateCardEntry { + account_id: String::new(), event_category: "creative.render".to_string(), + task_key: String::new(), credits: 3, effective_at: 10, expires_at: 0, + policy_version: "policy_pin_1".to_string(), rate_version: "rate_pin_1".to_string(), + }; + let changeset = crate::RateCardChangeSetV1 { change_set_id: "cs_pin".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_pin".to_string()], expected_target_count: 1, manifest_hash: "manifest_pin".to_string(), staged_entry_count: 0, approval_ref: "approval_pin".to_string(), audit_ref: "audit_pin".to_string(), activation_epoch: 1, applied: false }; + let changeset = canonical_changeset(changeset, &[rate.clone()]); + for (nonce, operation) in [ + (1, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![rate.clone()] }), + (2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![rate] }), + ] { + ledger.apply_transaction(&Transaction::sign(&admin, nonce, operation)).await.unwrap(); + } + ledger.apply_transaction(&Transaction::sign(&billing, 0, CostsOperation::CreditTopup { account_id: "account_pin".to_string(), credits: 10, rail_ref: "pi_pin".to_string() })).await.unwrap(); + let changed_topup = ledger.apply_transaction(&Transaction::sign(&billing, 1, CostsOperation::CreditTopup { account_id: "account_pin".to_string(), credits: 9, rail_ref: "pi_pin".to_string() })).await.unwrap_err(); + assert!(matches!(changed_topup, CostsError::IdempotencyConflict { .. })); + let bad = SpendRecordV1 { event_id: "usage:pin_1".to_string(), account_id: "account_pin".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 1, credits: 2, observed_at: 10, policy_version: "policy_pin_1".to_string(), rate_version: "rate_pin_1".to_string(), source_ref: "source_pin".to_string(), lineage_ref: "lineage_pin".to_string(), cohort_ref: "cohort_1".to_string() }; + let error = ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::RecordSpendBatch { records: vec![bad] })).await.unwrap_err(); + assert!(matches!(error, CostsError::PinnedRateMismatch { .. })); + let valid = SpendRecordV1 { event_id: "usage:pin_duplicate".to_string(), account_id: "account_pin".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 1, credits: 3, observed_at: 10, policy_version: "policy_pin_1".to_string(), rate_version: "rate_pin_1".to_string(), source_ref: "source_pin".to_string(), lineage_ref: "lineage_pin".to_string(), cohort_ref: String::new() }; + let mut changed = valid.clone(); + changed.credits = 4; + let error = ledger.apply_transaction(&Transaction::sign(&ingest, 0, CostsOperation::RecordSpendBatch { records: vec![valid, changed] })).await.unwrap_err(); + assert!(matches!(error, CostsError::IdempotencyConflict { .. })); + let metadata = crate::AdjustmentMetadata { reference: "adjustment_pin_1".to_string(), reason_code: "correction".to_string(), period_ref: "period_202607".to_string(), approval_ref: "approval_1".to_string(), audit_ref: "audit_1".to_string() }; + ledger.apply_transaction(&Transaction::sign(&admin, 3, CostsOperation::CreditAdjustmentV1 { kind: crate::AdjustmentKind::Grant, account_id: "account_pin".to_string(), credits: 2, metadata: metadata.clone() })).await.unwrap(); + let error = ledger.apply_transaction(&Transaction::sign(&admin, 4, CostsOperation::CreditAdjustmentV1 { kind: crate::AdjustmentKind::Reversal, account_id: "account_pin".to_string(), credits: 2, metadata })).await.unwrap_err(); + assert!(matches!(error, CostsError::IdempotencyConflict { .. })); + let correction = Transaction::sign(&admin, 4, CostsOperation::CreditAdjustmentV1 { + kind: crate::AdjustmentKind::Reversal, + account_id: "account_pin".to_string(), + credits: 1, + metadata: crate::AdjustmentMetadata { + reference: "adjustment_pin_2".to_string(), + reason_code: "correction".to_string(), + period_ref: "period_202607".to_string(), + approval_ref: "approval_2".to_string(), + audit_ref: "audit_2".to_string(), + }, + }); + ledger.apply_transaction(&correction).await.unwrap(); + assert_eq!(ledger.account("account_pin").await.unwrap().unwrap().available_credits, 11); + let mutation = ledger.journal(0, 100).await.unwrap().pop().unwrap(); + assert_eq!( + FinalizedOutcomeV1::from_finalized_mutation(&mutation, correction.digest(), 20).kind, + FinalizedOutcomeKind::CreditAdjustmentApplied + ); + }); +} + +#[test] +fn onboarding_rotation_private_reads_and_expiry_release_are_deterministic() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(71); + let replacement = PrivateKey::ed25519_from_seed(72); + let billing = PrivateKey::ed25519_from_seed(73); + let ingest = PrivateKey::ed25519_from_seed(74); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + state.set_writer(WriterRole::Ingest, &address(&ingest), true); + let mut ledger = CostsLedger::new(&mut state); + let onboard = CostsOperation::CreateAccount { + account_id: "account_program".to_string(), + external_ref: "onboarding_program_1".to_string(), + policy_ref: "policy_fixture_1".to_string(), + cohort_ref: "cohort_fixture_1".to_string(), + created_at: 100, + }; + ledger + .apply_transaction(&Transaction::sign(&admin, 0, onboard.clone())) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign(&admin, 1, onboard)) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &admin, + 2, + CostsOperation::RotateAdmin { + replacement: address(&replacement), + }, + )) + .await + .unwrap(); + let rejected = ledger + .apply_transaction(&Transaction::sign( + &admin, + 3, + CostsOperation::SetAccountStatusV1 { + account_id: "account_program".to_string(), + status: AccountStatus::Suspended, + metadata: StatusChangeMetadataV1 { reason_code: "suspended".to_string(), changed_at: 110, approval_ref: "approval_program".to_string(), audit_ref: "audit_program".to_string() }, + }, + )) + .await + .unwrap_err(); + assert!(matches!(rejected, CostsError::Unauthorized { .. })); + let rate = RateCardEntry { + account_id: String::new(), + event_category: "creative.render".to_string(), + task_key: String::new(), + credits: 5, + effective_at: 100, + expires_at: 150, + policy_version: "policy_program_1".to_string(), + rate_version: "rate_program_1".to_string(), + }; + let changeset = crate::RateCardChangeSetV1 { change_set_id: "changeset_program_1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_program".to_string()], expected_target_count: 1, manifest_hash: "manifest_program_1".to_string(), staged_entry_count: 0, approval_ref: "approval_program".to_string(), audit_ref: "audit_program".to_string(), activation_epoch: 1, applied: false }; + let changeset = canonical_changeset(changeset, &[rate.clone()]); + ledger + .apply_transaction(&Transaction::sign( + &replacement, + 0, + CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), + entries: vec![rate.clone()], + }, + )) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &replacement, + 1, + CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, + entries: vec![rate], + }, + )) + .await + .unwrap(); + let quote = ledger.quote_request(crate::QuoteRequestV1 { account_id: "account_program".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), quantity: 1, quoted_at: 110, expires_at: 140 }).await.unwrap().unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &billing, + 0, + CostsOperation::CreditTopup { + account_id: "account_program".to_string(), + credits: 10, + rail_ref: "pi_program_1".to_string(), + }, + )) + .await + .unwrap(); + let read = ledger.account_read("account_program").await.unwrap().unwrap(); + assert_eq!(read.profile.policy_ref, "policy_fixture_1"); + assert_eq!(read.profile.cohort_ref, "cohort_fixture_1"); + assert_eq!(ledger.status_history("account_program").await.unwrap().len(), 1); + assert_eq!( + ledger + .quote("account_program", "creative.render", "", 100) + .await + .unwrap() + .unwrap() + .credits_per_unit, + 5 + ); + assert!( + ledger + .quote("account_program", "creative.render", "", 150) + .await + .unwrap() + .is_none() + ); + ledger + .apply_transaction(&Transaction::sign( + &ingest, + 0, + CostsOperation::ReserveCreditsV1 { + reservation_id: "reservation_program_1".to_string(), + quote: quote.clone(), lineage_ref: "lineage_program".to_string(), reserved_at: 111, + }, + )) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign( + &ingest, + 1, + CostsOperation::ExpireReservationV1 { + reservation_id: "reservation_program_1".to_string(), + metadata: ReservationActionMetadataV1 { reason_code: "expired".to_string(), occurred_at: 140, approval_ref: "approval_program".to_string(), audit_ref: "audit_program".to_string() }, + }, + )) + .await + .unwrap(); + let account = ledger.account("account_program").await.unwrap().unwrap(); + assert_eq!((account.available_credits, account.reserved_credits), (10, 0)); + assert_eq!( + ledger + .reservation("reservation_program_1") + .await + .unwrap() + .unwrap() + .status, + crate::ReservationStatus::Expired + ); + let error = ledger + .apply_transaction(&Transaction::sign( + &ingest, + 2, + CostsOperation::SettleSpendV1 { + reservation_id: "reservation_program_1".to_string(), + event_id: "usage:expired_program_1".to_string(), + event_category: "creative.render".to_string(), + task_key: String::new(), quote_id: quote.quote_id.clone(), snapshot_hash: quote.snapshot_hash.clone(), lineage_ref: "lineage_program".to_string(), metadata: ReservationActionMetadataV1 { reason_code: "settled".to_string(), occurred_at: 141, approval_ref: "approval_program".to_string(), audit_ref: "audit_program".to_string() }, + }, + )) + .await + .unwrap_err(); + assert!(matches!(error, CostsError::ReservationNotActive { .. } | CostsError::ReservationExpired { .. })); + }); +} + +#[test] +fn replay_is_a_nonce_consuming_noop_without_duplicate_journal_entries() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(111); + let billing = PrivateKey::ed25519_from_seed(112); + let ingest = PrivateKey::ed25519_from_seed(113); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + state.set_writer(WriterRole::Ingest, &address(&ingest), true); + let mut ledger = CostsLedger::new(&mut state); + ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_replay"))).await.unwrap(); + let rate = RateCardEntry { account_id: String::new(), event_category: "sms.send".to_string(), task_key: String::new(), credits: 2, effective_at: 1, expires_at: 0, policy_version: "policy_replay".to_string(), rate_version: "rate_replay".to_string() }; + let changeset = crate::RateCardChangeSetV1 { change_set_id: "cs_replay".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_replay".to_string()], expected_target_count: 1, manifest_hash: "manifest_replay".to_string(), staged_entry_count: 0, approval_ref: "approval_replay".to_string(), audit_ref: "audit_replay".to_string(), activation_epoch: 1, applied: false }; + let changeset = canonical_changeset(changeset, &[rate.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: changeset.clone(), entries: vec![rate.clone()] })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: changeset, entries: vec![rate] })).await.unwrap(); + let first = CostsOperation::CreditTopup { account_id: "account_replay".to_string(), credits: 20, rail_ref: "rail_replay".to_string() }; + ledger.apply_transaction(&Transaction::sign(&billing, 0, first.clone())).await.unwrap(); + let before_topup_replay = ledger.journal(0, 100).await.unwrap().len(); + assert!(ledger.apply_transaction_with_outcomes(&Transaction::sign(&billing, 1, first)).await.unwrap().is_empty()); + assert_eq!(ledger.journal(0, 100).await.unwrap().len(), before_topup_replay); + + let spend = SpendRecordV1 { event_id: "evt_replay".to_string(), account_id: "account_replay".to_string(), event_category: "sms.send".to_string(), task_key: String::new(), quantity: 1, credits: 2, observed_at: 2, policy_version: "policy_replay".to_string(), rate_version: "rate_replay".to_string(), source_ref: "source_replay".to_string(), lineage_ref: "lineage_replay".to_string(), cohort_ref: String::new() }; + let outcomes = ledger.apply_transaction_with_outcomes(&Transaction::sign(&ingest, 0, CostsOperation::RecordSpendBatch { records: vec![spend.clone(), spend.clone()] })).await.unwrap(); + assert_eq!(outcomes.len(), 1, "in-batch exact duplicate has one debit and one journal event"); + let before_batch_replay = ledger.journal(0, 100).await.unwrap().len(); + assert!(ledger.apply_transaction_with_outcomes(&Transaction::sign(&ingest, 1, CostsOperation::RecordSpendBatch { records: vec![spend] })).await.unwrap().is_empty()); + assert_eq!(ledger.journal(0, 100).await.unwrap().len(), before_batch_replay); + let conflict = ledger.apply_transaction(&Transaction::sign(&ingest, 2, CostsOperation::RecordSpendBatch { records: vec![SpendRecordV1 { credits: 4, quantity: 2, ..SpendRecordV1 { event_id: "evt_replay".to_string(), account_id: "account_replay".to_string(), event_category: "sms.send".to_string(), task_key: String::new(), quantity: 1, credits: 2, observed_at: 2, policy_version: "policy_replay".to_string(), rate_version: "rate_replay".to_string(), source_ref: "source_replay".to_string(), lineage_ref: "lineage_replay".to_string(), cohort_ref: String::new() } }] })).await.unwrap_err(); + assert!(matches!(conflict, CostsError::IdempotencyConflict { .. })); + }); +} + +#[test] +fn rate_history_resolves_historical_revision_without_repricing() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(114); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + let mut ledger = CostsLedger::new(&mut state); + let early = RateCardEntry { account_id: String::new(), event_category: "creative.render".to_string(), task_key: String::new(), credits: 3, effective_at: 10, expires_at: 0, policy_version: "policy_a".to_string(), rate_version: "global_v1".to_string() }; + let later = RateCardEntry { credits: 5, effective_at: 20, rate_version: "global_v2".to_string(), ..early.clone() }; + let scoped = RateCardEntry { account_id: "account_history".to_string(), credits: 7, effective_at: 15, rate_version: "account_v1".to_string(), ..early.clone() }; + for (nonce, id, entry) in [(0, "history_1", early.clone()), (2, "history_2", later), (4, "history_3", scoped)] { + let targets = if entry.account_id.is_empty() { Vec::new() } else { vec![entry.account_id.clone()] }; + let change_set = crate::RateCardChangeSetV1 { change_set_id: id.to_string(), expected_entry_count: 1, expected_target_count: targets.len() as u16, target_account_ids: targets, manifest_hash: "placeholder".to_string(), staged_entry_count: 0, approval_ref: format!("approval_{id}"), audit_ref: format!("audit_{id}"), activation_epoch: nonce + 1, applied: false }; + let change_set = canonical_changeset(change_set, &[entry.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, nonce, CostsOperation::StageRateCardChangeSetV1 { change_set: change_set.clone(), entries: vec![entry.clone()] })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&admin, nonce + 1, CostsOperation::ApplyRateCardChangeSetV1 { change_set, entries: vec![entry] })).await.unwrap(); + } + assert_eq!(ledger.active_rate("account_other", "creative.render", "", 12).await.unwrap().unwrap().rate_version, "global_v1"); + assert_eq!(ledger.active_rate("account_other", "creative.render", "", 22).await.unwrap().unwrap().rate_version, "global_v2"); + assert_eq!(ledger.active_rate("account_history", "creative.render", "", 12).await.unwrap().unwrap().rate_version, "global_v1"); + assert_eq!(ledger.active_rate("account_history", "creative.render", "", 16).await.unwrap().unwrap().rate_version, "account_v1"); + }); +} + +#[test] +fn v1_rate_manifest_binds_full_approval_payload_and_completion_is_once_only() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(141); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + let mut ledger = CostsLedger::new(&mut state); + ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_manifest"))).await.unwrap(); + let rate = RateCardEntry { account_id: String::new(), event_category: "sms.send".to_string(), task_key: String::new(), credits: 3, effective_at: 10, expires_at: 0, policy_version: "policy_manifest".to_string(), rate_version: "rate_manifest".to_string() }; + let raw = crate::RateCardChangeSetV1 { change_set_id: "manifest_v1".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_manifest".to_string()], expected_target_count: 1, manifest_hash: "arbitrary".to_string(), staged_entry_count: 0, approval_ref: "approval_manifest".to_string(), audit_ref: "audit_manifest".to_string(), activation_epoch: 1, applied: false }; + assert!(matches!(ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: raw.clone(), entries: vec![rate.clone()] })).await, Err(CostsError::RateChangeSetConflict { .. }))); + let before_boundary = RateCardEntry { effective_at: 0, ..rate.clone() }; + let before_boundary_envelope = canonical_changeset(raw.clone(), &[before_boundary.clone()]); + assert!(matches!(ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: before_boundary_envelope, entries: vec![before_boundary] })).await, Err(CostsError::InvalidField { field: "rate_effective_at_before_activation_epoch" }))); + let envelope = canonical_changeset(raw, &[rate.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: envelope.clone(), entries: vec![rate.clone()] })).await.unwrap(); + + let mut altered_targets = envelope.clone(); + altered_targets.target_account_ids = Vec::new(); + altered_targets.expected_target_count = 0; + altered_targets.manifest_hash = altered_targets.expected_manifest_hash(&[rate.clone()]); + assert!(matches!(ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: altered_targets, entries: vec![rate.clone()] })).await, Err(CostsError::RateChangeSetConflict { .. }))); + + let altered_entry = RateCardEntry { credits: 4, ..rate.clone() }; + let altered_entry_envelope = canonical_changeset(envelope.clone(), &[altered_entry.clone()]); + assert!(matches!(ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: altered_entry_envelope, entries: vec![altered_entry] })).await, Err(CostsError::RateChangeSetConflict { .. }))); + + let outcomes = ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: envelope.clone(), entries: vec![rate] })).await.unwrap(); + let completion = outcomes.iter().filter(|outcome| outcome.kind == LedgerMutationKind::RateCardCompleted).collect::>(); + assert_eq!(completion.len(), 1); + assert!(completion[0].has_rate_card_completion); + assert_eq!(completion[0].rate_card_completion.manifest_hash, envelope.manifest_hash); + assert_eq!(completion[0].rate_card_completion.target_count, 1); + assert_eq!(completion[0].rate_card_completion.affected_rates.len(), 2); + let finality = FinalizedOutcomeV1::from_finalized_mutation(completion[0], Transaction::sign(&admin, 99, CostsOperation::RotateAdmin { replacement: address(&PrivateKey::ed25519_from_seed(143)) }).digest(), 100); + assert_eq!(finality.event_type, "pricing.rate_card_updated"); + let finality_completion = finality.rate_card_completion.unwrap(); + assert_eq!(finality_completion.manifest_hash, envelope.manifest_hash); + assert!(finality_completion.affected_rates.iter().any(|rate| rate.account_id == "account_manifest" && rate.rate_version == "rate_manifest")); + assert!(ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 3, CostsOperation::ApplyRateCardChangeSetV1 { change_set: envelope, entries: vec![RateCardEntry { account_id: String::new(), event_category: "sms.send".to_string(), task_key: String::new(), credits: 3, effective_at: 10, expires_at: 0, policy_version: "policy_manifest".to_string(), rate_version: "rate_manifest".to_string() }] })).await.unwrap().is_empty()); + }); +} + +#[test] +fn global_task_does_not_materialize_over_an_onboarded_account_category_and_account_task_wins() { + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(142); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + let mut ledger = CostsLedger::new(&mut state); + ledger.apply_transaction(&Transaction::sign(&admin, 0, onboard("account_scope_order"))).await.unwrap(); + let account_category = RateCardEntry { account_id: "account_scope_order".to_string(), event_category: "creative.render".to_string(), task_key: String::new(), credits: 7, effective_at: 10, expires_at: 0, policy_version: "policy_scope".to_string(), rate_version: "account_category".to_string() }; + let account_category_set = canonical_changeset(crate::RateCardChangeSetV1 { change_set_id: "account_category_set".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_scope_order".to_string()], expected_target_count: 1, manifest_hash: "placeholder".to_string(), staged_entry_count: 0, approval_ref: "approval_scope".to_string(), audit_ref: "audit_scope".to_string(), activation_epoch: 1, applied: false }, &[account_category.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 1, CostsOperation::StageRateCardChangeSetV1 { change_set: account_category_set.clone(), entries: vec![account_category.clone()] })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&admin, 2, CostsOperation::ApplyRateCardChangeSetV1 { change_set: account_category_set, entries: vec![account_category] })).await.unwrap(); + + let global_task = RateCardEntry { account_id: String::new(), event_category: "creative.render".to_string(), task_key: "image_hd".to_string(), credits: 5, effective_at: 20, expires_at: 0, policy_version: "policy_scope".to_string(), rate_version: "global_task".to_string() }; + let global_set = canonical_changeset(crate::RateCardChangeSetV1 { change_set_id: "global_task_set".to_string(), expected_entry_count: 1, target_account_ids: Vec::new(), expected_target_count: 0, manifest_hash: "placeholder".to_string(), staged_entry_count: 0, approval_ref: "approval_scope".to_string(), audit_ref: "audit_scope".to_string(), activation_epoch: 3, applied: false }, &[global_task.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 3, CostsOperation::StageRateCardChangeSetV1 { change_set: global_set.clone(), entries: vec![global_task.clone()] })).await.unwrap(); + let outcomes = ledger.apply_transaction_with_outcomes(&Transaction::sign(&admin, 4, CostsOperation::ApplyRateCardChangeSetV1 { change_set: global_set, entries: vec![global_task] })).await.unwrap(); + assert!(!outcomes.iter().any(|outcome| outcome.kind == LedgerMutationKind::RateCardApplied && outcome.account_id == "account_scope_order")); + assert_eq!(ledger.active_rate("account_scope_order", "creative.render", "image_hd", 20).await.unwrap().unwrap().rate_version, "account_category"); + + let account_task = RateCardEntry { account_id: "account_scope_order".to_string(), event_category: "creative.render".to_string(), task_key: "image_hd".to_string(), credits: 9, effective_at: 30, expires_at: 0, policy_version: "policy_scope".to_string(), rate_version: "account_task".to_string() }; + let account_task_set = canonical_changeset(crate::RateCardChangeSetV1 { change_set_id: "account_task_set".to_string(), expected_entry_count: 1, target_account_ids: vec!["account_scope_order".to_string()], expected_target_count: 1, manifest_hash: "placeholder".to_string(), staged_entry_count: 0, approval_ref: "approval_scope".to_string(), audit_ref: "audit_scope".to_string(), activation_epoch: 5, applied: false }, &[account_task.clone()]); + ledger.apply_transaction(&Transaction::sign(&admin, 5, CostsOperation::StageRateCardChangeSetV1 { change_set: account_task_set.clone(), entries: vec![account_task.clone()] })).await.unwrap(); + ledger.apply_transaction(&Transaction::sign(&admin, 6, CostsOperation::ApplyRateCardChangeSetV1 { change_set: account_task_set, entries: vec![account_task] })).await.unwrap(); + assert_eq!(ledger.active_rate("account_scope_order", "creative.render", "image_hd", 30).await.unwrap().unwrap().rate_version, "account_task"); + }); +} + +#[test] +fn grant_taxonomy_e2e_periodic_campaign_and_topup() { + use crate::grant::{ + credit_grant_op, periodic_grant_metadata, campaign_grant_metadata, REASON_INCLUDED_CREDITS, + REASON_PROMOTION, REASON_TOPUP, + }; + + futures::executor::block_on(async { + let admin = PrivateKey::ed25519_from_seed(200); + let billing = PrivateKey::ed25519_from_seed(201); + let adjustment = PrivateKey::ed25519_from_seed(202); + let mut state = MemoryState::default(); + state.set_writer(WriterRole::Admin, &address(&admin), true); + state.set_writer(WriterRole::Billing, &address(&billing), true); + state.set_writer(WriterRole::Adjustment, &address(&adjustment), true); + let mut ledger = CostsLedger::new(&mut state); + + ledger + .apply_transaction(&Transaction::sign( + &admin, + 0, + onboard("account_grants"), + )) + .await + .unwrap(); + + // periodic program monthly grant (idempotent) + let periodic = credit_grant_op( + "account_grants".to_string(), + 3_000, + periodic_grant_metadata("account_grants", "2026-07", REASON_INCLUDED_CREDITS, "policy_auto"), + ); + ledger + .apply_transaction(&Transaction::sign(&adjustment, 0, periodic.clone())) + .await + .unwrap(); + ledger + .apply_transaction(&Transaction::sign(&adjustment, 1, periodic)) + .await + .unwrap(); + + // Campaign campaign grant + let campaign = credit_grant_op( + "account_grants".to_string(), + 500, + campaign_grant_metadata( + "launch_2026q3", + "account_grants", + REASON_PROMOTION, + "csm_approval_1", + "audit_promo_1", + ), + ); + ledger + .apply_transaction(&Transaction::sign(&adjustment, 2, campaign)) + .await + .unwrap(); + + // payment rail top-up with bonus baked into total credits + ledger + .apply_transaction(&Transaction::sign( + &billing, + 0, + CostsOperation::CreditTopup { + account_id: "account_grants".to_string(), + credits: 8_888, + rail_ref: "pi_showcase_bonus".to_string(), + }, + )) + .await + .unwrap(); + + let account = ledger.account("account_grants").await.unwrap().unwrap(); + assert_eq!(account.available_credits, 3_000 + 500 + 8_888); + + let journal = ledger.journal(0, u16::MAX).await.unwrap(); + let grant_entries: Vec<_> = journal + .iter() + .filter(|e| e.kind == LedgerMutationKind::BalanceChanged && e.reason_code != REASON_TOPUP) + .collect(); + assert_eq!(grant_entries.len(), 2); + assert_eq!(grant_entries[0].reason_code, REASON_INCLUDED_CREDITS); + assert_eq!(grant_entries[1].reason_code, REASON_PROMOTION); + + let topup_entries: Vec<_> = journal + .iter() + .filter(|e| e.reason_code == REASON_TOPUP) + .collect(); + assert_eq!(topup_entries.len(), 1); + assert_eq!(topup_entries[0].credit_delta, 8_888); + }); +} diff --git a/costs/src/transaction.rs b/costs/src/transaction.rs new file mode 100644 index 0000000..f057b37 --- /dev/null +++ b/costs/src/transaction.rs @@ -0,0 +1,565 @@ +use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use nunchi_common::Operation; + +use crate::{ + types::{identifier_encode_size, read_identifier, write_identifier}, + AdjustmentKind, AdjustmentMetadata, CreditGrantV2, CreditTopupV2, QuoteV1, + RateCardChangeSetV1, RateCardEntry, RefundPaidLotV1, ReservationActionMetadataV1, + SpendRecordV1, StatusChangeMetadataV1, StoredValueSpendV2, UntrackedSourceV1, WriterRole, + COSTS_NAMESPACE, +}; + +const OP_REGISTER_SITE: u8 = 0; +const OP_SET_WRITER: u8 = 1; +const OP_CREDIT_TOPUP: u8 = 2; +const OP_RECORD_SPEND_BATCH: u8 = 3; +const OP_SET_SITE_STATUS: u8 = 4; +const OP_CREDIT_GRANT: u8 = 5; +const OP_CREDIT_REVERSAL: u8 = 6; +const OP_RESERVE_CREDITS: u8 = 7; +const OP_RELEASE_RESERVATION: u8 = 8; +const OP_SETTLE_SPEND: u8 = 9; +const OP_REGISTER_UNTRACKED_SOURCE: u8 = 10; +const OP_STAGE_RATE_CARD_ENTRIES: u8 = 11; +const OP_APPLY_RATE_CARD_CHANGE_SET: u8 = 12; +const OP_ONBOARD_SITE: u8 = 13; +const OP_ROTATE_ADMIN: u8 = 14; +const OP_EXPIRE_RESERVATION: u8 = 15; +const OP_CREDIT_ADJUSTMENT_V1: u8 = 16; +const OP_REGISTER_UNTRACKED_SOURCE_V1: u8 = 17; +const OP_SET_SITE_WRITER: u8 = 18; +const OP_SET_SITE_STATUS_V1: u8 = 19; +const OP_STAGE_RATE_CARD_CHANGE_SET_V1: u8 = 20; +const OP_APPLY_RATE_CARD_CHANGE_SET_V1: u8 = 21; +const OP_RESERVE_CREDITS_V1: u8 = 22; +const OP_SETTLE_SPEND_V1: u8 = 23; +const OP_RELEASE_RESERVATION_V1: u8 = 24; +const OP_EXPIRE_RESERVATION_V1: u8 = 25; +const OP_STORED_VALUE_TOPUP_V2: u8 = 26; +const OP_STORED_VALUE_GRANT_V2: u8 = 27; +const OP_STORED_VALUE_SPEND_V2: u8 = 28; +const OP_REFUND_PAID_LOT_V1: u8 = 29; +const OP_RESERVE_STORED_VALUE_V2: u8 = 30; +const OP_RELEASE_STORED_VALUE_RESERVATION_V2: u8 = 31; +const OP_EXPIRE_STORED_VALUE_RESERVATION_V2: u8 = 32; +const OP_SETTLE_STORED_VALUE_RESERVATION_V2: u8 = 33; + +/// Signed state transitions supported by the costs module. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CostsOperation { + RegisterAccount { account_id: String }, + /// Idempotent programmatic onboarding. All fields are opaque service + /// references and must be authorized by an administrator signer. + CreateAccount { + account_id: String, + external_ref: String, + policy_ref: String, + cohort_ref: String, + created_at: u64, + }, + SetWriter { role: WriterRole, writer: nunchi_common::Address, enabled: bool }, + /// Grants/revokes a backend capability for precisely one account. Global + /// `SetWriter` remains the break-glass system capability. + SetAccountWriter { account_id: String, role: WriterRole, writer: nunchi_common::Address, enabled: bool }, + /// Safe capability handoff: only the current administrator can remove its + /// own capability after enabling the replacement. + RotateAdmin { replacement: nunchi_common::Address }, + CreditTopup { account_id: String, credits: u64, rail_ref: String }, + /// Provenance-preserving paid credit ingress. This is the clean-state + /// stored-value command; legacy aggregate `CreditTopup` remains metering-only. + StoredValueTopupV2 { topup: CreditTopupV2 }, + /// Immutable non-refundable grant lot with operator approval evidence. + StoredValueGrantV2 { grant: CreditGrantV2 }, + /// Deterministic grant-first lot allocation for a finalized spend event. + StoredValueSpendV2 { spend: StoredValueSpendV2 }, + /// Support-mediated refund of unused paid credit to its originating rail. + RefundPaidLotV1 { refund: RefundPaidLotV1 }, + /// Grant-first lot reservation for a long-running action. The reservation + /// is private ledger state, never a public client-side balance mutation. + ReserveStoredValueV2 { + reservation_id: String, + account_id: String, + credits: u64, + expires_at: u64, + reserved_at: u64, + }, + ReleaseStoredValueReservationV2 { reservation_id: String, released_at: u64 }, + ExpireStoredValueReservationV2 { reservation_id: String, expired_at: u64 }, + SettleStoredValueReservationV2 { reservation_id: String, spend: StoredValueSpendV2 }, + RecordSpendBatch { records: Vec }, + SetAccountStatus { account_id: String, status: crate::AccountStatus }, + SetAccountStatusV1 { account_id: String, status: crate::AccountStatus, metadata: StatusChangeMetadataV1 }, + CreditGrant { account_id: String, credits: u64, grant_ref: String }, + CreditReversal { account_id: String, credits: u64, reversal_ref: String }, + ReserveCredits { + reservation_id: String, + account_id: String, + credits: u64, + expires_at: u64, + }, + /// Quote-bound fixed-price hold. Quotes are immutable snapshots and do + /// not reprice when a later card is activated. + ReserveCreditsV1 { reservation_id: String, quote: QuoteV1, lineage_ref: String, reserved_at: u64 }, + ReleaseReservation { reservation_id: String }, + /// Authorization-layer clock input. Releases only after the reservation's + /// immutable expiry and is idempotent after expiry. + ExpireReservation { reservation_id: String, expired_at: u64 }, + ReleaseReservationV1 { reservation_id: String, metadata: ReservationActionMetadataV1 }, + ExpireReservationV1 { reservation_id: String, metadata: ReservationActionMetadataV1 }, + SettleSpend { + reservation_id: String, + event_id: String, + event_category: String, + }, + SettleSpendV1 { reservation_id: String, event_id: String, event_category: String, task_key: String, quote_id: String, snapshot_hash: String, lineage_ref: String, metadata: ReservationActionMetadataV1 }, + RegisterUntrackedSource { source_id: String, reason_code: String }, + RegisterUntrackedSourceV1 { source: UntrackedSourceV1 }, + CreditAdjustmentV1 { + kind: AdjustmentKind, + account_id: String, + credits: u64, + metadata: AdjustmentMetadata, + }, + StageRateCardEntries { + change_set_id: String, + expected_entry_count: u16, + manifest_hash: String, + entries: Vec, + }, + ApplyRateCardChangeSet { + change_set_id: String, + manifest_hash: String, + entries: Vec, + }, + StageRateCardChangeSetV1 { change_set: RateCardChangeSetV1, entries: Vec }, + ApplyRateCardChangeSetV1 { change_set: RateCardChangeSetV1, entries: Vec }, +} + +impl Write for CostsOperation { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::RegisterAccount { account_id } => { + OP_REGISTER_SITE.write(buf); + write_identifier(account_id, buf); + } + Self::CreateAccount { account_id, external_ref, policy_ref, cohort_ref, created_at } => { + OP_ONBOARD_SITE.write(buf); + write_identifier(account_id, buf); + write_identifier(external_ref, buf); + write_identifier(policy_ref, buf); + write_identifier(cohort_ref, buf); + created_at.write(buf); + } + Self::SetWriter { role, writer, enabled } => { + OP_SET_WRITER.write(buf); + role.write(buf); + writer.write(buf); + enabled.write(buf); + } + Self::SetAccountWriter { account_id, role, writer, enabled } => { + OP_SET_SITE_WRITER.write(buf); + write_identifier(account_id, buf); + role.write(buf); + writer.write(buf); + enabled.write(buf); + } + Self::RotateAdmin { replacement } => { + OP_ROTATE_ADMIN.write(buf); + replacement.write(buf); + } + Self::CreditTopup { account_id, credits, rail_ref } => { + OP_CREDIT_TOPUP.write(buf); + write_identifier(account_id, buf); + credits.write(buf); + write_identifier(rail_ref, buf); + } + Self::StoredValueTopupV2 { topup } => { + OP_STORED_VALUE_TOPUP_V2.write(buf); + topup.write(buf); + } + Self::StoredValueGrantV2 { grant } => { + OP_STORED_VALUE_GRANT_V2.write(buf); + grant.write(buf); + } + Self::StoredValueSpendV2 { spend } => { + OP_STORED_VALUE_SPEND_V2.write(buf); + spend.write(buf); + } + Self::RefundPaidLotV1 { refund } => { + OP_REFUND_PAID_LOT_V1.write(buf); + refund.write(buf); + } + Self::ReserveStoredValueV2 { reservation_id, account_id, credits, expires_at, reserved_at } => { + OP_RESERVE_STORED_VALUE_V2.write(buf); + write_identifier(reservation_id, buf); + write_identifier(account_id, buf); + credits.write(buf); + expires_at.write(buf); + reserved_at.write(buf); + } + Self::ReleaseStoredValueReservationV2 { reservation_id, released_at } => { + OP_RELEASE_STORED_VALUE_RESERVATION_V2.write(buf); + write_identifier(reservation_id, buf); + released_at.write(buf); + } + Self::ExpireStoredValueReservationV2 { reservation_id, expired_at } => { + OP_EXPIRE_STORED_VALUE_RESERVATION_V2.write(buf); + write_identifier(reservation_id, buf); + expired_at.write(buf); + } + Self::SettleStoredValueReservationV2 { reservation_id, spend } => { + OP_SETTLE_STORED_VALUE_RESERVATION_V2.write(buf); + write_identifier(reservation_id, buf); + spend.write(buf); + } + Self::RecordSpendBatch { records } => { + OP_RECORD_SPEND_BATCH.write(buf); + (records.len() as u16).write(buf); + for record in records { + record.write(buf); + } + } + Self::SetAccountStatus { account_id, status } => { + OP_SET_SITE_STATUS.write(buf); + write_identifier(account_id, buf); + status.write(buf); + } + Self::SetAccountStatusV1 { account_id, status, metadata } => { OP_SET_SITE_STATUS_V1.write(buf); write_identifier(account_id, buf); status.write(buf); metadata.write(buf); } + Self::CreditGrant { account_id, credits, grant_ref } => { + OP_CREDIT_GRANT.write(buf); + write_identifier(account_id, buf); + credits.write(buf); + write_identifier(grant_ref, buf); + } + Self::CreditReversal { account_id, credits, reversal_ref } => { + OP_CREDIT_REVERSAL.write(buf); + write_identifier(account_id, buf); + credits.write(buf); + write_identifier(reversal_ref, buf); + } + Self::ReserveCredits { reservation_id, account_id, credits, expires_at } => { + OP_RESERVE_CREDITS.write(buf); + write_identifier(reservation_id, buf); + write_identifier(account_id, buf); + credits.write(buf); + expires_at.write(buf); + } + Self::ReserveCreditsV1 { reservation_id, quote, lineage_ref, reserved_at } => { OP_RESERVE_CREDITS_V1.write(buf); write_identifier(reservation_id, buf); write_quote(quote, buf); write_identifier(lineage_ref, buf); reserved_at.write(buf); } + Self::ReleaseReservation { reservation_id } => { + OP_RELEASE_RESERVATION.write(buf); + write_identifier(reservation_id, buf); + } + Self::ExpireReservation { reservation_id, expired_at } => { + OP_EXPIRE_RESERVATION.write(buf); + write_identifier(reservation_id, buf); + expired_at.write(buf); + } + Self::ReleaseReservationV1 { reservation_id, metadata } => { OP_RELEASE_RESERVATION_V1.write(buf); write_identifier(reservation_id, buf); metadata.write(buf); } + Self::ExpireReservationV1 { reservation_id, metadata } => { OP_EXPIRE_RESERVATION_V1.write(buf); write_identifier(reservation_id, buf); metadata.write(buf); } + Self::SettleSpend { reservation_id, event_id, event_category } => { + OP_SETTLE_SPEND.write(buf); + write_identifier(reservation_id, buf); + write_identifier(event_id, buf); + write_identifier(event_category, buf); + } + Self::SettleSpendV1 { reservation_id, event_id, event_category, task_key, quote_id, snapshot_hash, lineage_ref, metadata } => { OP_SETTLE_SPEND_V1.write(buf); write_identifier(reservation_id, buf); write_identifier(event_id, buf); write_identifier(event_category, buf); write_identifier(task_key, buf); write_identifier(quote_id, buf); write_identifier(snapshot_hash, buf); write_identifier(lineage_ref, buf); metadata.write(buf); } + Self::RegisterUntrackedSource { source_id, reason_code } => { + OP_REGISTER_UNTRACKED_SOURCE.write(buf); + write_identifier(source_id, buf); + write_identifier(reason_code, buf); + } + Self::RegisterUntrackedSourceV1 { source } => { + OP_REGISTER_UNTRACKED_SOURCE_V1.write(buf); + source.write(buf); + } + Self::CreditAdjustmentV1 { kind, account_id, credits, metadata } => { + OP_CREDIT_ADJUSTMENT_V1.write(buf); + kind.write(buf); + write_identifier(account_id, buf); + credits.write(buf); + metadata.write(buf); + } + Self::StageRateCardEntries { change_set_id, expected_entry_count, manifest_hash, entries } => { + OP_STAGE_RATE_CARD_ENTRIES.write(buf); + write_identifier(change_set_id, buf); + expected_entry_count.write(buf); + write_identifier(manifest_hash, buf); + (entries.len() as u16).write(buf); + for entry in entries { + entry.write(buf); + } + } + Self::ApplyRateCardChangeSet { change_set_id, manifest_hash, entries } => { + OP_APPLY_RATE_CARD_CHANGE_SET.write(buf); + write_identifier(change_set_id, buf); + write_identifier(manifest_hash, buf); + (entries.len() as u16).write(buf); + for entry in entries { + entry.write(buf); + } + } + Self::StageRateCardChangeSetV1 { change_set, entries } => { OP_STAGE_RATE_CARD_CHANGE_SET_V1.write(buf); change_set.write(buf); write_entries(entries, buf); } + Self::ApplyRateCardChangeSetV1 { change_set, entries } => { OP_APPLY_RATE_CARD_CHANGE_SET_V1.write(buf); change_set.write(buf); write_entries(entries, buf); } + } + } +} + +impl Read for CostsOperation { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + OP_REGISTER_SITE => Ok(Self::RegisterAccount { + account_id: read_identifier(buf)?, + }), + OP_ONBOARD_SITE => Ok(Self::CreateAccount { + account_id: read_identifier(buf)?, + external_ref: read_identifier(buf)?, + policy_ref: read_identifier(buf)?, + cohort_ref: read_identifier(buf)?, + created_at: u64::read(buf)?, + }), + OP_SET_WRITER => Ok(Self::SetWriter { + role: WriterRole::read(buf)?, + writer: nunchi_common::Address::read(buf)?, + enabled: bool::read(buf)?, + }), + OP_SET_SITE_WRITER => Ok(Self::SetAccountWriter { + account_id: read_identifier(buf)?, + role: WriterRole::read(buf)?, + writer: nunchi_common::Address::read(buf)?, + enabled: bool::read(buf)?, + }), + OP_ROTATE_ADMIN => Ok(Self::RotateAdmin { replacement: nunchi_common::Address::read(buf)? }), + OP_CREDIT_TOPUP => Ok(Self::CreditTopup { + account_id: read_identifier(buf)?, + credits: u64::read(buf)?, + rail_ref: read_identifier(buf)?, + }), + OP_STORED_VALUE_TOPUP_V2 => Ok(Self::StoredValueTopupV2 { + topup: CreditTopupV2::read(buf)?, + }), + OP_STORED_VALUE_GRANT_V2 => Ok(Self::StoredValueGrantV2 { + grant: CreditGrantV2::read(buf)?, + }), + OP_STORED_VALUE_SPEND_V2 => Ok(Self::StoredValueSpendV2 { + spend: StoredValueSpendV2::read(buf)?, + }), + OP_REFUND_PAID_LOT_V1 => Ok(Self::RefundPaidLotV1 { + refund: RefundPaidLotV1::read(buf)?, + }), + OP_RESERVE_STORED_VALUE_V2 => Ok(Self::ReserveStoredValueV2 { + reservation_id: read_identifier(buf)?, + account_id: read_identifier(buf)?, + credits: u64::read(buf)?, + expires_at: u64::read(buf)?, + reserved_at: u64::read(buf)?, + }), + OP_RELEASE_STORED_VALUE_RESERVATION_V2 => Ok(Self::ReleaseStoredValueReservationV2 { + reservation_id: read_identifier(buf)?, + released_at: u64::read(buf)?, + }), + OP_EXPIRE_STORED_VALUE_RESERVATION_V2 => Ok(Self::ExpireStoredValueReservationV2 { + reservation_id: read_identifier(buf)?, + expired_at: u64::read(buf)?, + }), + OP_SETTLE_STORED_VALUE_RESERVATION_V2 => Ok(Self::SettleStoredValueReservationV2 { + reservation_id: read_identifier(buf)?, + spend: StoredValueSpendV2::read(buf)?, + }), + OP_RECORD_SPEND_BATCH => { + let len = u16::read(buf)? as usize; + let mut records = Vec::with_capacity(len); + for _ in 0..len { + records.push(SpendRecordV1::read(buf)?); + } + Ok(Self::RecordSpendBatch { records }) + } + OP_SET_SITE_STATUS => Ok(Self::SetAccountStatus { + account_id: read_identifier(buf)?, + status: crate::AccountStatus::read(buf)?, + }), + OP_SET_SITE_STATUS_V1 => Ok(Self::SetAccountStatusV1 { account_id: read_identifier(buf)?, status: crate::AccountStatus::read(buf)?, metadata: StatusChangeMetadataV1::read(buf)? }), + OP_CREDIT_GRANT => Ok(Self::CreditGrant { + account_id: read_identifier(buf)?, + credits: u64::read(buf)?, + grant_ref: read_identifier(buf)?, + }), + OP_CREDIT_REVERSAL => Ok(Self::CreditReversal { + account_id: read_identifier(buf)?, + credits: u64::read(buf)?, + reversal_ref: read_identifier(buf)?, + }), + OP_RESERVE_CREDITS => Ok(Self::ReserveCredits { + reservation_id: read_identifier(buf)?, + account_id: read_identifier(buf)?, + credits: u64::read(buf)?, + expires_at: u64::read(buf)?, + }), + OP_RESERVE_CREDITS_V1 => Ok(Self::ReserveCreditsV1 { reservation_id: read_identifier(buf)?, quote: read_quote(buf)?, lineage_ref: read_identifier(buf)?, reserved_at: u64::read(buf)? }), + OP_RELEASE_RESERVATION => Ok(Self::ReleaseReservation { + reservation_id: read_identifier(buf)?, + }), + OP_EXPIRE_RESERVATION => Ok(Self::ExpireReservation { + reservation_id: read_identifier(buf)?, + expired_at: u64::read(buf)?, + }), + OP_RELEASE_RESERVATION_V1 => Ok(Self::ReleaseReservationV1 { reservation_id: read_identifier(buf)?, metadata: ReservationActionMetadataV1::read(buf)? }), + OP_EXPIRE_RESERVATION_V1 => Ok(Self::ExpireReservationV1 { reservation_id: read_identifier(buf)?, metadata: ReservationActionMetadataV1::read(buf)? }), + OP_SETTLE_SPEND => Ok(Self::SettleSpend { + reservation_id: read_identifier(buf)?, + event_id: read_identifier(buf)?, + event_category: read_identifier(buf)?, + }), + OP_SETTLE_SPEND_V1 => Ok(Self::SettleSpendV1 { reservation_id: read_identifier(buf)?, event_id: read_identifier(buf)?, event_category: read_identifier(buf)?, task_key: read_identifier(buf)?, quote_id: read_identifier(buf)?, snapshot_hash: read_identifier(buf)?, lineage_ref: read_identifier(buf)?, metadata: ReservationActionMetadataV1::read(buf)? }), + OP_REGISTER_UNTRACKED_SOURCE => Ok(Self::RegisterUntrackedSource { + source_id: read_identifier(buf)?, + reason_code: read_identifier(buf)?, + }), + OP_REGISTER_UNTRACKED_SOURCE_V1 => Ok(Self::RegisterUntrackedSourceV1 { + source: UntrackedSourceV1::read(buf)?, + }), + OP_CREDIT_ADJUSTMENT_V1 => Ok(Self::CreditAdjustmentV1 { + kind: AdjustmentKind::read(buf)?, + account_id: read_identifier(buf)?, + credits: u64::read(buf)?, + metadata: AdjustmentMetadata::read(buf)?, + }), + OP_STAGE_RATE_CARD_ENTRIES => { + let change_set_id = read_identifier(buf)?; + let expected_entry_count = u16::read(buf)?; + let manifest_hash = read_identifier(buf)?; + let len = u16::read(buf)? as usize; + let mut entries = Vec::with_capacity(len); + for _ in 0..len { + entries.push(RateCardEntry::read(buf)?); + } + Ok(Self::StageRateCardEntries { + change_set_id, + expected_entry_count, + manifest_hash, + entries, + }) + } + OP_APPLY_RATE_CARD_CHANGE_SET => { + let change_set_id = read_identifier(buf)?; + let manifest_hash = read_identifier(buf)?; + let len = u16::read(buf)? as usize; + let mut entries = Vec::with_capacity(len); + for _ in 0..len { + entries.push(RateCardEntry::read(buf)?); + } + Ok(Self::ApplyRateCardChangeSet { + change_set_id, + manifest_hash, + entries, + }) + } + OP_STAGE_RATE_CARD_CHANGE_SET_V1 => Ok(Self::StageRateCardChangeSetV1 { change_set: RateCardChangeSetV1::read(buf)?, entries: read_entries(buf)? }), + OP_APPLY_RATE_CARD_CHANGE_SET_V1 => Ok(Self::ApplyRateCardChangeSetV1 { change_set: RateCardChangeSetV1::read(buf)?, entries: read_entries(buf)? }), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for CostsOperation { + fn encode_size(&self) -> usize { + 1 + match self { + Self::RegisterAccount { account_id } => identifier_encode_size(account_id), + Self::CreateAccount { account_id, external_ref, policy_ref, cohort_ref, created_at } => { + identifier_encode_size(account_id) + identifier_encode_size(external_ref) + + identifier_encode_size(policy_ref) + identifier_encode_size(cohort_ref) + + created_at.encode_size() + } + Self::SetWriter { role, writer, enabled } => { + role.encode_size() + writer.encode_size() + enabled.encode_size() + } + Self::SetAccountWriter { account_id, role, writer, enabled } => { + identifier_encode_size(account_id) + role.encode_size() + writer.encode_size() + enabled.encode_size() + } + Self::RotateAdmin { replacement } => replacement.encode_size(), + Self::CreditTopup { account_id, credits, rail_ref } => { + identifier_encode_size(account_id) + credits.encode_size() + identifier_encode_size(rail_ref) + } + Self::StoredValueTopupV2 { topup } => topup.encode_size(), + Self::StoredValueGrantV2 { grant } => grant.encode_size(), + Self::StoredValueSpendV2 { spend } => spend.encode_size(), + Self::RefundPaidLotV1 { refund } => refund.encode_size(), + Self::ReserveStoredValueV2 { reservation_id, account_id, credits, expires_at, reserved_at } => { + identifier_encode_size(reservation_id) + identifier_encode_size(account_id) + + credits.encode_size() + expires_at.encode_size() + reserved_at.encode_size() + } + Self::ReleaseStoredValueReservationV2 { reservation_id, released_at } + | Self::ExpireStoredValueReservationV2 { reservation_id, expired_at: released_at } => { + identifier_encode_size(reservation_id) + released_at.encode_size() + } + Self::SettleStoredValueReservationV2 { reservation_id, spend } => { + identifier_encode_size(reservation_id) + spend.encode_size() + } + Self::RecordSpendBatch { records } => { + 2 + records.iter().map(EncodeSize::encode_size).sum::() + } + Self::SetAccountStatus { account_id, status } => { + identifier_encode_size(account_id) + status.encode_size() + } + Self::SetAccountStatusV1 { account_id, status, metadata } => identifier_encode_size(account_id) + status.encode_size() + metadata.encode_size(), + Self::CreditGrant { account_id, credits, grant_ref } + | Self::CreditReversal { account_id, credits, reversal_ref: grant_ref } => { + identifier_encode_size(account_id) + credits.encode_size() + identifier_encode_size(grant_ref) + } + Self::ReserveCredits { reservation_id, account_id, credits, expires_at } => { + identifier_encode_size(reservation_id) + + identifier_encode_size(account_id) + + credits.encode_size() + + expires_at.encode_size() + } + Self::ReserveCreditsV1 { reservation_id, quote, lineage_ref, reserved_at } => identifier_encode_size(reservation_id) + quote_encode_size(quote) + identifier_encode_size(lineage_ref) + reserved_at.encode_size(), + Self::ReleaseReservation { reservation_id } => identifier_encode_size(reservation_id), + Self::ExpireReservation { reservation_id, expired_at } => { + identifier_encode_size(reservation_id) + expired_at.encode_size() + } + Self::ReleaseReservationV1 { reservation_id, metadata } | Self::ExpireReservationV1 { reservation_id, metadata } => identifier_encode_size(reservation_id) + metadata.encode_size(), + Self::SettleSpend { reservation_id, event_id, event_category } => { + identifier_encode_size(reservation_id) + + identifier_encode_size(event_id) + + identifier_encode_size(event_category) + } + Self::SettleSpendV1 { reservation_id, event_id, event_category, task_key, quote_id, snapshot_hash, lineage_ref, metadata } => identifier_encode_size(reservation_id) + identifier_encode_size(event_id) + identifier_encode_size(event_category) + identifier_encode_size(task_key) + identifier_encode_size(quote_id) + identifier_encode_size(snapshot_hash) + identifier_encode_size(lineage_ref) + metadata.encode_size(), + Self::RegisterUntrackedSource { source_id, reason_code } => { + identifier_encode_size(source_id) + identifier_encode_size(reason_code) + } + Self::RegisterUntrackedSourceV1 { source } => source.encode_size(), + Self::CreditAdjustmentV1 { kind, account_id, credits, metadata } => { + kind.encode_size() + identifier_encode_size(account_id) + credits.encode_size() + + metadata.encode_size() + } + Self::StageRateCardEntries { change_set_id, expected_entry_count, manifest_hash, entries } => { + identifier_encode_size(change_set_id) + + expected_entry_count.encode_size() + + identifier_encode_size(manifest_hash) + + 2 + + entries.iter().map(EncodeSize::encode_size).sum::() + } + Self::ApplyRateCardChangeSet { change_set_id, manifest_hash, entries } => { + identifier_encode_size(change_set_id) + + identifier_encode_size(manifest_hash) + + 2 + + entries.iter().map(EncodeSize::encode_size).sum::() + } + Self::StageRateCardChangeSetV1 { change_set, entries } | Self::ApplyRateCardChangeSetV1 { change_set, entries } => change_set.encode_size() + 2 + entries.iter().map(EncodeSize::encode_size).sum::(), + } + } +} + +fn write_entries(entries: &[RateCardEntry], buf: &mut impl bytes::BufMut) { (entries.len() as u16).write(buf); for entry in entries { entry.write(buf); } } +fn read_entries(buf: &mut impl bytes::Buf) -> Result, Error> { let len = u16::read(buf)? as usize; let mut entries = Vec::with_capacity(len); for _ in 0..len { entries.push(RateCardEntry::read(buf)?); } Ok(entries) } +fn write_quote(quote: &QuoteV1, buf: &mut impl bytes::BufMut) { write_identifier("e.quote_id, buf); write_identifier("e.snapshot_hash, buf); write_identifier("e.account_id, buf); write_identifier("e.event_category, buf); write_identifier("e.task_key, buf); quote.quoted_at.write(buf); quote.credits_per_unit.write(buf); quote.quantity.write(buf); quote.total_credits.write(buf); write_identifier("e.policy_version, buf); write_identifier("e.rate_version, buf); quote.expires_at.write(buf); } +fn read_quote(buf: &mut impl bytes::Buf) -> Result { Ok(QuoteV1 { quote_id: read_identifier(buf)?, snapshot_hash: read_identifier(buf)?, account_id: read_identifier(buf)?, event_category: read_identifier(buf)?, task_key: read_identifier(buf)?, quoted_at: u64::read(buf)?, credits_per_unit: u64::read(buf)?, quantity: u64::read(buf)?, total_credits: u64::read(buf)?, policy_version: read_identifier(buf)?, rate_version: read_identifier(buf)?, expires_at: u64::read(buf)? }) } +fn quote_encode_size(quote: &QuoteV1) -> usize { identifier_encode_size("e.quote_id) + identifier_encode_size("e.snapshot_hash) + identifier_encode_size("e.account_id) + identifier_encode_size("e.event_category) + identifier_encode_size("e.task_key) + quote.quoted_at.encode_size() + quote.credits_per_unit.encode_size() + quote.quantity.encode_size() + quote.total_credits.encode_size() + identifier_encode_size("e.policy_version) + identifier_encode_size("e.rate_version) + quote.expires_at.encode_size() } + +impl Operation for CostsOperation { + const NAMESPACE: &'static [u8] = COSTS_NAMESPACE; +} + +pub type TransactionPayload = nunchi_common::TransactionPayload; +pub type Transaction = nunchi_common::Transaction; diff --git a/costs/src/types.rs b/costs/src/types.rs new file mode 100644 index 0000000..f8f3c52 --- /dev/null +++ b/costs/src/types.rs @@ -0,0 +1,1121 @@ +use bytes::Buf; +use commonware_codec::{EncodeSize, Error, Read, ReadExt, Write}; +use commonware_cryptography::{Hasher, Sha256}; + +const STATUS_ACTIVE: u8 = 0; +const STATUS_SUSPENDED: u8 = 1; +const ROLE_ADMIN: u8 = 0; +const ROLE_INGEST: u8 = 1; +const ROLE_BILLING: u8 = 2; +const ROLE_ADJUSTMENT: u8 = 3; +const RESERVATION_ACTIVE: u8 = 0; +const RESERVATION_RELEASED: u8 = 1; +const RESERVATION_SETTLED: u8 = 2; +const RESERVATION_EXPIRED: u8 = 3; +const ADJUSTMENT_GRANT: u8 = 0; +const ADJUSTMENT_REVERSAL: u8 = 1; +const JOURNAL_ONBOARDED: u8 = 0; +const JOURNAL_BALANCE_CHANGED: u8 = 1; +const JOURNAL_SPEND_RECORDED: u8 = 2; +const JOURNAL_STATUS_CHANGED: u8 = 3; +const JOURNAL_RESERVATION_CHANGED: u8 = 4; +const JOURNAL_RATE_STAGED: u8 = 5; +const JOURNAL_RATE_APPLIED: u8 = 6; +const JOURNAL_UNTRACKED_SOURCE_REGISTERED: u8 = 7; +const JOURNAL_RATE_GLOBAL_APPLIED: u8 = 8; +const JOURNAL_RATE_CARD_COMPLETED: u8 = 9; +const BALANCE_DIRECTION_NONE: u8 = 0; +const BALANCE_DIRECTION_CREDIT: u8 = 1; +const BALANCE_DIRECTION_DEBIT: u8 = 2; + +/// The largest wire value accepted for an identifier before ledger-level policy +/// applies its tighter domain-specific limits. +const MAX_WIRE_IDENTIFIER_LEN: usize = 1024; + +pub(crate) fn write_identifier(value: &str, buf: &mut impl bytes::BufMut) { + let length = u16::try_from(value.len()) + .expect("costs identifier exceeds the u16 transaction encoding limit"); + length.write(buf); + buf.put_slice(value.as_bytes()); +} + +pub(crate) fn read_identifier(buf: &mut impl bytes::Buf) -> Result { + let length = u16::read(buf)? as usize; + if length > MAX_WIRE_IDENTIFIER_LEN { + return Err(Error::InvalidLength(length)); + } + if buf.remaining() < length { + return Err(Error::EndOfBuffer); + } + String::from_utf8(buf.copy_to_bytes(length).to_vec()) + .map_err(|_| Error::Invalid("costs identifier", "must be valid UTF-8")) +} + +pub(crate) fn identifier_encode_size(value: &str) -> usize { + u16::default().encode_size() + value.len() +} + +/// Whether a account account may settle new spend. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccountStatus { + Active, + Suspended, +} + +impl Write for AccountStatus { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Active => STATUS_ACTIVE.write(buf), + Self::Suspended => STATUS_SUSPENDED.write(buf), + } + } +} + +impl Read for AccountStatus { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + STATUS_ACTIVE => Ok(Self::Active), + STATUS_SUSPENDED => Ok(Self::Suspended), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for AccountStatus { + fn encode_size(&self) -> usize { + 1 + } +} + +/// Backend capability required for a costs operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WriterRole { + Admin, + Ingest, + Billing, + /// Authorized finance service for grants and post-settlement corrections. + Adjustment, +} + +impl WriterRole { + pub(crate) const fn tag(self) -> u8 { + match self { + Self::Admin => ROLE_ADMIN, + Self::Ingest => ROLE_INGEST, + Self::Billing => ROLE_BILLING, + Self::Adjustment => ROLE_ADJUSTMENT, + } + } +} + +impl Write for WriterRole { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.tag().write(buf); + } +} + +impl Read for WriterRole { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + ROLE_ADMIN => Ok(Self::Admin), + ROLE_INGEST => Ok(Self::Ingest), + ROLE_BILLING => Ok(Self::Billing), + ROLE_ADJUSTMENT => Ok(Self::Adjustment), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for WriterRole { + fn encode_size(&self) -> usize { + 1 + } +} + +/// The on-chain credit state for one opaque client account. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreditAccount { + pub status: AccountStatus, + pub available_credits: u64, + pub reserved_credits: u64, +} + +impl CreditAccount { + pub const fn active() -> Self { + Self { + status: AccountStatus::Active, + available_credits: 0, + reserved_credits: 0, + } + } +} + +impl Write for CreditAccount { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.status.write(buf); + self.available_credits.write(buf); + self.reserved_credits.write(buf); + } +} + +impl Read for CreditAccount { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + status: AccountStatus::read(buf)?, + available_credits: u64::read(buf)?, + reserved_credits: u64::read(buf)?, + }) + } +} + +impl EncodeSize for CreditAccount { + fn encode_size(&self) -> usize { + self.status.encode_size() + + self.available_credits.encode_size() + + self.reserved_credits.encode_size() + } +} + +/// Lifecycle state for a hold against a account's available credit balance. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReservationStatus { + Active, + Released, + Settled, + /// Released by an authorized expiry sweep after `expires_at`. + Expired, +} + +impl Write for ReservationStatus { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Active => RESERVATION_ACTIVE.write(buf), + Self::Released => RESERVATION_RELEASED.write(buf), + Self::Settled => RESERVATION_SETTLED.write(buf), + Self::Expired => RESERVATION_EXPIRED.write(buf), + } + } +} + +impl Read for ReservationStatus { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + RESERVATION_ACTIVE => Ok(Self::Active), + RESERVATION_RELEASED => Ok(Self::Released), + RESERVATION_SETTLED => Ok(Self::Settled), + RESERVATION_EXPIRED => Ok(Self::Expired), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for ReservationStatus { + fn encode_size(&self) -> usize { + 1 + } +} + +/// A deterministic hold for a long-running priced action. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Reservation { + pub reservation_id: String, + pub account_id: String, + /// Full immutable V1 price snapshot. A later rate activation must never + /// alter a hold that has already been authorized. + pub quote: QuoteV1, + /// Opaque source lineage bound at authorization time. Settlement must + /// present the identical lineage, preventing a quote from being reused + /// for a different measured action. + pub lineage_ref: String, + pub credits: u64, + /// Unix seconds supplied by the authorization layer; expiration is released + /// only by an explicit, authorized transaction. + pub expires_at: u64, + pub status: ReservationStatus, +} + +/// Immutable account-account metadata. This is deliberately separate from the +/// balance record so that a future account-profile read model cannot mutate +/// custodial credits. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountProfile { + pub account_id: String, + pub external_ref: String, + /// Opaque internal program identifier; this is not an end-user identity. + pub policy_ref: String, + pub created_at: u64, + pub cohort_ref: String, + pub status_reason: String, + pub status_changed_at: u64, +} + +impl Write for AccountProfile { + fn write(&self, buf: &mut impl bytes::BufMut) { + write_identifier(&self.account_id, buf); + write_identifier(&self.external_ref, buf); + write_identifier(&self.policy_ref, buf); + self.created_at.write(buf); + write_identifier(&self.cohort_ref, buf); + write_identifier(&self.status_reason, buf); + self.status_changed_at.write(buf); + } +} + +impl Read for AccountProfile { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + account_id: read_identifier(buf)?, + external_ref: read_identifier(buf)?, + policy_ref: read_identifier(buf)?, + created_at: u64::read(buf)?, + cohort_ref: read_identifier(buf)?, + status_reason: read_identifier(buf)?, + status_changed_at: u64::read(buf)?, + }) + } +} + +impl EncodeSize for AccountProfile { + fn encode_size(&self) -> usize { + identifier_encode_size(&self.account_id) + + identifier_encode_size(&self.external_ref) + + identifier_encode_size(&self.policy_ref) + + self.created_at.encode_size() + + identifier_encode_size(&self.cohort_ref) + + identifier_encode_size(&self.status_reason) + + self.status_changed_at.encode_size() + } +} + +/// Append-only account-status history item. Reasons are opaque policy codes, +/// never employee names or free-form client data. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StatusHistoryEntry { + pub sequence: u64, + pub status: AccountStatus, + pub reason_code: String, + pub changed_at: u64, + pub approval_ref: String, + pub audit_ref: String, +} + +/// PII-safe control-plane evidence required for a V1 account-status change. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StatusChangeMetadataV1 { + pub reason_code: String, + pub changed_at: u64, + pub approval_ref: String, + pub audit_ref: String, +} + +/// Evidence for a V1 reservation lifecycle mutation. The chain stores only +/// opaque control-plane references; approval detail remains off-chain. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReservationActionMetadataV1 { + pub reason_code: String, + pub occurred_at: u64, + pub approval_ref: String, + pub audit_ref: String, +} + +impl Write for ReservationActionMetadataV1 { + fn write(&self, buf: &mut impl bytes::BufMut) { + write_identifier(&self.reason_code, buf); + self.occurred_at.write(buf); + write_identifier(&self.approval_ref, buf); + write_identifier(&self.audit_ref, buf); + } +} +impl Read for ReservationActionMetadataV1 { + type Cfg = (); + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { reason_code: read_identifier(buf)?, occurred_at: u64::read(buf)?, approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)? }) + } +} +impl EncodeSize for ReservationActionMetadataV1 { + fn encode_size(&self) -> usize { + identifier_encode_size(&self.reason_code) + self.occurred_at.encode_size() + + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref) + } +} + +impl Write for StatusChangeMetadataV1 { + fn write(&self, buf: &mut impl bytes::BufMut) { + write_identifier(&self.reason_code, buf); + self.changed_at.write(buf); + write_identifier(&self.approval_ref, buf); + write_identifier(&self.audit_ref, buf); + } +} +impl Read for StatusChangeMetadataV1 { + type Cfg = (); + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { reason_code: read_identifier(buf)?, changed_at: u64::read(buf)?, approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)? }) + } +} +impl EncodeSize for StatusChangeMetadataV1 { + fn encode_size(&self) -> usize { identifier_encode_size(&self.reason_code) + self.changed_at.encode_size() + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref) } +} + +/// Credit adjustment direction. Grants add credits; reversals debit only +/// available credits and can never consume a separate reservation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AdjustmentKind { + Grant, + Reversal, +} + +impl Write for AdjustmentKind { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::Grant => ADJUSTMENT_GRANT.write(buf), + Self::Reversal => ADJUSTMENT_REVERSAL.write(buf), + } + } +} + +impl Read for AdjustmentKind { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + ADJUSTMENT_GRANT => Ok(Self::Grant), + ADJUSTMENT_REVERSAL => Ok(Self::Reversal), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for AdjustmentKind { + fn encode_size(&self) -> usize { 1 } +} + +/// PII-safe, auditable metadata attached to an idempotent credit adjustment. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdjustmentMetadata { + pub reference: String, + pub reason_code: String, + pub period_ref: String, + /// Off-chain approval and audit correlation identifiers. They are opaque + /// references only; employee approval state remains outside the chain. + pub approval_ref: String, + pub audit_ref: String, +} + +impl Write for AdjustmentMetadata { + fn write(&self, buf: &mut impl bytes::BufMut) { + write_identifier(&self.reference, buf); + write_identifier(&self.reason_code, buf); + write_identifier(&self.period_ref, buf); + write_identifier(&self.approval_ref, buf); + write_identifier(&self.audit_ref, buf); + } +} + +impl Read for AdjustmentMetadata { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + reference: read_identifier(buf)?, + reason_code: read_identifier(buf)?, + period_ref: read_identifier(buf)?, + approval_ref: read_identifier(buf)?, + audit_ref: read_identifier(buf)?, + }) + } +} + +impl EncodeSize for AdjustmentMetadata { + fn encode_size(&self) -> usize { + identifier_encode_size(&self.reference) + + identifier_encode_size(&self.reason_code) + + identifier_encode_size(&self.period_ref) + + identifier_encode_size(&self.approval_ref) + + identifier_encode_size(&self.audit_ref) + } +} + +/// A coverage-only record for shared or dark cost. It cannot debit a client. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UntrackedSourceV1 { + pub source_id: String, + pub reason_code: String, + pub owner_ref: String, + pub period_ref: String, + pub provenance_ref: String, + /// Coverage and confidence are controlled opaque taxonomy codes such as + /// `cost_dark` and `unverified`; evidence remains an opaque reference. + pub coverage_code: String, + pub confidence_code: String, + pub evidence_ref: String, + pub cohort_ref: String, +} + +/// Client-safe, private account read returned to an authorized backend/BFF. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountReadV1 { + pub account: CreditAccount, + pub profile: AccountProfile, +} + +/// A credit-only quote resolved from an applied rate card. It contains no +/// provider COGS, margin, confidence, or approval metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct QuoteV1 { + pub quote_id: String, + pub snapshot_hash: String, + pub account_id: String, + pub event_category: String, + pub task_key: String, + pub quoted_at: u64, + pub credits_per_unit: u64, + pub quantity: u64, + pub total_credits: u64, + pub policy_version: String, + pub rate_version: String, + pub expires_at: u64, +} + +pub(crate) fn write_quote_snapshot(quote: &QuoteV1, buf: &mut impl bytes::BufMut) { + write_identifier("e.quote_id, buf); + write_identifier("e.snapshot_hash, buf); + write_identifier("e.account_id, buf); + write_identifier("e.event_category, buf); + write_identifier("e.task_key, buf); + quote.quoted_at.write(buf); + quote.credits_per_unit.write(buf); + quote.quantity.write(buf); + quote.total_credits.write(buf); + write_identifier("e.policy_version, buf); + write_identifier("e.rate_version, buf); + quote.expires_at.write(buf); +} + +pub(crate) fn read_quote_snapshot(buf: &mut impl bytes::Buf) -> Result { + Ok(QuoteV1 { + quote_id: read_identifier(buf)?, snapshot_hash: read_identifier(buf)?, + account_id: read_identifier(buf)?, event_category: read_identifier(buf)?, + task_key: read_identifier(buf)?, quoted_at: u64::read(buf)?, + credits_per_unit: u64::read(buf)?, quantity: u64::read(buf)?, + total_credits: u64::read(buf)?, policy_version: read_identifier(buf)?, + rate_version: read_identifier(buf)?, expires_at: u64::read(buf)?, + }) +} + +pub(crate) fn quote_snapshot_encode_size(quote: &QuoteV1) -> usize { + identifier_encode_size("e.quote_id) + identifier_encode_size("e.snapshot_hash) + + identifier_encode_size("e.account_id) + identifier_encode_size("e.event_category) + + identifier_encode_size("e.task_key) + quote.quoted_at.encode_size() + + quote.credits_per_unit.encode_size() + quote.quantity.encode_size() + + quote.total_credits.encode_size() + identifier_encode_size("e.policy_version) + + identifier_encode_size("e.rate_version) + quote.expires_at.encode_size() +} + +/// A private request to price a specific quantity. The client receives only +/// the resulting credit quote, not COGS or policy approval detail. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct QuoteRequestV1 { + pub account_id: String, + pub event_category: String, + pub task_key: String, + pub quantity: u64, + pub quoted_at: u64, + pub expires_at: u64, +} + +/// The typed category of a persisted, post-state ledger mutation. This is a +/// private read contract for the finality sink and operational projections; +/// it is never an input transaction and cannot authorize a debit. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LedgerMutationKind { + AccountOnboarded, + BalanceChanged, + SpendRecorded, + AccountStatusChanged, + ReservationChanged, + RateCardStaged, + RateCardApplied, + RateCardGlobalApplied, + RateCardCompleted, + UntrackedSourceRegistered, +} + +/// Explicit financial direction for a persisted balance mutation. A finality +/// sink must consume this field rather than infer a debit/credit from the +/// transaction that happened to produce the mutation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BalanceMutationDirection { None, Credit, Debit } + +impl Write for BalanceMutationDirection { fn write(&self, buf: &mut impl bytes::BufMut) { match self { Self::None => BALANCE_DIRECTION_NONE, Self::Credit => BALANCE_DIRECTION_CREDIT, Self::Debit => BALANCE_DIRECTION_DEBIT }.write(buf); } } +impl Read for BalanceMutationDirection { type Cfg = (); fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { match u8::read(buf)? { BALANCE_DIRECTION_NONE => Ok(Self::None), BALANCE_DIRECTION_CREDIT => Ok(Self::Credit), BALANCE_DIRECTION_DEBIT => Ok(Self::Debit), tag => Err(Error::InvalidEnum(tag)) } } } +impl EncodeSize for BalanceMutationDirection { fn encode_size(&self) -> usize { 1 } } + +impl Write for LedgerMutationKind { + fn write(&self, buf: &mut impl bytes::BufMut) { + match self { + Self::AccountOnboarded => JOURNAL_ONBOARDED.write(buf), + Self::BalanceChanged => JOURNAL_BALANCE_CHANGED.write(buf), + Self::SpendRecorded => JOURNAL_SPEND_RECORDED.write(buf), + Self::AccountStatusChanged => JOURNAL_STATUS_CHANGED.write(buf), + Self::ReservationChanged => JOURNAL_RESERVATION_CHANGED.write(buf), + Self::RateCardStaged => JOURNAL_RATE_STAGED.write(buf), + Self::RateCardApplied => JOURNAL_RATE_APPLIED.write(buf), + Self::RateCardGlobalApplied => JOURNAL_RATE_GLOBAL_APPLIED.write(buf), + Self::RateCardCompleted => JOURNAL_RATE_CARD_COMPLETED.write(buf), + Self::UntrackedSourceRegistered => JOURNAL_UNTRACKED_SOURCE_REGISTERED.write(buf), + } + } +} + +impl Read for LedgerMutationKind { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + match u8::read(buf)? { + JOURNAL_ONBOARDED => Ok(Self::AccountOnboarded), + JOURNAL_BALANCE_CHANGED => Ok(Self::BalanceChanged), + JOURNAL_SPEND_RECORDED => Ok(Self::SpendRecorded), + JOURNAL_STATUS_CHANGED => Ok(Self::AccountStatusChanged), + JOURNAL_RESERVATION_CHANGED => Ok(Self::ReservationChanged), + JOURNAL_RATE_STAGED => Ok(Self::RateCardStaged), + JOURNAL_RATE_APPLIED => Ok(Self::RateCardApplied), + JOURNAL_RATE_GLOBAL_APPLIED => Ok(Self::RateCardGlobalApplied), + JOURNAL_RATE_CARD_COMPLETED => Ok(Self::RateCardCompleted), + JOURNAL_UNTRACKED_SOURCE_REGISTERED => Ok(Self::UntrackedSourceRegistered), + tag => Err(Error::InvalidEnum(tag)), + } + } +} + +impl EncodeSize for LedgerMutationKind { + fn encode_size(&self) -> usize { 1 } +} + +/// Append-only, ledger-generated post-state record. Empty values mean that a +/// field does not apply to this mutation; `has_*` flags distinguish absence +/// from a valid zero balance. The record contains only opaque references. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LedgerMutationV1 { + pub sequence: u64, + pub transaction_id: String, + pub kind: LedgerMutationKind, + pub account_id: String, + pub has_account: bool, + pub account: CreditAccount, + pub cohort_ref: String, + pub source_ref: String, + pub balance_direction: BalanceMutationDirection, + /// Absolute credit movement for a balance mutation; zero when this journal + /// entry is not a balance-changing operation. + pub credit_delta: u64, + /// Finance-close period associated with a grant or reversal. + pub period_ref: String, + /// Control-plane evidence for status, reservation, and rate lifecycle + /// records. Empty only when the mutation category does not require it. + pub reason_code: String, + pub occurred_at: u64, + pub approval_ref: String, + pub audit_ref: String, + pub has_reservation: bool, + pub reservation: Reservation, + pub rate_change_set_id: String, + pub has_rate: bool, + pub rate: RateCardEntry, + pub has_untracked_source: bool, + pub untracked_source: UntrackedSourceV1, + pub has_rate_card_completion: bool, + pub rate_card_completion: RateCardCompletionV1, +} + +impl Write for LedgerMutationV1 { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.sequence.write(buf); + write_identifier(&self.transaction_id, buf); + self.kind.write(buf); + write_identifier(&self.account_id, buf); + self.has_account.write(buf); + self.account.write(buf); + write_identifier(&self.cohort_ref, buf); + write_identifier(&self.source_ref, buf); + self.balance_direction.write(buf); + self.credit_delta.write(buf); + write_identifier(&self.period_ref, buf); + write_identifier(&self.reason_code, buf); + self.occurred_at.write(buf); + write_identifier(&self.approval_ref, buf); + write_identifier(&self.audit_ref, buf); + self.has_reservation.write(buf); + self.reservation.write(buf); + write_identifier(&self.rate_change_set_id, buf); + self.has_rate.write(buf); + self.rate.write(buf); + self.has_untracked_source.write(buf); + self.untracked_source.write(buf); + self.has_rate_card_completion.write(buf); + self.rate_card_completion.write(buf); + } +} + +impl Read for LedgerMutationV1 { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + sequence: u64::read(buf)?, transaction_id: read_identifier(buf)?, + kind: LedgerMutationKind::read(buf)?, account_id: read_identifier(buf)?, + has_account: bool::read(buf)?, account: CreditAccount::read(buf)?, + cohort_ref: read_identifier(buf)?, source_ref: read_identifier(buf)?, + balance_direction: BalanceMutationDirection::read(buf)?, credit_delta: u64::read(buf)?, period_ref: read_identifier(buf)?, + reason_code: read_identifier(buf)?, occurred_at: u64::read(buf)?, + approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)?, + has_reservation: bool::read(buf)?, reservation: Reservation::read(buf)?, + rate_change_set_id: read_identifier(buf)?, has_rate: bool::read(buf)?, + rate: RateCardEntry::read(buf)?, has_untracked_source: bool::read(buf)?, + untracked_source: UntrackedSourceV1::read(buf)?, + has_rate_card_completion: bool::read(buf)?, rate_card_completion: RateCardCompletionV1::read(buf)?, + }) + } +} + +impl EncodeSize for LedgerMutationV1 { + fn encode_size(&self) -> usize { + self.sequence.encode_size() + identifier_encode_size(&self.transaction_id) + + self.kind.encode_size() + identifier_encode_size(&self.account_id) + + self.has_account.encode_size() + self.account.encode_size() + + identifier_encode_size(&self.cohort_ref) + identifier_encode_size(&self.source_ref) + + self.balance_direction.encode_size() + self.credit_delta.encode_size() + identifier_encode_size(&self.period_ref) + + identifier_encode_size(&self.reason_code) + self.occurred_at.encode_size() + + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref) + + self.has_reservation.encode_size() + self.reservation.encode_size() + + identifier_encode_size(&self.rate_change_set_id) + self.has_rate.encode_size() + + self.rate.encode_size() + self.has_untracked_source.encode_size() + + self.untracked_source.encode_size() + self.has_rate_card_completion.encode_size() + + self.rate_card_completion.encode_size() + } +} + +impl Write for UntrackedSourceV1 { + fn write(&self, buf: &mut impl bytes::BufMut) { + write_identifier(&self.source_id, buf); + write_identifier(&self.reason_code, buf); + write_identifier(&self.owner_ref, buf); + write_identifier(&self.period_ref, buf); + write_identifier(&self.provenance_ref, buf); + write_identifier(&self.coverage_code, buf); + write_identifier(&self.confidence_code, buf); + write_identifier(&self.evidence_ref, buf); + write_identifier(&self.cohort_ref, buf); + } +} + +impl Read for UntrackedSourceV1 { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + source_id: read_identifier(buf)?, + reason_code: read_identifier(buf)?, + owner_ref: read_identifier(buf)?, + period_ref: read_identifier(buf)?, + provenance_ref: read_identifier(buf)?, + coverage_code: read_identifier(buf)?, + confidence_code: read_identifier(buf)?, + evidence_ref: read_identifier(buf)?, + cohort_ref: read_identifier(buf)?, + }) + } +} + +impl EncodeSize for UntrackedSourceV1 { + fn encode_size(&self) -> usize { + identifier_encode_size(&self.source_id) + + identifier_encode_size(&self.reason_code) + + identifier_encode_size(&self.owner_ref) + + identifier_encode_size(&self.period_ref) + + identifier_encode_size(&self.provenance_ref) + + identifier_encode_size(&self.coverage_code) + + identifier_encode_size(&self.confidence_code) + + identifier_encode_size(&self.evidence_ref) + + identifier_encode_size(&self.cohort_ref) + } +} + +impl Write for Reservation { + fn write(&self, buf: &mut impl bytes::BufMut) { + write_identifier(&self.reservation_id, buf); + write_identifier(&self.account_id, buf); + write_quote_snapshot(&self.quote, buf); + write_identifier(&self.lineage_ref, buf); + self.credits.write(buf); + self.expires_at.write(buf); + self.status.write(buf); + } +} + +impl Read for Reservation { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + reservation_id: read_identifier(buf)?, + account_id: read_identifier(buf)?, + quote: read_quote_snapshot(buf)?, + lineage_ref: read_identifier(buf)?, + credits: u64::read(buf)?, + expires_at: u64::read(buf)?, + status: ReservationStatus::read(buf)?, + }) + } +} + +impl EncodeSize for Reservation { + fn encode_size(&self) -> usize { + identifier_encode_size(&self.reservation_id) + + identifier_encode_size(&self.account_id) + + quote_snapshot_encode_size(&self.quote) + + identifier_encode_size(&self.lineage_ref) + + self.credits.encode_size() + + self.expires_at.encode_size() + + self.status.encode_size() + } +} + +impl Write for StatusHistoryEntry { + fn write(&self, buf: &mut impl bytes::BufMut) { + self.sequence.write(buf); self.status.write(buf); write_identifier(&self.reason_code, buf); + self.changed_at.write(buf); write_identifier(&self.approval_ref, buf); write_identifier(&self.audit_ref, buf); + } +} +impl Read for StatusHistoryEntry { + type Cfg = (); + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { sequence: u64::read(buf)?, status: AccountStatus::read(buf)?, reason_code: read_identifier(buf)?, changed_at: u64::read(buf)?, approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)? }) + } +} +impl EncodeSize for StatusHistoryEntry { + fn encode_size(&self) -> usize { self.sequence.encode_size() + self.status.encode_size() + identifier_encode_size(&self.reason_code) + self.changed_at.encode_size() + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref) } +} + +/// One effective-at credit rate. Empty `account_id` denotes a global default and +/// empty `task_key` denotes a category-level default. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RateCardEntry { + pub account_id: String, + pub event_category: String, + pub task_key: String, + pub credits: u64, + pub effective_at: u64, + /// Zero denotes no expiry. + pub expires_at: u64, + pub policy_version: String, + pub rate_version: String, +} + +impl Write for RateCardEntry { + fn write(&self, buf: &mut impl bytes::BufMut) { + write_identifier(&self.account_id, buf); + write_identifier(&self.event_category, buf); + write_identifier(&self.task_key, buf); + self.credits.write(buf); + self.effective_at.write(buf); + self.expires_at.write(buf); + write_identifier(&self.policy_version, buf); + write_identifier(&self.rate_version, buf); + } +} + +impl Read for RateCardEntry { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + account_id: read_identifier(buf)?, + event_category: read_identifier(buf)?, + task_key: read_identifier(buf)?, + credits: u64::read(buf)?, + effective_at: u64::read(buf)?, + expires_at: u64::read(buf)?, + policy_version: read_identifier(buf)?, + rate_version: read_identifier(buf)?, + }) + } +} + +impl EncodeSize for RateCardEntry { + fn encode_size(&self) -> usize { + identifier_encode_size(&self.account_id) + + identifier_encode_size(&self.event_category) + + identifier_encode_size(&self.task_key) + + self.credits.encode_size() + + self.effective_at.encode_size() + + self.expires_at.encode_size() + + identifier_encode_size(&self.policy_version) + + identifier_encode_size(&self.rate_version) + } +} + +/// Metadata for a staged, finance-approved atomic rate change set. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RateCardChangeSet { + pub change_set_id: String, + pub expected_entry_count: u16, + pub target_account_ids: Vec, + pub expected_target_count: u16, + pub manifest_hash: String, + pub staged_entry_count: u16, + /// Opaque finance approval correlation. This is required before a staged + /// card may be activated; the detailed approval record stays off-chain. + pub approval_ref: String, + /// Required append-only audit correlation for both staging and apply. + pub audit_ref: String, + /// Monotonic activation epoch supplied by the control plane. + pub activation_epoch: u64, + /// Makes exact activation replay a no-op and prevents historical rewrite. + pub applied: bool, +} + +/// Explicit V1 name for the finance-approved, atomic rate-card envelope. +pub type RateCardChangeSetV1 = RateCardChangeSet; + +impl RateCardChangeSet { + /// Compute the only valid V1 manifest hash. This hashes finance evidence + /// and the complete approved payload, not a caller-provided label. + #[allow(clippy::too_many_arguments)] // public, explicit approval-envelope inputs + pub fn canonical_manifest_hash( + change_set_id: &str, + approval_ref: &str, + audit_ref: &str, + activation_epoch: u64, + expected_entry_count: u16, + target_account_ids: &[String], + expected_target_count: u16, + entries: &[RateCardEntry], + ) -> String { + const DOMAIN: &[u8] = b"_NUNCHI_COSTS_RATE_CARD_CHANGE_SET_V1_MANIFEST\0"; + let mut bytes = DOMAIN.to_vec(); + write_manifest_string(&mut bytes, change_set_id); + write_manifest_string(&mut bytes, approval_ref); + write_manifest_string(&mut bytes, audit_ref); + bytes.extend_from_slice(&activation_epoch.to_be_bytes()); + bytes.extend_from_slice(&expected_entry_count.to_be_bytes()); + + let mut targets = target_account_ids.to_vec(); + targets.sort(); + bytes.extend_from_slice(&(targets.len() as u32).to_be_bytes()); + for target in targets { write_manifest_string(&mut bytes, &target); } + bytes.extend_from_slice(&expected_target_count.to_be_bytes()); + + let mut entries = entries.to_vec(); + entries.sort_by(|left, right| rate_card_sort_key(left).cmp(&rate_card_sort_key(right))); + bytes.extend_from_slice(&(entries.len() as u32).to_be_bytes()); + for entry in entries { + write_manifest_string(&mut bytes, &entry.account_id); + write_manifest_string(&mut bytes, &entry.event_category); + write_manifest_string(&mut bytes, &entry.task_key); + bytes.extend_from_slice(&entry.credits.to_be_bytes()); + bytes.extend_from_slice(&entry.effective_at.to_be_bytes()); + bytes.extend_from_slice(&entry.expires_at.to_be_bytes()); + write_manifest_string(&mut bytes, &entry.policy_version); + write_manifest_string(&mut bytes, &entry.rate_version); + } + Sha256::hash(&bytes).to_string() + } + + pub fn expected_manifest_hash(&self, entries: &[RateCardEntry]) -> String { + Self::canonical_manifest_hash( + &self.change_set_id, &self.approval_ref, &self.audit_ref, self.activation_epoch, + self.expected_entry_count, &self.target_account_ids, + self.expected_target_count, entries, + ) + } +} + +fn write_manifest_string(bytes: &mut Vec, value: &str) { + let length = u32::try_from(value.len()).expect("manifest field exceeds u32 encoding limit"); + bytes.extend_from_slice(&length.to_be_bytes()); + bytes.extend_from_slice(value.as_bytes()); +} + +fn rate_card_sort_key(entry: &RateCardEntry) -> (&str, &str, &str, u64, u64, u64, &str, &str) { + (&entry.account_id, &entry.event_category, &entry.task_key, entry.credits, entry.effective_at, + entry.expires_at, &entry.policy_version, &entry.rate_version) +} + +/// One durable completion record for a successfully activated approved card. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RateCardCompletionV1 { + pub change_set_id: String, + pub manifest_hash: String, + pub entry_count: u16, + pub target_count: u16, + pub activation_epoch: u64, + pub approval_ref: String, + pub audit_ref: String, + /// Exact base and materialized target revisions activated by this card. + /// These carry only credits and opaque policy/rate versions, never COGS. + pub affected_rates: Vec, +} + +impl Write for RateCardCompletionV1 { + fn write(&self, buf: &mut impl bytes::BufMut) { + write_identifier(&self.change_set_id, buf); write_identifier(&self.manifest_hash, buf); + self.entry_count.write(buf); self.target_count.write(buf); self.activation_epoch.write(buf); + write_identifier(&self.approval_ref, buf); write_identifier(&self.audit_ref, buf); + (self.affected_rates.len() as u16).write(buf); + for rate in &self.affected_rates { rate.write(buf); } + } +} +impl Read for RateCardCompletionV1 { + type Cfg = (); + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { change_set_id: read_identifier(buf)?, manifest_hash: read_identifier(buf)?, + entry_count: u16::read(buf)?, target_count: u16::read(buf)?, activation_epoch: u64::read(buf)?, + approval_ref: read_identifier(buf)?, audit_ref: read_identifier(buf)?, + affected_rates: { let count = u16::read(buf)? as usize; let mut rates = Vec::with_capacity(count); for _ in 0..count { rates.push(RateCardEntry::read(buf)?); } rates }, }) + } +} +impl EncodeSize for RateCardCompletionV1 { + fn encode_size(&self) -> usize { + identifier_encode_size(&self.change_set_id) + identifier_encode_size(&self.manifest_hash) + + self.entry_count.encode_size() + self.target_count.encode_size() + self.activation_epoch.encode_size() + + identifier_encode_size(&self.approval_ref) + identifier_encode_size(&self.audit_ref) + + u16::default().encode_size() + self.affected_rates.iter().map(EncodeSize::encode_size).sum::() + } +} + +impl Write for RateCardChangeSet { + fn write(&self, buf: &mut impl bytes::BufMut) { + write_identifier(&self.change_set_id, buf); + self.expected_entry_count.write(buf); + (self.target_account_ids.len() as u16).write(buf); + for account_id in &self.target_account_ids { write_identifier(account_id, buf); } + self.expected_target_count.write(buf); + write_identifier(&self.manifest_hash, buf); + self.staged_entry_count.write(buf); + write_identifier(&self.approval_ref, buf); + write_identifier(&self.audit_ref, buf); + self.activation_epoch.write(buf); + self.applied.write(buf); + } +} + +impl Read for RateCardChangeSet { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + change_set_id: read_identifier(buf)?, + expected_entry_count: u16::read(buf)?, + target_account_ids: { let count = u16::read(buf)? as usize; let mut accounts = Vec::with_capacity(count); for _ in 0..count { accounts.push(read_identifier(buf)?); } accounts }, + expected_target_count: u16::read(buf)?, + manifest_hash: read_identifier(buf)?, + staged_entry_count: u16::read(buf)?, + approval_ref: read_identifier(buf)?, + audit_ref: read_identifier(buf)?, + activation_epoch: u64::read(buf)?, + applied: bool::read(buf)?, + }) + } +} + +impl EncodeSize for RateCardChangeSet { + fn encode_size(&self) -> usize { + identifier_encode_size(&self.change_set_id) + + self.expected_entry_count.encode_size() + + u16::default().encode_size() + self.target_account_ids.iter().map(|account_id| identifier_encode_size(account_id)).sum::() + self.expected_target_count.encode_size() + + identifier_encode_size(&self.manifest_hash) + + self.staged_entry_count.encode_size() + + identifier_encode_size(&self.approval_ref) + + identifier_encode_size(&self.audit_ref) + + self.activation_epoch.encode_size() + + self.applied.encode_size() + } +} + +/// A normalized, PII-free metered debit input. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SpendRecordV1 { + pub event_id: String, + pub account_id: String, + pub event_category: String, + /// Empty only for legacy fixture records. Real records use the exact + /// rate-card task key and are validated against the pinned snapshot. + pub task_key: String, + pub quantity: u64, + pub credits: u64, + pub observed_at: u64, + pub policy_version: String, + pub rate_version: String, + /// Opaque, non-PII source and lineage identifiers for reconciliation. + pub source_ref: String, + pub lineage_ref: String, + /// Optional application cohort used by finality consumers to isolate + /// exports; it does not change the account's debit semantics. + pub cohort_ref: String, +} + +impl Write for SpendRecordV1 { + fn write(&self, buf: &mut impl bytes::BufMut) { + write_identifier(&self.event_id, buf); + write_identifier(&self.account_id, buf); + write_identifier(&self.event_category, buf); + write_identifier(&self.task_key, buf); + self.quantity.write(buf); + self.credits.write(buf); + self.observed_at.write(buf); + write_identifier(&self.policy_version, buf); + write_identifier(&self.rate_version, buf); + write_identifier(&self.source_ref, buf); + write_identifier(&self.lineage_ref, buf); + write_identifier(&self.cohort_ref, buf); + } +} + +impl Read for SpendRecordV1 { + type Cfg = (); + + fn read_cfg(buf: &mut impl bytes::Buf, _: &Self::Cfg) -> Result { + Ok(Self { + event_id: read_identifier(buf)?, + account_id: read_identifier(buf)?, + event_category: read_identifier(buf)?, + task_key: read_identifier(buf)?, + quantity: u64::read(buf)?, + credits: u64::read(buf)?, + observed_at: u64::read(buf)?, + policy_version: read_identifier(buf)?, + rate_version: read_identifier(buf)?, + source_ref: read_identifier(buf)?, + lineage_ref: read_identifier(buf)?, + cohort_ref: read_identifier(buf)?, + }) + } +} + +impl EncodeSize for SpendRecordV1 { + fn encode_size(&self) -> usize { + identifier_encode_size(&self.event_id) + + identifier_encode_size(&self.account_id) + + identifier_encode_size(&self.event_category) + + identifier_encode_size(&self.task_key) + + self.quantity.encode_size() + + self.credits.encode_size() + + self.observed_at.encode_size() + + identifier_encode_size(&self.policy_version) + + identifier_encode_size(&self.rate_version) + + identifier_encode_size(&self.source_ref) + + identifier_encode_size(&self.lineage_ref) + + identifier_encode_size(&self.cohort_ref) + } +} From d822f99d3ba88625c5780e9ab7d7c250e64838e2 Mon Sep 17 00:00:00 2001 From: JaeLeex Date: Mon, 20 Jul 2026 15:28:57 -0400 Subject: [PATCH 2/2] docs(costs): explain grant and credit flow --- costs/README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/costs/README.md b/costs/README.md index 181ee77..2461fe9 100644 --- a/costs/README.md +++ b/costs/README.md @@ -17,6 +17,52 @@ The module owns only opaque `account_id` account state: All chain writes require an allowlisted backend signer. Client applications and end users never receive a chain key or submit a raw transaction. +## Why grants and paid credits are separate + +They are the same unit of spend, but they are not the same economic claim. +The ledger therefore represents each ingress as a distinct **credit lot** rather +than keeping only one aggregate balance: + +- A **paid credit lot** comes from a payment rail. It retains its `rail_ref`, + purchase amount, terms version, and any bonus credits. Unused paid credit can + be refunded only to that originating rail. +- A **grant credit lot** comes from a plan entitlement, trial, promotion, or + goodwill decision. It retains its reason, period, approval, audit reference, + and expiry. It is non-refundable. + +This distinction is necessary even though both lots are denominated in +credits. A single undifferentiated balance could not answer whether a refund is +permitted, which credits expire, or why a particular amount was issued and +consumed. It would also allow promotional value to be accidentally returned as +cash-equivalent value. The account read model exposes paid and grant balances +separately while the spending interface treats both as one spendable pool. + +### Stored-value flow + +```mermaid +flowchart LR + Payment["Payment rail"] -->|"Billing writer: top-up"| Paid["Paid credit lot\nrail + terms + amount"] + Program["Plan / trial / promotion"] -->|"Adjustment writer: grant"| Grant["Grant credit lot\nreason + period + expiry + approval"] + Paid --> Pool["Account's spendable credits"] + Grant --> Pool + Pool -->|"Ingest writer: reserve or record spend"| Allocate["Deterministic allocation\ngrants first, then paid lots"] + Allocate --> Finality["Finalized chain transaction"] + Finality --> Outbox["Post-finality event\nwarehouse / accounting / client projections"] + Paid -->|"Unused and not reserved"| Refund["Adjustment writer: refund\nto original payment rail"] +``` + +For a spend or reservation, the ledger first selects unexpired grants (earliest +expiry first) and then paid lots. The exact allocations are stored with the +spend or reservation. That makes the result deterministic and preserves the +remaining refundable paid balance. A reservation keeps the selected lots on +hold; it may settle into a spend or be released/expired, after which the same +lots become available again. + +The authorization boundary mirrors these economic responsibilities: `Billing` +can create paid top-ups, `Adjustment` can issue grants and process refunds, and +`Ingest` can reserve or record product usage. Each command and post-finality +event is idempotent, so retries cannot mint or spend a lot twice. + ## Local proof ```sh