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).
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