Skip to content

feat(credits): micropayment credit ledger & multi-party revenue distr… - #1582

Merged
yusuftomilola merged 1 commit into
DistinctCodes:mainfrom
feyishola:feature/1575-credit-ledger-revenue-splits
Aug 25, 2026
Merged

feat(credits): micropayment credit ledger & multi-party revenue distr…#1582
yusuftomilola merged 1 commit into
DistinctCodes:mainfrom
feyishola:feature/1575-credit-ledger-revenue-splits

Conversation

@feyishola

@feyishola feyishola commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

…ibution

Adds an internal double-entry credit ledger for high-frequency, low-value charges, a configurable revenue-split engine, and a batch settlement job that moves netted balances off-platform over the Soroban rail.

Per-minute usage, printing and room overage are too small to settle on-chain per event, so a charge is now a synchronous internal ledger movement with no rail or chain call in the hot path; value only crosses the platform boundary later, in one netted transfer per recipient.

Ledger

  • ledger_accounts (members plus singleton system accounts) and append-only ledger_entries grouped by ledger_transactions. Every transaction is validated to balance before anything is written, so all balances in a currency always sum to exactly zero.
  • ledger_transactions.reference is unique: replayed charges, re-run settlement passes and resumed batch jobs return the original transaction instead of posting a duplicate.
  • Accounts are locked FOR UPDATE in ascending id order, so concurrent movements serialize rather than deadlock, and an overdraft check can never run against a stale balance.
  • balance is a materialized cache of the entries; an admin integrity report re-derives it and reports any drift.

Overdraft policy

  • A charge is refused the moment it would take a member below -overdraftLimit (CREDITS_DEFAULT_OVERDRAFT_LIMIT, default 0 — no overdraft), raisable per account for graceful degradation. The check runs inside the account's row lock, so two charges that are each individually affordable but not affordable together can never both succeed. Tested with 25 concurrent charges against a fixed balance.

Splits

  • RevenueSplitConfig basis points must sum to exactly 10000, rejected at configuration time rather than discovered mid-settlement.
  • Allocation uses the largest-remainder method with a documented, deterministic tie-break, so the shares always sum to exactly the input amount — no minor unit is ever dropped or duplicated.
  • Attachable to a Payments 1/7: Payment Domain Model, Initiation Flow & Idempotent Transaction Lifecycle #1570 payment (posted as internal ledger entries) or computed over a settlement batch (internal entries plus on-chain payouts).

Settlement

  • Hourly job: resumes open batches first, then distributes accumulated revenue across a split config and nets each payable account.
  • Safe to crash mid-run: amounts come from account balances rather than a running tally, one in-flight payout per account, and per-payout idempotency keys the rail dedupes on (reused on retry, never reminted).
  • A submission is never a settlement. The ledger drawdown and the per-entry settled marker are written only after the rail confirms the payout from fresh chain state; a failed leg leaves the balance shown as still owed. An unreachable rail is indeterminate, not a failure.
  • Admins can inspect a batch's full breakdown — entries in, recipients out, on-chain transaction references — and retry or abandon it.

Payments integration

  • Credits reads payments, never the reverse: a CONFIRMED payment marked metadata.purpose=CREDIT_TOP_UP funds the payer's balance, and the payment/credit link table lives on the credits side.
  • SorobanPayoutAdapter implements the payout port over the existing escrow contract, deriving the escrow id from the payout's idempotency key so re-submitting can never transfer twice; the port resolves to null (and settlement says so) when SOROBAN_ENABLED is not true.

closes #1575

…ibution

Adds an internal double-entry credit ledger for high-frequency, low-value
charges, a configurable revenue-split engine, and a batch settlement job
that moves netted balances off-platform over the DistinctCodes#1574 Soroban rail.

Per-minute usage, printing and room overage are too small to settle
on-chain per event, so a charge is now a synchronous internal ledger
movement with no rail or chain call in the hot path; value only crosses
the platform boundary later, in one netted transfer per recipient.

Ledger
- ledger_accounts (members plus singleton system accounts) and
  append-only ledger_entries grouped by ledger_transactions. Every
  transaction is validated to balance before anything is written, so all
  balances in a currency always sum to exactly zero.
- ledger_transactions.reference is unique: replayed charges, re-run
  settlement passes and resumed batch jobs return the original
  transaction instead of posting a duplicate.
- Accounts are locked FOR UPDATE in ascending id order, so concurrent
  movements serialize rather than deadlock, and an overdraft check can
  never run against a stale balance.
- balance is a materialized cache of the entries; an admin integrity
  report re-derives it and reports any drift.

Overdraft policy
- A charge is refused the moment it would take a member below
  -overdraftLimit (CREDITS_DEFAULT_OVERDRAFT_LIMIT, default 0 — no
  overdraft), raisable per account for graceful degradation. The check
  runs inside the account's row lock, so two charges that are each
  individually affordable but not affordable together can never both
  succeed. Tested with 25 concurrent charges against a fixed balance.

Splits
- RevenueSplitConfig basis points must sum to exactly 10000, rejected at
  configuration time rather than discovered mid-settlement.
- Allocation uses the largest-remainder method with a documented,
  deterministic tie-break, so the shares always sum to exactly the input
  amount — no minor unit is ever dropped or duplicated.
- Attachable to a DistinctCodes#1570 payment (posted as internal ledger entries) or
  computed over a settlement batch (internal entries plus on-chain
  payouts).

Settlement
- Hourly job: resumes open batches first, then distributes accumulated
  revenue across a split config and nets each payable account.
- Safe to crash mid-run: amounts come from account balances rather than a
  running tally, one in-flight payout per account, and per-payout
  idempotency keys the rail dedupes on (reused on retry, never reminted).
- A submission is never a settlement. The ledger drawdown and the
  per-entry settled marker are written only after the rail confirms the
  payout from fresh chain state; a failed leg leaves the balance shown as
  still owed. An unreachable rail is indeterminate, not a failure.
- Admins can inspect a batch's full breakdown — entries in, recipients
  out, on-chain transaction references — and retry or abandon it.

Payments integration
- Credits reads payments, never the reverse: a CONFIRMED payment marked
  metadata.purpose=CREDIT_TOP_UP funds the payer's balance, and the
  payment/credit link table lives on the credits side.
- SorobanPayoutAdapter implements the payout port over the existing
  escrow contract, deriving the escrow id from the payout's idempotency
  key so re-submitting can never transfer twice; the port resolves to
  null (and settlement says so) when SOROBAN_ENABLED is not true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

@feyishola is attempting to deploy a commit to the naijabuz's projects Team on Vercel.

A member of the Team first needs to authorize it.

@yusuftomilola
yusuftomilola merged commit 285943e into DistinctCodes:main Aug 25, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Payments 6/7: Micropayment Credit Ledger & Multi-Party Revenue Distribution

2 participants