Skip to content

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:

  1. SellerOrder.Status == Delivered (Processing/Confirmed/Packed/ReadyToShip/Shipped/Cancelled all rejected)
  2. SellerOrder.DeliveredAt is set
  3. Current UTC time ≥ DeliveredAt + HoldDays
  4. Parent Order.PaymentStatus == Paid
  5. A Verified PaymentTransaction exists for that order
  6. PaymentTransaction.Amount == Order.TotalAmount (payment reconciliation - see below)
  7. No settlement already exists for this SellerOrder
  8. 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:

  1. At settlement generation/mark-eligible time: PaymentTransaction.Amount must equal Order.TotalAmount exactly, or the operation is blocked with PAYMENT_RECONCILIATION_FAILED - never silently rounded away or "fixed."
  2. On every settlement DTO (both Admin and seller views): paymentReconciled (bool), orderAmount, paymentAmount are 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 in SettlementsController can set any of these directly - the client only ever sends command inputs (payoutReference, an optional note), never authoritative amounts.
  • No bank password, UPI PIN, Razorpay secret, card detail, or access token is ever stored - SellerSettlement/PaymentTransaction are internal accounting records only.
  • Auditability: CreatedAt/UpdatedAt/EligibleAt/ProcessingAt/SettledAt/CancelledAt plus ProcessedByUserId/SettledByUserId (who took the action - ICurrentUserService, already existing infrastructure, not a new audit framework built for this slice) and PayoutReference.

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/PartiallyRefunded and RefundAdjustment/ReturnAdjustment/ChargebackAdjustment/ManualAdjustment are documented extension points, not implemented. Historical SellerOrder/SellerSettlement amounts 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 (CancelSellerOrderCommandHandler cancels 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 - TaxAmount is snapshotted from SellerOrder for 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.