Payments & Seller Settlements¶
Purpose¶
The internal accounting ledger that sits after fulfilment: a durable, reconciled record of what was paid, and what the platform owes each seller once their part of an order is delivered. No real seller payout, refund, or tax-compliance logic exists anywhere in this codebase - see "Deferred work" below. Builds on Seller Order Fulfilment and Marketplace Catalog Model.
Marketplace payment flow¶
Customer
|
v
Razorpay payment (server creates the order, verifies the signature server-side)
|
v
PaymentTransaction (durable record - same DB transaction as everything below)
|
v
Order (parent, PaymentStatus = Paid)
|
v
SellerOrders (one per seller, per PR #4/#10)
|
v
Delivered (per-seller fulfilment - see seller-fulfilment.md)
|
v
SellerSettlement (Admin-generated, immutable financial snapshot)
|
v
Eligible
|
v
Processing
|
v
Settled
"Settled" does not yet mean an automated bank API was called. It means the platform has recorded/confirmed a payout by some external or manual means. No Razorpay Route/RazorpayX payout, Stripe Connect, or real bank transfer happens anywhere in this codebase.
PaymentTransaction model¶
One durable row per successfully verified checkout (PaymentTransaction.CreateVerified, called
inside VerifyAndCreateOrderCommandHandler.CompleteVerifiedPaymentAsync - the same database
transaction as the Order, SellerOrders, inventory commit, and cart clear, so the payment record
and what it paid for can never disagree about whether checkout actually completed).
| Field | Notes |
|---|---|
Amount |
The server-calculated Order.TotalAmount - never a client-supplied figure |
Currency |
INR |
Provider / ProviderOrderId / ProviderPaymentId |
Razorpay / the Razorpay order id / the Razorpay payment id |
Status |
Verified is the only status this codebase's checkout path ever produces |
Never persisted: the Razorpay signature, card/UPI/bank details, or any access token. The type
itself has no property that could hold one (see PaymentTransactionTests.PaymentTransaction_HasNoSignatureProperty).
Payment failure rule¶
An invalid Razorpay signature never creates any PaymentTransaction row - verified in
SettlementGenerationTests.Checkout_InvalidSignature_NeverCreatesAPaymentTransaction. This slice
does not implement recording failed payment attempts (the Failed status exists on the enum for
future compatibility only) - see "Deferred work".
Uniqueness¶
OrderId unique, (Provider, ProviderOrderId) unique, (Provider, ProviderPaymentId) unique -
there is exactly one verified payment transaction per order under this checkout model. All three
were safe to add outright since PaymentTransactions is a brand-new table in this slice's
migration - no existing data could conflict.
Seller settlement architecture¶
SellerSettlement belongs to exactly one SellerOrder (DB-unique on SellerOrderId) and
references the PaymentTransaction it was reconciled against. It is an immutable financial
snapshot once created - MerchandiseAmount/TaxAmount/SellerShippingAmount/commission/net
figures are computed once, at generation time, and never recalculated afterward even if the
underlying SellerOrder amounts were somehow edited later (they aren't - SellerOrder's
financial fields are themselves set once at order-creation time).
Commission calculation¶
MerchandiseAmount = SellerOrder.SubTotal
TaxAmount = SellerOrder.TaxAmount
SellerShippingAmount = SellerOrder.ShippingCost (0 in this slice - see below)
GrossAmount = MerchandiseAmount + TaxAmount + SellerShippingAmount
CommissionBaseAmount = MerchandiseAmount (never tax, never shipping)
PlatformCommissionAmount = CommissionBaseAmount * CommissionRatePercent / 100
NetPayableAmount = GrossAmount - PlatformCommissionAmount
All monetary results rounded to 2 decimal places with MidpointRounding.AwayFromZero (e.g.
333.33 × 7.5% = 24.99975 → 25.00, not 24.99/25.00 via banker's rounding) - see
SellerSettlementTests.Create_RoundingUsesAwayFromZeroAt2DecimalPlaces. Every monetary field is
decimal end to end; no float/double appears anywhere in this calculation.
The parent Order's global shipping fee is not allocated to sellers in this slice -
SellerOrder.ShippingCost is currently always 0 under the existing checkout pricing model
(CheckoutPricing.Calculate computes shipping once, at the parent-Order level). A future
Logistics slice can define a real shipping-cost allocation policy; SellerShippingAmount already
exists on the snapshot ready to receive it without a schema change.
Commission rate configuration¶
Marketplace:Settlements:CommissionRatePercent - fails closed. If missing or outside [0, 100],
settlement generation is rejected with SETTLEMENT_COMMISSION_NOT_CONFIGURED rather than silently
assuming a rate. This is a real revenue-share number and must be an explicit business decision -
see SettlementConfig.ReadCommissionRatePercent. The repository's appsettings.json (production
template) deliberately does not set this key; appsettings.Development.json sets it to
10.00 with a comment making clear this is a local/dev/test value only, not a business decision.
Customer checkout never depends on this configuration - VerifyAndCreateOrderCommandHandler
does not read Marketplace:Settlements:* at all. A missing/invalid commission rate only blocks
settlement generation (an Admin-only, separate operation), never order placement - see
SettlementGenerationTests.Generate_DoesNotBreakOrderPlacement_SettlementConfigMissingButOrderAlreadyExists.
Settlement hold period¶
Marketplace:Settlements:HoldDays - defaults to 0 if missing. This is a deliberately different
choice from commission: a missing hold-days value is far lower-stakes (worst case, settlements
become eligible immediately rather than after a safety window) than a missing/wrong commission
rate (which either overpays or underpays the platform on every single transaction). 0 is also
explicitly sanctioned by this slice's own spec for local/test use. A real deployment must
still set this explicitly to a business-approved value - the code default exists so local
development and this slice's own test suite can run without inventing a production policy, not as
a statement that "0 days" is ever the right production number.
Settlement lifecycle¶
Pending -> Eligible -> Processing -> Settled
Eligible -> OnHold -> Eligible
Processing -> OnHold -> Eligible
Pending -> Cancelled
Eligible -> Cancelled
Settled is permanently terminal - no method on SellerSettlement can move it anywhere else (see
SellerSettlementTests's "Settled_IsImmutable_*" tests). Every domain method
(MarkEligible/StartProcessing/PutOnHold/ReleaseHold/Settle/Cancel) accepts exactly one
set of valid predecessor statuses and throws SettlementInvalidTransitionException (or the more
specific SettlementAlreadySettledException/SettlementOnHoldException) otherwise - there is no
arbitrary status assignment anywhere in this API.
Why generation and MarkEligible are two separate steps¶
POST /admin/settlements/generate/{sellerOrderId} already validates the full eligibility rule
(below) before creating anything, yet the result starts in Pending, not Eligible - a
separate, explicit POST /admin/settlements/{id}/mark-eligible call is required. This is a
deliberate second checkpoint: MarkEligible re-validates the same criteria against the
seller order/payment's current state, protecting against drift between generation time and
whenever an admin actually acts on it (e.g. a delayed cancellation in between).
Settlement eligibility rule¶
A SellerOrder is settlement-eligible only when all of the following hold:
SellerOrder.Status == Delivered(Processing/Confirmed/Packed/ReadyToShip/Shipped/Cancelled all rejected)SellerOrder.DeliveredAtis set- Current UTC time ≥
DeliveredAt + HoldDays - Parent
Order.PaymentStatus == Paid - A
VerifiedPaymentTransactionexists for that order PaymentTransaction.Amount == Order.TotalAmount(payment reconciliation - see below)- No settlement already exists for this
SellerOrder - Commission rate is configured
Every one of these is covered by a dedicated test in SettlementGenerationTests.cs - see
Generate_SellerOrderNotDelivered_ThrowsSettlementNotEligible (parameterized across every
non-Delivered status), Generate_HoldPeriodNotElapsed_ThrowsSettlementNotEligible,
Generate_PaymentAmountMismatch_ThrowsPaymentReconciliationFailed,
Generate_CommissionNotConfigured_ThrowsSettlementCommissionNotConfigured, and
Generate_CalledTwice_SecondCallThrowsSettlementAlreadyExists.
Payment reconciliation¶
Two levels:
- At settlement generation/mark-eligible time:
PaymentTransaction.Amountmust equalOrder.TotalAmountexactly, or the operation is blocked withPAYMENT_RECONCILIATION_FAILED- never silently rounded away or "fixed." - On every settlement DTO (both Admin and seller views):
paymentReconciled(bool),orderAmount,paymentAmountare always included, so a mismatch (however it might arise) is visible on the settlement detail page without needing a separate reconciliation report.
Duplicate / concurrency handling¶
GenerateSettlementCommandHandler checks for an existing settlement before creating one, but a
true race (two concurrent admin requests) is caught by the database's own unique index on
SellerOrderId - SettlementRepository.SaveChangesAsync translates that specific DbUpdateException
into SettlementAlreadyExistsException with the winning record's id, rather than letting a raw
SQL error escape or double-counting the seller order's payable amount. This translation happens
in the Infrastructure layer, not Application - DbUpdateException is an EF Core type, and
Application must not reference it directly (a real Clean Architecture layering bug caught and
fixed during this slice's own development).
Settlement vs refund concurrency (P1 fix)¶
The check above (a unique index on SellerOrderId) only prevents two concurrent
GenerateSettlementCommandHandler calls from creating two settlements for the same seller
order. It does not protect against a different, more subtle race between
GenerateSettlementCommandHandler and InspectReturnCommandHandler: both handlers read
"does a settlement/refund already exist for this seller order" independently, and a genuinely
concurrent pair could each observe the other's pre-state (no committed refund / no settlement
yet) and both commit - producing a gross settlement with no SettlementAdjustment reconciling a
refund that happened concurrently. That is a real marketplace financial loss (the seller is paid
in full for merchandise that was, in fact, returned), not merely a display/reporting bug.
Root cause¶
Neither handler previously took any lock before reading settlement/refund state.
GenerateSettlementCommandHandler did not even run inside an explicit transaction. Two concurrent
requests for the same SellerOrderId (a return being inspected at the same moment an operator or
an automated job generates the settlement) could interleave as:
- Refund path reads "no settlement yet" → takes the "settlement not generated yet" branch (no
SettlementAdjustmentcreated - correctly expects the not-yet-generated settlement to account for it). - Settlement path reads "no prior refunds" → generates a settlement using the full pre-refund merchandise amount.
- Both commit. The refund's seller-side debit is now accounted for nowhere.
Fix: a shared row lock, acquired first, inside both transactions¶
IOrderRepository.LockSellerOrderForFinancialUpdateAsync(sellerOrderId) - a real MySQL/InnoDB
exclusive row lock (SELECT ... FOR UPDATE) on the SellerOrders row, executed via a raw ADO
command sharing the caller's ambient EF transaction. Both GenerateSettlementCommandHandler.Handle
(now wrapped in its own IUnitOfWork.BeginTransactionAsync, which it did not have before) and
InspectReturnCommandHandler.Handle call this as the very first statement inside their
transaction, before any other (plain, non-locking) read.
Why "very first statement" matters, precisely: under MySQL's default REPEATABLE READ
isolation (confirmed the default in every environment this project targets - no isolation-level
override exists anywhere in this codebase), a plain SELECT's result is fixed to the
transaction's consistent-read snapshot, established at that transaction's first plain read -
not to whatever is latest at the moment of the call. A locking read (FOR UPDATE) is
different: it always reads the latest committed row and blocks rather than consulting any
snapshot. So:
- Whichever transaction's
LockSellerOrderForFinancialUpdateAsynccall reaches MySQL first wins the lock immediately (a locking read establishes no snapshot). - The second transaction's identical call blocks until the first transaction commits (or rolls back) and releases the row lock.
- Only after the lock is acquired does either transaction perform its first plain read (settlement existence, prior refunds) - which is what actually establishes that transaction's REPEATABLE READ snapshot. For the transaction that had to wait, that snapshot is therefore taken after the other transaction's commit, so it correctly observes the other transaction's committed change.
Calling the lock anywhere after another plain read in the same transaction defeats the guarantee - the snapshot would already be fixed by then. Both handlers were audited to ensure the lock call precedes every other read.
What is deliberately NOT covered by this lock¶
- External Razorpay calls stay outside any database transaction, as before - the lock only ever wraps local reads/writes, never a network call.
- SQLite (the fast test suite) has no equivalent row-locking primitive; the
IOrderRepository.LockSellerOrderForFinancialUpdateAsyncSQLite branch only preserves the existing-row check (SellerOrderNotFoundException) so the existing SQLite-backed test suite is unaffected - seeMySqlSettlementRefundConcurrencyTests.csfor the real-InnoDB proof this primitive actually serializes the two handlers. - Two concurrent settlement-generation requests for the same seller order still rely on the
pre-existing unique-index/
SettlementAlreadyExistsExceptionmechanism above (unchanged) - the new lock does not replace it, it protects a different pair of handlers.
Tests¶
backend/tests/ECommerce.Tests/Application/MySqlSettlementRefundConcurrencyTests.cs
([Trait("Category","mysql-smoke")], real MySQL/InnoDB) proves: refund-starts-first and
settlement-starts-first both converge on exactly one consistent financial outcome (verified via an
order-independent invariant: the seller's total accounted-for merchandise/tax, across the
settlement itself plus any SettlementAdjustment, always equals the original entitlement minus
the refund's debit - never more, never less); a true simultaneous-start race completes without
deadlock within a 30s timeout; retrying GenerateSettlementCommandHandler/
InspectReturnCommandHandler after a successful call remains idempotent
(SettlementAlreadyExistsException/RefundAlreadyExistsException, no duplicate row, lock properly
released); and settlement generation with no refund involved, and refund inspection with no
settlement existing, both produce byte-for-byte the same amounts as before this fix (the new lock/
transaction wrapping changes no arithmetic).
Admin settlement operations¶
| Endpoint | Effect |
|---|---|
GET /api/admin/settlements |
List, filterable by status/seller/search |
GET /api/admin/settlements/{id} |
Detail with reconciliation |
POST /api/admin/settlements/generate/{sellerOrderId} |
Creates the immutable snapshot (Pending) |
POST /api/admin/settlements/{id}/mark-eligible |
Pending → Eligible, re-validates everything |
POST /api/admin/settlements/{id}/process |
Eligible → Processing |
POST /api/admin/settlements/{id}/hold |
Eligible/Processing → OnHold |
POST /api/admin/settlements/{id}/release-hold |
OnHold → Eligible |
POST /api/admin/settlements/{id}/settle |
Processing → Settled, records a manual payoutReference |
GET /api/admin/payments, /{id} |
Payment reconciliation metadata (no signatures/secrets) |
All Admin-only ([Authorize(Roles = "Admin")]), verified by attribute-wiring tests in
SettlementsControllerAuthorizationTests.cs.
Seller settlement view¶
GET /api/seller/settlements, /{id}, /summary - [Authorize(Roles = "Seller,Admin")].
SellerSettlement.SellerUserId is the seller's owner user id (same convention as
SellerOrder.SellerUserId) - the authenticated caller's own user id is the ownership key, no
separate Seller entity lookup is needed to enforce "seller sees only their own settlements." A
seller can never see, generate, or mutate another seller's settlement, and can never mark a
settlement processed or settled (both require the Admin role, enforced by the same attribute).
Security boundaries¶
- Every financial field (
CommissionRatePercent,PlatformCommissionAmount,NetPayableAmount,SellerUserId,Status) is server-computed. No request body accepted anywhere inSettlementsControllercan set any of these directly - the client only ever sends command inputs (payoutReference, an optionalnote), never authoritative amounts. - No bank password, UPI PIN, Razorpay secret, card detail, or access token is ever stored -
SellerSettlement/PaymentTransactionare internal accounting records only. - Auditability:
CreatedAt/UpdatedAt/EligibleAt/ProcessingAt/SettledAt/CancelledAtplusProcessedByUserId/SettledByUserId(who took the action -ICurrentUserService, already existing infrastructure, not a new audit framework built for this slice) andPayoutReference.
Shipping-cost reimbursement integration¶
GenerateSettlementCommandHandler reads Marketplace:Logistics:SellerShippingReimbursed (default
false) and, when enabled, looks up any non-cancelled Shipment for the SellerOrder being
settled: if one exists, SellerShippingAmount becomes that shipment's actual logistics cost
(Shipment.ShippingCost) rather than the customer-facing charge (SellerOrder.ShippingCost) this
handler used before the logistics slice - the two are never assumed equal. Evaluated once, at
generation time, and never revisited afterwards. See
Marketplace Logistics → Shipping cost model.
Deferred work (explicitly out of scope for this slice)¶
- Real seller payout - no Razorpay Route/RazorpayX payout, Stripe Connect, or bank API call.
Settle()only records a manually-supplied reference string. - Refunds/returns -
PaymentTransactionStatus.Refunded/PartiallyRefundedandRefundAdjustment/ReturnAdjustment/ChargebackAdjustment/ManualAdjustmentare documented extension points, not implemented. HistoricalSellerOrder/SellerSettlementamounts are never mutated to represent a refund - a future slice would add adjustment records alongside the original immutable snapshot, never edit it in place. - SellerOrder cancellation ↔ settlement integration is implemented
(
CancelSellerOrderCommandHandlercancels a Pending/Eligible settlement if one exists, leaves Processing/OnHold/Settled untouched) but is unreachable under the current domain model -SellerOrder.Cancel()only succeeds from Processing/Confirmed, and a settlement only ever exists once a SellerOrder is Delivered, so no SellerOrder that can still be cancelled ever has a settlement to integrate with. This exists purely as the forward-compatible integration point for a future returns/RMA slice that might allow cancelling a Delivered order. - GST/TDS/TCS/statutory tax compliance -
TaxAmountis snapshotted fromSellerOrderfor accounting visibility only. No tax-compliance calculation, filing, or regulatory logic of any kind is implemented or assumed. Do not treat this ledger as a substitute for real Indian tax-compliance tooling. - SellerPayoutAccount / bank details - Seller currently has KYC/PAN/GST fields only (see
Marketplace Catalog Model). A future payout-onboarding slice would add
a properly encrypted/tokenized
SellerPayoutAccount, not stored here.
Related pages¶
- Seller Order Fulfilment
- Marketplace Logistics - the Shipment aggregate whose actual shipping cost this settlement can optionally reimburse
- Marketplace Returns & Refunds - pre/post-settlement refund integration (commission avoidance vs. SettlementAdjustment), and why Settled remains immutable
- Marketplace Catalog Model
- Marketplace Notifications - the seller notifications fired on settlement generation and every status transition
- Marketplace Reporting & Dashboards - reads this settlement snapshot (never recalculates it) for gross/commission/net-payable/settled/pending figures
- Application Architecture