Skip to content

work / payments-platform · engineering case study · 2026-06

Payments Platform — One Generic Ledger, Self-Healing Settlement

One money ledger for every paid feature — engineered so a payment can never be captured without the thing it paid for eventually arriving, even when the delivery step fails after the charge.

Sole engineer of Partners.com.bd — I design, build, and run the whole platform end to end: the Flutter mobile apps, the .NET API, and the legacy MVC → API migration. This payments platform is one slice of that rebuild, taken from a legacy wide table through a full security review to a shared, self-healing settlement pipeline.

[dotnet][payments][distributed-systems][reliability][sqlserver]

1

generic ledger for every paid feature

4 → 1

drifting settlers replaced by one coordinator

~883

automated tests across the suite

exactly once

FulfilledDate claimed per payment

5 min

reconciliation backstop cadence

In one minute

When you pay for something in an app, two things have to happen: the money leaves your account, and the thing you bought unlocks. It feels like one action, but on the server it's two — capture the money, then write the feature. They are not one database transaction, so there's a gap: what if the money is captured and the feature write then fails? You've been charged and got nothing.

The legacy code "solved" this by not looking. When the vendor re-delivered the callback, it saw the payment was already settled and returned success — throwing away the one retry that could have finished the delivery. This project is the opposite instinct: a single settlement pipeline that treats the delivery step as idempotent and always-run, so a payment that got stuck halfway self-heals the next time anything touches it — and a reconciliation job that goes looking every five minutes.

01

A wide table wasn’t the dangerous part

The legacy dbo.OnlinePayment was one wide table that crammed every paid feature's columns and bKash's raw API shape into a single row — with Amount stored as NVARCHAR(200), money as free text. But the schema wasn't the dangerous part. Four features — boost, the profile fee, bundle top-ups, membership — each had their own copy-pasted verify→claim→fulfil settlement code, and the copies had drifted. Every one of them ended a re-delivered callback with the same short-circuit: already settled → return success. There was no atomic claim, no server-side check that the amount the vendor reported matched the order, and nothing that ever went back to look at a payment that got stuck. A June 2026 end-to-end review is what surfaced how much money that quiet line could lose.

symptom → cause

A user could be charged and never receive what they paid for

Settlement is two steps — claim the money (Status → Settled), then do the feature's domain write (credit the balance, apply the boost, grant the tier). They aren't one transaction. If the write failed after the claim, the payment sat Settled-but-undelivered — and because the re-delivered callback short-circuited on "already settled," the retry that could have completed it was discarded. Charged, undelivered, no recovery. This was finding C1, the most severe in the review.

symptom → cause

The upgrade that took the money but not the tier

The membership settler called MarkPaid but ignored its result, and returned success even when the request reference was null. So a membership payment could capture the money while the tier was never granted — and report a clean success on the way out, which is exactly the failure that never generates an alert, only a confused customer.

symptom → cause

A replayed callback could settle an expensive order cheaply

Nothing cross-checked the amount the vendor said it captured against the amount the server had recorded for the order. A replayed or tampered callback carrying a small amount could, in principle, settle a large purchase — the class of bug where the ledger and reality quietly disagree.

02

Make settlement a property of the data, not the code

Any framework gives you a payments table and an HTTP client. What none of them give you is the property that actually matters here: settlement is two steps that can't be one transaction, so the second step must be safe to run any number of times. That's the whole thesis.

So the design collapses to two moves. First, one generic ledger — dbo.Payment with a Purpose discriminator and a ReferenceId — so payment status, idempotency, settlement, refunds and reconciliation are written once, not re-implemented per feature. Second, one settlement coordinator that owns the money-critical sequence, where every feature supplies only its own idempotent delivery step. The idempotency is anchored in a single column, FulfilledDate, stamped exactly once per payment by a guarded SQL claim — which is simultaneously what stops a double credit and what lets a reconciliation sweep find the payments that got stuck.

01

Generic ledger

One `dbo.Payment` row is the money record for every feature. `Purpose` + a nullable `ReferenceId` link it back to the feature's own domain row, so the plumbing lives once. `DECIMAL(18,2)`, never text.

02

Three identifiers

`Id` (internal BIGINT, for joins), `PublicId` (external GUID, for URLs/callbacks), and a Stripe-style `pay_…` receipt code — each with one job, so the enumerable id is never what's exposed.

03

Vendor abstraction

`IPaymentGateway` (create · execute · verify-callback · resolve-webhook-reference · refund) behind a resolver that indexes every registered gateway by provider and country. A new vendor is one class, one DI line, listed under a country.

04

Trusted settlement gate

Exactly one place settles: `VerifyCallbackAsync`, signature/execute-checked, followed by an amount cross-check against the server-stored order. A mismatch is `AMOUNT_MISMATCH`, not a settlement.

05

Claim-once

`Pending → Settled` is a single guarded `UPDATE … WHERE Status = 'Pending'` that reports whether *this* call won the race — so concurrent and re-delivered callbacks settle a payment exactly once.

06

Idempotent fulfilment

Every fulfilment stored procedure stamps `FulfilledDate` in one `WHERE FulfilledDate IS NULL` claim inside its transaction — no double credit on a re-run, and a visible gap (`Settled` + `FulfilledDate IS NULL`) when a delivery is owed.

07

Reconciliation backstop

A Hangfire job runs every five minutes with three disjoint passes: sweep inline-Bundle rows to Settled, expire abandoned intents past a 30-minute TTL, and re-dispatch the idempotent settle command for the stuck-but-owed (giving up after 168 hours).

08

Create idempotency

A retried "start payment" can't double-charge — `dbo.PaymentCreate` dedupes on a per-user `IdempotencyKey` unique index. On top of that, a MediatR idempotency behavior (ADR-0008; required header + SHA-256 body hash) backs a curated 13-endpoint rollout of the highest-risk commands.

03

One ledger, one gateway seam, one settlement path

At the center is a single class, PaymentSettlementCoordinator, and a rule: no feature writes its own settlement sequence. Each per-feature settler — boost, bundle, membership, profile fee, advertisement — calls the coordinator and hands it exactly one thing: a fulfil delegate that does that feature's domain write. The coordinator owns everything money-critical around it: parsing the provider, looking the payment up by the gateway's reference, checking the Purpose matches (a boost row that reaches the membership settler is a routing bug, WRONG_PURPOSE), resolving the vendor gateway by the payment row's server-trusted country, verifying, cross-checking the amount, and claiming the money once. Because the sequence exists in one place, it's tested once and exhaustively instead of four times and inconsistently — which is precisely what let the old defects diverge per feature.

One generic ledger, five paid features, seven vendors A single dbo.Payment table is the money ledger for every paid feature. A Purpose integer plus a nullable ReferenceId link each payment back to the feature's own domain row: Boost to a BoostOrder, BundlePurchase to a balance credit with no domain table, Membership to a membership request, ProfileFee to a profile, Advertisement to an advertisement request. On the other side, the IPaymentGatewayResolver maps a provider and country to one IPaymentGateway implementation. Bundle, the internal balance debit, and bKash tokenized checkout are fully implemented; Nagad, Rocket, SSLCommerz, Stripe and PayHere are ready to drop in behind the same seam. Adding a vendor is one gateway class, one DI registration, and listing it under a country. Hover or focus a feature or a vendor to read it. Purpose + ReferenceId ← dbo.Payment → IPaymentGateway 5 paid features 7 providers · 2 implemented, 5 ready to add dbo.Payment, the one generic money ledger shared by every feature dbo.Payment one ledger · DECIMAL(18,2) Id · PublicId · pay_… status, idempotency, settlement, refunds live ONCE here Boost, purpose 1, links to a BoostOrder row Boost = 1 → BoostOrder paid ad promotion BundlePurchase, purpose 2, credits the user balance, no domain table BundlePurchase = 2 → credit no domain table — the row IS the order Membership, purpose 3, links to the membership request Membership = 3 → request tier purchase / renewal ProfileFee, purpose 4, unlocks a professional profile ProfileFee = 4 → profile the legacy 52-BDT CV-unlock gate Advertisement, purpose 5, links to an advertisement user request, approval-gated Advertisement = 5 → request display banner · admin publishes after paid Bundle gateway, internal balance debit, implemented Bundle · internal balance URL-less — settles inline on create bKash tokenized checkout, implemented bKash · tokenized checkout real 4-call flow · execute→query fallback Nagad, ready to drop in behind the same seam Nagad · ready to add enum value only — no gateway yet Rocket, ready to drop in behind the same seam Rocket · ready to add enum value only — no gateway yet SSLCommerz, ready to drop in behind the same seam SSLCommerz · ready to add webhook vendor · resolve-webhook seam ready Stripe, ready to drop in behind the same seam, UK expansion Stripe · ready to add (UK) resolver picks it by country, not code branches PayHere, ready to drop in behind the same seam, Sri Lanka expansion PayHere · ready to add (LK) add a vendor = 1 class + 1 DI line hover ▸
Fig. 1 — vendor-agnostic decoupling: one dbo.Payment ledger links via Purpose + ReferenceId to five paid features, and via IPaymentGateway + resolver to seven providers — Bundle and bKash implemented, five more ready to drop in behind the same seam — so a new vendor is one class plus one DI line
04

Follow one payment from tap to delivered

  1. 1

    1. The order is priced and recorded on the server

    server-stored

    When you start a payment, the server recomputes the total (never trusting a client amount), writes a dbo.Payment row with a per-user idempotency key, and — for an external vendor — asks the gateway to create a checkout. Bundle top-ups are internal-balance and settle inline; bKash returns a hosted-checkout URL.

  2. 2

    2. The vendor captures the money

    tokenized

    For bKash this is the real four-call tokenized flow — grant-token (cached process-wide behind a semaphore so concurrent requests don't each hit the rate limit), create, then an explicit server-side execute after you return from the hosted page. A failed or timed-out execute that might actually have captured is reconciled by a status query before we ever report failure, so a network blip after capture doesn't lose a real payment.

  3. 3

    3. Settlement claims the money — exactly once

    claim-once

    The callback lands in the coordinator. It verifies with the vendor (the single trusted gate), cross-checks the captured amount against the stored order, then runs the guarded Pending → Settled update. If a concurrent caller already claimed it, this call simply loses the race — and falls through anyway, because the next step is safe to run regardless.

  4. 4

    4. Fulfilment runs — always, and idempotently

    self-healing

    The coordinator always runs the feature's fulfil step: on a fresh claim, on a lost claim race, and on a re-delivered callback for an already-settled payment. Each fulfilment stamps FulfilledDate exactly once, so re-running is a no-op if it already happened. If fulfilment fails, the coordinator logs critical and returns FULFILMENT_FAILED — it never reports success — so the payment stays visibly owed and self-heals on the next callback or reconciliation pass.

The self-healing settlement pipeline Every per-feature settler delegates to one coordinator. It looks the payment up by the gateway reference, checks the Purpose matches (else WRONG_PURPOSE), then branches on whether the payment is already Settled. A not-yet-settled payment resolves the gateway by the payment row's own country, verifies with the vendor, cross-checks the captured amount against the server-stored order (else AMOUNT_MISMATCH), and atomically claims Pending to Settled exactly once. An already-settled re-delivery skips the vendor round-trip and reuses the stored transaction id. Both paths then ALWAYS run the idempotent fulfilment step, which stamps FulfilledDate exactly once so it can never double-credit. If fulfilment fails the coordinator logs critical and returns FULFILMENT_FAILED — it never reports success, so the payment self-heals on the next callback or reconciliation pass. Hover or focus any stage to read what it guarantees. PaymentSettlementCoordinator · the ONE settlement gate Look up the payment by the gateway reference 1 · look up by ProviderPaymentId no row → 404 NotFound · the gateway ref is the settlement key Check the payment purpose matches this settler, else WRONG_PURPOSE 2 · Purpose == expected? mismatch → WRONG_PURPOSE (a boost row reached the membership settler = routing bug) Branch on whether the payment is already settled 3 · already Settled? no — Pending yes — re-delivery Resolve the gateway by the payment row country, server-trusted resolve gateway · by payment.CountryCode server-trusted country, not the caller's Verify with the vendor, the single trusted settlement gate VerifyCallbackAsync — the trusted gate signature/execute verified before any state changes Cross-check the captured amount against the server-stored order, else AMOUNT_MISMATCH amount == order? else AMOUNT_MISMATCH blocks a replayed cheap-payment callback settling an expensive order Atomically claim Pending to Settled exactly once PaymentMarkSettled · claim once guarded UPDATE WHERE Status='Pending' — lost race is safe Already settled re-delivery reuses the stored transaction and skips the vendor reuse trx · skip vendor money already captured — do NOT short-circuit; still fulfil Always run the idempotent fulfilment step, which stamps FulfilledDate exactly once 4 · ALWAYS run fulfil — idempotent reached on a fresh claim, a lost claim race, AND a re-delivered already-settled callback If fulfilment fails, log critical and return FULFILMENT_FAILED, never success ok → Applied ✓ | fail → FULFILMENT_FAILED LogCritical · never reports success · FulfilledDate still null → self-heals next pass hover a stage ▸
Fig. 2 — the self-healing settlement pipeline: one coordinator looks the payment up, checks Purpose, branches on already-Settled, verifies with the vendor and cross-checks the amount, claims Pending→Settled once, then ALWAYS runs the idempotent fulfilment; a failed fulfil logs critical and returns FULFILMENT_FAILED so it self-heals on the next pass
05

Where the real thinking went

Every one of these had a tempting shortcut. Choosing the harder-but-correct path — and being able to say why — is the difference between code that works in a demo and code that survives production.

One generic ledger, not a payment column per feature

chose: a shared `dbo.Payment` + `Purpose`/`ReferenceId`over: payment fields bolted onto each feature table

Payment status, idempotency, settlement, refunds and reconciliation are identical across features, so they live once on the ledger and a new paid feature adds (at most) its own small domain table plus one Purpose value. The contrast is the legacy wide table that re-implemented all of it, per feature, in NVARCHAR.

One settlement coordinator, not four copies

chose: a single pipeline every settler delegates toover: per-feature verify→claim→fulfil code

Copy-pasted money code drifts, and drift is where the defects hid. Centralizing the sequence means it's written and audited in one place and tested exhaustively once; each feature contributes only the delivery step that's genuinely feature-specific.

FulfilledDate idempotency, not a distributed transaction

chose: a once-stamped marker column + always-run fulfilmentover: wrapping capture and delivery in one transaction

Capture and delivery can't share a transaction (the capture is a claim against an external charge). Rather than reach for a distributed transaction, the delivery step is made idempotent by a single guarded FulfilledDate claim — which also becomes the signal reconciliation scans for. Simpler, and it turns "stuck" into "self-healing."

Resolve the gateway by the payment's country, not the caller's

chose: the server-stored `CountryCode` on the payment rowover: the country supplied on the callback

A settlement that trusts the caller's country can be steered to the wrong vendor on a cross-country callback. The payment row already records the country it was created under, server-side; settlement resolves the gateway from that. (Finding H2 — it was defaulting to "BD" and would have broken the GB/LK expansion.)

The war story · retiring a money table

Retiring OnlinePayment without charging anyone twice

The dangerous bug wasn't a crash — it was a line that looked like a safety check. Four settlers all ended a re-delivered callback with if the payment is already settled, return success. Reading it in isolation, it's obviously correct: don't settle twice. But settlement is two steps, and that line silently assumed the second step — the feature delivery — had also succeeded. If delivery had failed after the money was claimed, the payment was Settled-but-undelivered, and this exact line threw away every future callback that could have completed it. Charged, nothing delivered, no path back. It was finding C1 of the June review, and its root cause was structural: the same logic copy-pasted four times had drifted, so there was no single place to reason about it.

The fix was to delete the assumption. All four settlers now delegate to one coordinator that always re-runs the (now idempotent) fulfilment — on a fresh settle, on a lost race, and on a re-delivered already-settled callback — with FulfilledDate making the re-run safe and a five-minute reconciliation sweep as the backstop for anything the callbacks never revisit. Then a second adversarial review of the fix caught a regression I'd introduced: the profile-fee "already active" branch didn't stamp FulfilledDate, so if the entitlement was ever granted by another path, that payment would loop in the reconciliation backlog forever. That's what added the give-up window (168 hours) so a genuinely permanent failure drops out for a human instead of re-alerting for eternity. The same pass deleted the unsafe deferred-debit dbo.WalletDebit primitive and folded the membership balance debit into one transaction with the fulfilment claim — so the double-debit pattern it enabled can't come back.

Legacy dbo.OnlinePayment versus the new dbo.Payment platform A row-by-row contrast. The legacy OnlinePayment table stored money as NVARCHAR up to two hundred characters; the new Payment stores DECIMAL eighteen-two. Legacy crammed every feature and bKash's API shape into one wide table; the new ledger is generic with a Purpose discriminator and a nullable ReferenceId. Legacy trusted the callback from client-side state; the new pipeline recomputes and stores the order amount server-side, verifies with the vendor, and cross-checks the captured amount. Legacy copy-pasted the verify-claim-fulfil sequence into each feature where it drifted; the new code has one shared coordinator. Legacy short-circuited a re-delivered callback with an already-settled return-success that lost money; the new coordinator always re-runs the idempotent fulfilment. Legacy had no recovery; the new system reconciles every five minutes. Hover or focus a row to read the consequence. LEGACY · dbo.OnlinePayment NEW · dbo.Payment Money type: legacy NVARCHAR text versus new DECIMAL Amount NVARCHAR(200) money as free text — no arithmetic safety Amount DECIMAL(18,2) real money, server-recomputed total vs Table shape: one wide crammed table versus generic ledger one wide table per feature every feature + bKash's shape crammed in Purpose + ReferenceId ledger one ledger; a new feature adds one enum value vs Callback trust: client-trusted versus server-stored plus vendor verify plus amount cross-check callback trusted from client a tampered amount could settle a cheap order verify + amount cross-check vendor verify vs server amount → AMOUNT_MISMATCH vs Settlement code: copy-pasted per feature versus one shared coordinator verify→claim→fulfil, copy-pasted 4 settlers drifted apart — defects diverged one shared coordinator written + audited once; settlers supply only fulfil vs Re-delivery: already-settled short-circuit lost money versus always idempotent fulfil already settled → return success a failed fulfil was never retried — charged, undelivered ALWAYS re-run idempotent fulfil FulfilledDate claimed once → self-heals, no double credit vs Recovery: none versus reconciliation every five minutes no recovery a stuck payment stayed stuck until a support ticket reconciliation every 5 min Hangfire backstop finds Settled + FulfilledDate null vs correctness by construction — not by patch hover a row ▸
Fig. 3 — legacy vs new, row by row: NVARCHAR money and a client-trusted callback and copy-pasted settlers that lost money on re-delivery, against a DECIMAL ledger with server-side amount cross-check, one shared coordinator, always-idempotent fulfil, and a reconciliation backstop
06

A class of money-loss bugs, gone by construction

The money-loss short-circuit is gone by construction, not by patch: the coordinator re-runs idempotent fulfilment on every attempt, FulfilledDate prevents a double credit, and reconciliation finds anything the callbacks miss. Four drifting settlers collapsed into one audited pipeline serving five features (boost, bundle, membership, profile fee, advertisement) across a vendor-agnostic gateway abstraction, all under ~883 automated tests, with the full settlement flow validated end to end against the bKash tokenized checkout. The gateway abstraction is deliberately extensible: Bundle (internal balance) and bKash are implemented, and five more providers — Nagad, Rocket, SSLCommerz, Stripe, PayHere — drop in behind the same IPaymentGateway seam as one class and one DI line each, resolved per country. RefundAsync lives on every gateway. What all of it buys is a single property: the design is deliberately boring where money is on the line — one ledger, one coordinator, one idempotency column, one reconciliation job — because the thing that must never happen (a captured payment with nothing delivered, or a customer charged twice) has to be impossible by construction, not merely unlikely.

FulfilledDate state machine and the three disjoint reconciliation passes A payment row has two independent axes: Status (Pending or Settled) and FulfilledDate (null or stamped). The four cells are: Pending with no FulfilledDate is a live or abandoned external checkout intent; Pending with FulfilledDate set is an inline-Bundle payment that fulfilled at create time but crashed before the settle label was written; Settled with no FulfilledDate is the money-loss cell — the user was charged but the feature was never delivered; Settled with FulfilledDate set is the terminal happy state. A Hangfire job runs every five minutes with three passes whose WHERE clauses never overlap. Pass A sweeps inline-Bundle fulfilled-but-Pending rows to Settled. Pass B expires abandoned intents past the thirty-minute TTL. Pass C re-dispatches the idempotent settle command for settled-but-unfulfilled rows, giving up after a hundred and sixty-eight hours. Because fulfilment stamps FulfilledDate exactly once, every re-run is safe. Hover or focus a cell or a pass to read it. Status × FulfilledDate · reconciliation every 5 min, 3 disjoint passes FulfilledDate IS NULL FulfilledDate set Status Pending Status Settled Pending, FulfilledDate null: external checkout intent, live or abandoned external intent created, awaiting vendor live if fresh · past 30-min TTL → Pass B Pending, FulfilledDate set: inline-Bundle fulfilled at create but the settle label was not written inline-Bundle fulfilled, not yet labelled Settled crash between fulfil and settle → Pass A Settled, FulfilledDate null: charged but feature not delivered, the money-loss cell charged, undelivered the bug C1 fixed fulfil failed after the claim → Pass C re-dispatches Settled, FulfilledDate set: terminal happy state, money captured and feature delivered done ✓ captured + delivered terminal · every pass leads here or stays put Pass A: inline-Bundle sweep flips fulfilled-but-Pending rows to Settled Pass A · inline-Bundle sweep one UPDATE Pending→Settled (ledger label only) Pass B: expire abandoned external intents past the TTL Pass B · expire abandoned Pending past 30-min TTL → Expired Pass C: re-dispatch the idempotent settle command for settled-but-unfulfilled rows Pass C · re-dispatch settle idempotent re-run · give up after 168 h disjoint WHERE clauses · all windows use SYSUTCDATETIME() · SkipConcurrentExecution is efficiency, not safety idempotency is the real backstop — FulfilledDate is claimed exactly once hover a cell ▸
Fig. 4 — the FulfilledDate state machine: Status × FulfilledDate forms four cells, with Settled + FulfilledDate-null as the charged-but-undelivered money-loss cell; three disjoint reconciliation passes (inline-Bundle sweep, expire abandoned, re-dispatch settle) each match non-overlapping rows every five minutes
07

The whole thing is documented, not just built: a written end-to-end payment review (findings C1–C3, H1–H4 and their remediation), an ADR for idempotency on the 13 high-risk endpoints, and per-migration SQL manifests kept as first-class artifacts. The design is deliberately boring where money is on the line — one ledger, one coordinator, one idempotency column, one reconciliation job — because the goal was never cleverness; it was that the invariant which must hold (every captured payment eventually delivers what it paid for, exactly once) simply can't be broken. Every drifting settler folded into that one audited pipeline is one fewer place it could.

Stack: .NET, ASP.NET Core, C#, MediatR, Dapper + stored procedures, SQL Server, Hangfire (recurring reconciliation), bKash Tokenized Checkout, FluentValidation, xUnit

(sole-engineer build for a production marketplace — walk-through on request)

@misc{ammar2026paymentsplatform,
  author = {Ammar, Md. Abu},
  title  = {Payments Platform — One Generic Ledger, Self-Healing Settlement},
  year   = {2026},
  note   = {Engineering case study}
}