Skip to content

feat: multi-chain billing, tiered overages, configurable dunning retries, and per-tenant invoice branding (#933, #934, #935, #937) - #1041

Open
Itodo-S wants to merge 1 commit into
Smartdevs17:mainfrom
Itodo-S:feat/issues-933-934-935-937
Open

feat: multi-chain billing, tiered overages, configurable dunning retries, and per-tenant invoice branding (#933, #934, #935, #937)#1041
Itodo-S wants to merge 1 commit into
Smartdevs17:mainfrom
Itodo-S:feat/issues-933-934-935-937

Conversation

@Itodo-S

@Itodo-S Itodo-S commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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:

  • Chain bindings per subscription (chain type, chain id, network, token, funding wallet), with rebind() for wallet migrations.
  • Unified billingbuildUnifiedStatement() 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.
  • Settlement planningplanSettlement() marks each due charge direct, bridge, or blocked based on chain health and where the payer actually holds the token.

Two deliberate correctness choices:

  • A token with no conversion rate lands in unpricedSubscriptionIds and 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.
  • An unpayable charge is blocked with a reason rather than dropped — silently skipping is how subscriptions lapse unnoticed.

src/services/walletService.ts: adds getBalancesAcrossChains() (parallel, per-chain error isolation so one dead RPC doesn't blank the screen) and totalsBySymbol(), 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.ts did not compile on main. It referenced this.templates, this.recoveredEntries, and this.getStrategy() — none of which existed — read .stages off DunningConfiguration (which has no such field), used an undefined strategy variable inside recordFailedCharge, and dereferenced a possibly-undefined entry in recordSuccessfulCharge. All fixed.

On top of that:

  • Strategy resolution, most specific first: A/B variant → failure-reason override → plan default → built-in fallback.
  • Four backoff policies: 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: false for hard declines — escalates on first failure instead of burning processor reputation.
  • recoveryRate now 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.ts keeps ingestion; this owns units → money). Supports flat, graduated, volume, and package pricing, 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/ gains register_tiered_meter(), quote_usage(), a PricingModel/PriceTier ladder, 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 (null vs. 0 for unbounded) and validates first.

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 — that fix was a prerequisite for the tests below.

#937 — Invoice customization with per-tenant branding

Branding was a single platform-wide default, so every merchant's invoice looked identical.

  • src/store/invoiceStore.ts gains 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.ts now renders real, deterministic HTML plus a plain-text alternative, instead of console.log. Determinism is what makes it snapshot-testable and safe to re-render when a dispute needs the exact document a payer saw.
  • An issued invoice keeps the brand it was issued under; a rebrand doesn't rewrite history.

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.

Suite Before After
backend/services/billing 47 passing 146 passing
src/store + src/services 501 passing 565 passing
subtrackr-metering (Rust) 7 passing 20 passing

Pre-existing failures are unchanged in both suites — verified by stashing this branch and re-running against a clean upstream/main tree (identical 8 failing src suites / 24 failing tests, identical 5 failing backend suites). No regressions.

cargo fmt and cargo clippy -- -D warnings are clean for subtrackr-metering. ESLint is clean on all changed files.

npx jest --config jest.backend.config.js backend/services/billing
npx jest src/store src/services
cd contracts && cargo test -p subtrackr-metering --target x86_64-unknown-linux-gnu

The contracts workspace defaults to wasm32 via .cargo/config.toml, so contract tests need an explicit host --target.

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:

  1. backend/services/shared/errors.ts doesn't compile — it uses ErrorCode (a union type in apiResponse.ts) as a value. This blocks any test importing BillingError, which is why metering.ts throws a local MeteringPricingError instead. It also causes 3 of the 5 pre-existing backend failures.
  2. babel-plugin-module-resolver is referenced by babel.config.js but missing from package.json — the entire src/ Jest suite is unrunnable from a clean install without it. I installed it locally with --no-save to run the tests.
  3. contracts/Cargo.lock is gitignored, and soroban-env-host declares ed25519-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 on main (duplicate imports, inner attributes after outer doc comments, an unresolved StorageKeyExt). 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 in contracts/metering/, which I was able to fix and test.

…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.
@drips-wave

drips-wave Bot commented Aug 27, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@Itodo-S

Itodo-S commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

CI triage

The 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): TypeScript Build, TypeScript Type Check, TypeScript Lint & Format, TypeScript Tests (Sharded) 1–3, Rust Build, Rust Tests, Rust Clippy Lint, Rust Format Check, Bundle Size Analysis/Check, Frontend Performance Budget, Conventional Commit Check, NPM Audit, Container Scanning - Trivy, DAST - OWASP ZAP, all three k6 Load Test jobs, the locale checks, analyze, and CI Complete.

The one check that differs — Subscription Contract Invariant Tests — didn't run on #1039 at all, because it's path-filtered to contracts/** and that PR touched no contracts. Its failure is:

error[E0463]: can't find crate for `core`
  = note: the `wasm32-unknown-unknown` target may not be installed
error: could not compile `escape-bytes` (lib)

That is the runner missing the wasm32-unknown-unknown std, not a code failure — it dies compiling third-party crates (escape-bytes, num-traits) before reaching any SubTrackr source. It's the same root cause as the Rust Tests failure on #1039, which fails with the identical can't find crate for 'core'. The workflow runs cargo test with no --target, so it inherits the wasm32 default from contracts/.cargo/config.toml; a rustup target add wasm32-unknown-unknown step (or an explicit host --target) would fix it.

What does pass is the meaningful part for this PR: Contracts (Clippy + Test), Build Core Contracts, Build Subscription (All Features), Feature Flag / metering, and Gas Benchmarks (Soroban) are all green — those are the contract jobs with the wasm32 target configured, and they cover the contracts/metering changes.

Local verification

Run against this branch, and re-run against a clean upstream/main tree to establish the baseline:

Suite upstream/main This branch
backend/services/billing 47 passing, 5 suites failing 146 passing, same 5 suites failing
src/store + src/services 501 passing, 8 suites / 24 tests failing 565 passing, same 8 suites / 24 tests failing
subtrackr-metering (Rust) did not compile 20 passing

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.

@Itodo-S
Itodo-S deployed to security-review August 27, 2026 10:17 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant