feat: multi-chain billing, tiered overages, configurable dunning retries, and per-tenant invoice branding (#933, #934, #935, #937) - #1041
Conversation
…nvoice branding Implements four assigned issues across the billing stack. Closes Smartdevs17#933 — multi-chain subscription management with unified billing * New src/services/multiChainSubscriptionService.ts: chain bindings per subscription, unified statements that convert every chain into one currency while keeping native token subtotals, and settlement planning with health-aware cross-chain failover. * Unpriced tokens are reported explicitly rather than counted as zero, and unpayable charges are marked blocked rather than dropped. * walletService gains getBalancesAcrossChains() (parallel, per-chain error isolation) and totalsBySymbol(), which keeps holdings separated by chain because the same symbol on two chains is not fungible. Closes Smartdevs17#934 — automated dunning with configurable retry strategies * dunningService did not compile: it referenced this.templates, this.recoveredEntries and this.getStrategy(), none of which existed, read stages off DunningConfiguration rather than off a RetryStrategy, used an undefined `strategy` in recordFailedCharge, and dereferenced a possibly undefined entry in recordSuccessfulCharge. All fixed. * Adds strategy resolution (A/B variant > failure-reason override > plan default > built-in) and four backoff policies: fixed, linear, exponential and exponential-with-jitter, plus a retryable flag for hard declines. * Jitter decorrelates the retry storm that follows a single upstream outage. * recoveryRate is now measured over closed outcomes only, so in-flight dunning no longer depresses the rate. Closes Smartdevs17#935 — usage-based billing with metered pricing and tiered overages * New backend/services/billing/metering.ts: pure rating engine supporting flat, graduated, volume and package pricing, with included-unit proration, minimum charges and spend caps. * contracts/metering gains register_tiered_meter(), quote_usage(), a PricingModel/PriceTier ladder and per-tier charge breakdowns. The existing register_meter() is unchanged and now registers a flat meter. * Off-chain and on-chain rating implement identical arithmetic; toContractTiers() converts between the two tier encodings. * Also removes a dead subtrackr_types::CoreError import and a From impl that collided with contracterror's blanket TryFrom impls — the metering crate did not compile before this. Closes Smartdevs17#937 — invoice customization with per-tenant branding * Branding was a single platform-wide default. Adds a per-tenant registry to invoiceStore with field-by-field resolution (tenant > platform > fallback), per-tenant templates and numbering prefixes, and validation that reports every problem at once. * invoiceCustomizationService now renders real, deterministic HTML and a plain-text alternative instead of logging to the console. * All branding is attacker-supplied, so text is escaped, colours are pattern checked, logo/website URLs are scheme restricted (javascript: dropped), font stacks are stripped of CSS metacharacters and logo widths are clamped. Testing * 163 new TypeScript tests and 13 new contract tests, all passing. * backend/services/billing: 47 -> 146 passing. * src/store + src/services: 501 -> 565 passing. * Pre-existing failures are unchanged in both suites; no regressions. * cargo fmt and clippy -D warnings are clean for subtrackr-metering. Docs: DUNNING_RETRY_STRATEGIES.md, USAGE_BASED_BILLING.md, INVOICE_BRANDING.md, MULTI_CHAIN_SUBSCRIPTIONS.md.
|
@Itodo-S Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
CI triageThe red checks here are pre-existing and reproduce on unrelated PRs — this branch introduces no new CI failures. Evidence: Failing identically on #1039 (an unrelated PR): The one check that differs — That is the runner missing the What does pass is the meaningful part for this PR: Local verificationRun against this branch, and re-run against a clean
The failing suites and their error messages are byte-identical before and after, so nothing here regressed. The three pre-existing blockers behind them are described at the bottom of the PR description. |
Summary
Implements four assigned issues across the billing stack. Each is self-contained but they share the billing module, so they ship together.
Closes #933
Closes #934
Closes #935
Closes #937
#933 — Multi-chain subscription management with unified billing
A payer's subscriptions don't all live on one chain — one is funded from USDC on Polygon, another settles in XLM on Stellar. That produced one bill per chain, each in its own asset.
New
src/services/multiChainSubscriptionService.ts:rebind()for wallet migrations.buildUnifiedStatement()converts every chain into one currency, returning per-subscription lines, per-chain subtotals that keep native token amounts alongside the converted figure, and one total.planSettlement()marks each due chargedirect,bridge, orblockedbased on chain health and where the payer actually holds the token.Two deliberate correctness choices:
unpricedSubscriptionIdsand is excluded from the total, never counted as zero. A bill that quietly under-reports is worse than one that says what it couldn't price.blockedwith a reason rather than dropped — silently skipping is how subscriptions lapse unnoticed.src/services/walletService.ts: addsgetBalancesAcrossChains()(parallel, per-chain error isolation so one dead RPC doesn't blank the screen) andtotalsBySymbol(), which returns a per-chain map rather than a sum — the same symbol on two chains isn't fungible, and a single figure would imply it is.#934 — Automated dunning with configurable retry strategies
dunningService.tsdid not compile onmain. It referencedthis.templates,this.recoveredEntries, andthis.getStrategy()— none of which existed — read.stagesoffDunningConfiguration(which has no such field), used an undefinedstrategyvariable insiderecordFailedCharge, and dereferenced a possibly-undefined entry inrecordSuccessfulCharge. All fixed.On top of that:
fixed,linear,exponential,exponential_jitter. Jitter matters because a single upstream outage fails hundreds of charges in the same second; without it they all retry at the same instant and the retry storm hits as hard as the original burst.retryable: falsefor hard declines — escalates on first failure instead of burning processor reputation.recoveryRatenow measures closed outcomes only (recovered vs. cancelled). Previously in-flight dunning counted against it, so the rate looked worse the more traffic was in flight.#935 — Usage-based billing with metered pricing and tiered overages
New
backend/services/billing/metering.ts— a pure rating engine (meteringService.tskeeps ingestion; this owns units → money). Supportsflat,graduated,volume, andpackagepricing, plus included-unit proration, minimum charges, and spend caps. Being pure means one function prices a closed period, a mid-period estimate, and a quote.contracts/metering/gainsregister_tiered_meter(),quote_usage(), aPricingModel/PriceTierladder, and per-tier charge breakdowns.register_meter()is unchanged and now registers a flat meter, so existing callers keep working. Reconfiguring preserves totals, so a mid-period price change re-rates the same recorded usage.Off-chain and on-chain rating implement identical arithmetic — off-chain produces the invoice the payer reads, on-chain produces the charge the contract settles, and a disagreement between them is a dispute.
toContractTiers()converts between the two tier encodings (nullvs.0for unbounded) and validates first.#937 — Invoice customization with per-tenant branding
Branding was a single platform-wide default, so every merchant's invoice looked identical.
src/store/invoiceStore.tsgains a per-tenant registry with field-by-field resolution (tenant → platform → fallback) — a tenant overriding only its logo still inherits the platform palette. Plus per-tenant templates, numbering prefixes, and display names.resolveBranding()reports which layer supplied the result, which the preview UI needs.invoiceCustomizationService.tsnow renders real, deterministic HTML plus a plain-text alternative, instead ofconsole.log. Determinism is what makes it snapshot-testable and safe to re-render when a dispute needs the exact document a payer saw.Security: all branding is attacker-supplied (a tenant types it into a form). Text is HTML-escaped, colours are pattern-checked, logo/website URLs are scheme-restricted (
javascript:dropped), font stacks are stripped of CSS metacharacters that would let a tenant close the declaration and inject their own, and logo widths are clamped. Validation at write time and sanitization at render time — the store can be populated by a migration that never passed through validation.Store schema bumped to v2; v1 payloads migrate to an empty registry, which resolves to the platform defaults (exactly v1 behaviour).
Testing
163 new TypeScript tests and 13 new contract tests, all passing.
backend/services/billingsrc/store+src/servicessubtrackr-metering(Rust)Pre-existing failures are unchanged in both suites — verified by stashing this branch and re-running against a clean
upstream/maintree (identical 8 failing src suites / 24 failing tests, identical 5 failing backend suites). No regressions.cargo fmtandcargo clippy -- -D warningsare clean forsubtrackr-metering. ESLint is clean on all changed files.Notes for reviewers
Three pre-existing issues surfaced while working. I've left them alone to keep this PR conflict-free, but they're worth separate fixes:
backend/services/shared/errors.tsdoesn't compile — it usesErrorCode(a union type inapiResponse.ts) as a value. This blocks any test importingBillingError, which is whymetering.tsthrows a localMeteringPricingErrorinstead. It also causes 3 of the 5 pre-existing backend failures.babel-plugin-module-resolveris referenced bybabel.config.jsbut missing frompackage.json— the entiresrc/Jest suite is unrunnable from a clean install without it. I installed it locally with--no-saveto run the tests.contracts/Cargo.lockis gitignored, andsoroban-env-hostdeclaresed25519-dalek = ">=2.0.0", which now resolves to 3.0.0 and breaks the build. Locally I pinned it back to 2.2.0 to run contract tests; since the lockfile isn't tracked, that pin isn't in this PR.contracts/subscription/has 22 pre-existing compile errors onmain(duplicate imports, inner attributes after outer doc comments, an unresolvedStorageKeyExt). Issues #933 and #934 list that path in their technical scope, but repairing that crate is a much larger, separate job, so the work for those two issues is on the TypeScript side. #935's contract work landed incontracts/metering/, which I was able to fix and test.