open-standards/openpay/crates/op-revrec/tests/saas_setup_fee.rs
Transaction Science 6bd1d3ed41
Some checks are pending
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Waiting to run
OpenPay at zero clippy findings, and held there in CI; FedNow's cap at the network's 10 million; a low-s bound that is N/2
Every OpenPay crate now passes clippy with -D warnings, and the openpay
entry of the workspace build sets clippy: true so it stays there. Most
of the findings were mechanical (clippy's own fixes, write! for
push_str(&format!), let-else); where a lint is wrong for the code it is
allowed at the narrowest scope with its reason: code tables keep one
arm per code, units_cents amounts keep their grouping, op-fraud-graph's
index casts rest on a bound upsert_entity now enforces (it used to hand
every vertex past 2^32 the same id).

Two findings were bugs. op-payouts capped FedNow at USD 1,000,000, the
limit before November 2025; the network maximum is USD 10,000,000 and a
test now sends five million. op-crypto-extra's EIP-7702 low-s bound was
N - 2^255, not N/2: it refused valid low-s signatures in a band just
below N/2. It is N/2 now, with tests at both edges.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 22:21:23 -05:00

102 lines
3.7 KiB
Rust

//! Multi-element arrangement: SaaS subscription ($120/year, 12-month
//! straight-line) + one-time setup fee ($300, recognized at go-live).
//!
//! The combined contract is priced at $400 (a $20 discount off the
//! $420 list). Under ASC 606-10-32-31 the discount allocates by
//! relative SSP, NOT by line.
#![allow(clippy::inconsistent_digit_grouping, reason = "amounts in minor units are written units_cents: 12_000_00 is 12 000.00")]
use chrono::NaiveDate;
use op_core::{Currency, Money};
use op_revrec::{
Contract, ContractId, InMemoryLedger, DeferredRevenueLedger, ObligationId,
PerformanceObligation, Presentation, RecognitionPattern, TransactionPrice,
allocate_transaction_price, generate,
};
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).expect("valid date")
}
fn usd(n: i64) -> Money {
Money::from_minor(n, Currency::USD)
}
#[tokio::test]
async fn saas_plus_setup_fee_full_lifecycle() {
let saas = PerformanceObligation {
id: ObligationId::new("saas-2026"),
standalone_selling_price: usd(120_00),
pattern: RecognitionPattern::StraightLine {
start: ymd(2026, 1, 1),
end: ymd(2026, 12, 31),
},
presentation: Presentation::Gross,
};
let setup = PerformanceObligation {
id: ObligationId::new("setup-fee"),
standalone_selling_price: usd(300_00),
pattern: RecognitionPattern::PointInTime { date: ymd(2026, 1, 15) },
presentation: Presentation::Gross,
};
let contract = Contract {
id: ContractId::new(),
customer_ref: "customer-1".into(),
effective_date: ymd(2026, 1, 1),
obligations: vec![saas.clone(), setup.clone()],
transaction_price: TransactionPrice::fixed(usd(400_00)),
};
// Step 4: allocate. SSPs: saas 12000, setup 30000 -> total 42000.
// saas alloc = 40000 * 12000 / 42000 = 11428.57 -> 11428
// setup alloc = plug = 40000 - 11428 = 28572
let alloc = allocate_transaction_price(&contract).expect("alloc");
let map: std::collections::HashMap<_, _> = alloc.into_iter().collect();
let saas_alloc = map[&ObligationId::new("saas-2026")];
let setup_alloc = map[&ObligationId::new("setup-fee")];
assert_eq!(saas_alloc + setup_alloc, 40_000);
// Step 5: schedules.
let saas_sched = generate(&saas, saas_alloc, Currency::USD).expect("saas sched");
let setup_sched = generate(&setup, setup_alloc, Currency::USD).expect("setup sched");
assert_eq!(saas_sched.entries.len(), 12);
assert_eq!(setup_sched.entries.len(), 1);
assert_eq!(saas_sched.total_minor(), saas_alloc);
assert_eq!(setup_sched.total_minor(), setup_alloc);
// Open deferral at contract inception and walk the schedule.
let ledger = InMemoryLedger::new();
ledger
.open_deferral(
contract.id,
None,
usd(400_00),
contract.effective_date,
"contract inception",
)
.await
.expect("open");
// Post setup recognition (the one entry on 2026-01-15).
ledger
.post_recognition(contract.id, &setup_sched.entries[0], "go-live")
.await
.expect("setup rec");
// Post the first 3 months of SaaS.
for e in saas_sched.entries.iter().take(3) {
ledger
.post_recognition(contract.id, e, "saas monthly")
.await
.expect("monthly rec");
}
let bal = ledger.balances(contract.id).await.expect("bal");
let three_months_saas: i64 = saas_sched.entries.iter().take(3).map(|e| e.amount_minor).sum();
assert_eq!(bal.recognized_minor, setup_alloc + three_months_saas);
assert_eq!(
bal.deferred_minor,
40_000 - setup_alloc - three_months_saas
);
}