Skip to content

LEG-03: Seller KYC / GSTIN / PAN Compliance

Remediation pass (Codex independent preliminary review): this document was updated after a round of P1/P2 fixes - closing the real customer-facing activation boundary (see "Selling eligibility & the real activation boundary"), continuing enforcement when Approved KYC is later invalidated (see "Continuing enforcement"), tighter PAN/GSTIN masking, dual-role self-review denial, sensitive audit-snapshot minimization, a genuine first-create concurrency guard, and several smaller hardenings. Each is called out inline below and in code comments at its exact location; nothing in this pass was self-certified - it awaits independent review before merge, same as the original tranche.

Final post-#44 integration: LEG-02 (PR #44, statutory product compliance) has since merged to release/web-launch-readiness, and this branch has been rebased onto that baseline. "Selling eligibility & the real activation boundary" and "Continuing enforcement" below now describe the combined LEG-02+LEG-03 gate as it actually is in code, not the LEG-03-only design the remediation pass above originally documented - the "Known future merge point" this doc used to flag as pending has been resolved and is no longer a forward-looking note.

P1-1 remediation (Codex final review): the combined gate above required an Approved SellerKycRecord but did not consistently require the PRE-EXISTING Seller.Status/ Seller.KycStatus fields (see "Relationship to seller onboarding" below) to also be Approved/Verified - meaning an admin suspending or rejecting a seller through the legacy SellersController endpoints did not stop that seller's already-Active listings from remaining customer-buyable, since only SellerKycRecord.Status was checked. "Selling eligibility & the real activation boundary" and "Continuing enforcement" below now describe the fully combined THREE-field rule (Seller.Status, Seller.KycStatus, SellerKycRecord.Status, all three, plus LEG-02), and a new "Seller administration cascades to offer eligibility" section documents the suspend/reject-side enforcement this closes.

Overview

SellerKyc is the seller-level statutory identity/tax-compliance record: legal business name, business type, PAN, and (where applicable) GSTIN and tax registration state. It has its own admin-reviewed approval lifecycle, an immutable audit trail, and a genuine optimistic-concurrency guard - independent of, and additive to, the marketplace's existing seller-onboarding-approval flow (see "Relationship to seller onboarding" below, which is important to read before assuming this replaces anything).

State machine

NotSubmitted --Submit()--> Submitted --StartReview()--> UnderReview
     ^                         |                              |
     |                         +----------Approve()-----------+---> Approved
     |                         |                              |
     +---------Update()--------+----------Reject()------------+---> Rejected
                                                                        |
                                                          Update() -----+
                                                          (back to NotSubmitted,
                                                           same row - a resubmission)
  • Update() is allowed from any status and always resets Status back to NotSubmitted - see SellerKyc.Update's own doc comment. This closes a gap found in the LEG-02 review of ProductCompliance.Update (which left an Approved record silently Approved after its facts changed): here, an edit to Approved or Rejected or mid-review declarations always invalidates whatever review state existed and requires a fresh Submit().
  • There is no separate Resubmitted enum value - a resubmission is the same NotSubmitted -> Submitted transition on the same row. SellerKyc.SubmissionCount (incremented only by Submit(), never reset by Update()) is what the audit trail actually uses to label an action "Submitted" vs "Resubmitted", because Status alone cannot tell a genuinely-first submission apart from a post-rejection resubmission by the time Submit() runs again.
  • StartReview() (Submitted -> UnderReview) is an optional queue-visibility signal only, not a hard lock - see "Concurrency" below for the actual conflict guard.
  • Approve()/Reject() require Submitted or UnderReview and always fail closed (SellerKycInvalidTransitionException) from any other status - including Approved/Rejected themselves, so a second decision can never silently land on top of a first one via a stale in-memory read that happens to still show the old status (see "Concurrency").

Security

  • Seller A/B isolation: every seller-reachable handler (Upsert, Submit, Get) checks IsAdmin OR sellerId == currentUser.UserId. A seller can never read or write another seller's record.
  • Customer denial: SellerKycController's routes are [Authorize(Roles = "Seller,Admin")] (or "Admin" for review/audit-history/queue) - a Customer role token is rejected by ASP.NET Core's authorization middleware before any handler runs.
  • Self-approval is structurally impossible, including for a dual-role account (P1-3 fix): ReviewSellerKycCommandHandler and StartSellerKycReviewCommandHandler both require IsAdmin AND explicitly reject cmd.SellerId == currentUser.UserId - the second check exists specifically because IsAdmin alone is insufficient for an account that legitimately holds the Admin role and happens to be the seller under review (a real, not hypothetical, shape - the same person can be provisioned as both). Neither of the two seller-reachable commands (Upsert, Submit) ever sets Status to Approved/Rejected or touches ReviewedByAdminId - those fields are only ever written by SellerKyc.Approve/Reject, only callable from the Admin-only, self-review- denying review handler. Admin editing a seller's details on their behalf (Upsert with IsAdmin=true) does not auto-approve - unlike LEG-02's ProductCompliance, approval here is always a deliberate, separate Review action.
  • PAN/GSTIN masking keeps only the last 4 characters (P1-2 fix): the original masking kept the first 3-4 and last 3-4 characters, which - because PAN/GSTIN are structured identifiers with a small, well-known alphabet per position, and GSTIN embeds the PAN verbatim at a fixed offset - left too little real search space to call "masked" in a meaningful data-minimization sense. SellerKycMasking.MaskPan/MaskGstin now reveal only the last 4 characters (ABCDE1234F -> ******234F, 22AAAAA0000A1Z5 -> ***********A1Z5) - enough for a human scanning a list to disambiguate two entries, nowhere near enough to reconstitute the rest. GSTIN masking specifically never reveals any of its embedded-PAN characters (positions 3-12), even though a naive head/tail mask would have included them.
  • PAN/GSTIN exposure: the full, unmasked record is returned only to its owning seller or an Admin (GetSellerKycQuery's ownership check). The Admin review queue (GetSellerKycReviewQueueQuery, now returning a SellerKycQueuePageDto with a real Total - P3 fix) returns SellerKycQueueItemDto with the masking above applied - a list view has no legitimate need for every pending seller's full identifiers at once; opening a specific record for an actual decision is what returns the real values. Every endpoint that CAN return a full unmasked identifier (GetKyc/UpsertKyc/SubmitKyc/StartReview/ReviewKyc/GetAuditHistory) is decorated [NoStore] (ECommerce.API.Filters.NoStoreAttribute, P2 fix) - Cache-Control: no-store - to reduce client-side/intermediary retention; the masked queue endpoint does not need it.
  • Sensitive audit snapshots are minimized (P2 fix): SellerKycAuditRecord.SnapshotJson used to serialize the FULL SellerKycDto (raw PAN/GSTIN) on every single mutation - an indefinitely growing, unmasked archive of every value this seller has ever submitted, multiplying the blast radius of any future exposure well beyond the live row itself. It now serializes SellerKycAuditSnapshotDto - masked PAN/GSTIN plus the non-sensitive status/action/reviewer/ notes/timestamp metadata a genuine compliance audit trail actually needs. The audit table remains Admin-only and append-only (see SellerKycAuditRecord's own doc comment for why THAT boundary - who can read it, and that it is never updated - is the real audit-integrity guarantee here, not cryptographic tamper-evidence, which is not implemented).
  • No logging of raw identifiers: nothing in this feature calls ILogger with PanNumber/Gstin directly.
  • Rejection reason rendered as plain text on the seller-facing page (never dangerouslySetInnerHTML) - an admin-authored free-text reason is untrusted-origin as far as the browser is concerned. It is now stored separately from an approval's own notes (SellerKyc.ReviewNotes vs RejectionReason - previously conflated in one column, so an Approved record could show a non-null "rejection reason" that was actually approval commentary).
  • Business type is validated with Enum.IsDefined, not just Enum.TryParse (P2 fix): Enum.TryParse<SellerBusinessType>("99", ...) returns true with a value that is not a real enum member - Enum.IsDefined is required to reject it. Enforced both in UpsertSellerKycCommandHandler (fail-fast, clear error) and in SellerKyc.Update itself (defense in depth).
  • Field lengths are validated against DB column max lengths at the domain boundary (P2 fix) - LegalBusinessName, TaxRegistrationState, RejectionReason, ReviewNotes, ProviderName, ProviderReference all throw a controlled ArgumentException (400) when oversized, never an unhandled DB truncation/constraint exception (500).
  • Provider invariant enforced (P2 fix): SellerKyc.RecordProviderVerificationAttempt throws if verified: true is passed alongside attempted: false - that combination would let this codebase claim a PAN/GSTIN was externally verified when no verification ever took place, exactly what IKycVerificationProvider's own contract forbids.
  • Concurrent first-create no longer surfaces a raw 500 (P2 fix): two requests racing to create the FIRST SellerKyc row for the same never-before-seen seller both pass the existing is null check and both attempt an INSERT; only one wins IX_SellerKycRecords_SellerId's unique index. SellerKycRepository.SaveChangesAsync now recognizes the resulting duplicate-key DbUpdateException and translates it into the same SellerKycConcurrencyConflictException (409) the UPDATE-race path already produced - never an unhandled raw exception.

Concurrency

SellerKyc.Version is a genuine EF Core optimistic-concurrency token (.IsConcurrencyToken() on a plain int column - portable across MySQL/SQLite, unlike a SQL Server-style rowversion). Two writers who both loaded the same row/Version and both try to save produce exactly one winner; the loser's SaveChangesAsync throws DbUpdateConcurrencyException, translated by SellerKycRepository.SaveChangesAsync into the application-level SellerKycConcurrencyConflictException (409 Conflict) - never a silent overwrite. Two defenses exist together:

  1. Fresh-read status guard (the common case): each command handler's own GetBySellerIdAsync call is a brand-new read at the start of that request. If Admin A's approval already committed by the time Admin B's review request starts, B's own read already sees Approved, and B's Approve/Reject call throws SellerKycInvalidTransitionException immediately - no Version conflict even needed.
  2. Version mismatch (the narrower, genuinely-simultaneous case): if B's entity was already loaded into memory before A's write committed, B's in-memory Status still reads Submitted and the status guard alone would not catch it - only the DB-level Version mismatch does.

Both are covered by tests - see SellerKycConcurrencyTests.cs (SQLite, two independent AppDbContexts against one shared connection) and MySqlSellerKycConcurrencyTests.cs (real MySQL/InnoDB, [Trait("Category", "mysql-smoke")]).

Provider abstraction

IKycVerificationProvider (ECommerce.Application.Common.Interfaces) is the seam for a future real PAN/GSTIN verification integration. The only registered implementation today, ManualOnlyKycVerificationProvider, always returns Attempted=false, Verified=null - so this codebase never represents a PAN/GSTIN as "externally verified" when no such verification took place, and the entire workflow (manual admin review) works with zero external dependency. Swapping in a real provider later is a single DI registration change (DependencyInjection.cs) - nothing in SellerKyc, its command handlers, or the review workflow needs to change.

Selling eligibility & the real activation boundary (P1-1 fix, now the combined LEG-02+LEG-03 gate)

The original tranche gated the wrong boundary. ISellerEligibilityService.EnsureApprovedForSellingAsync was wired into PublishProductCommandHandler only - but that handler governs the shared Product catalog entry (an Admin-owned resource a seller does not exclusively control), not the seller's own customer-buyable listing. The ACTUAL customer-facing offer-publication boundary is POST /api/marketplace/seller-products/{id}/activate (MarketplaceCatalogController.ActivateSellerProduct) - SellerProduct.Status = Active is exactly what SeoFeedService.ResolvePublicOffer selects from for the product page, what checkout resolves against (PaymentFeatures.ResolveCheckoutItemAsync), and what the SEO/Merchant feeds enumerate. A seller with no KYC at all could previously activate a listing directly through this endpoint and it would immediately become live and purchasable - the PublishProductCommandHandler gate never ran anywhere near this path.

Fix: IMarketplaceCatalogRepository.TryActivateSellerProductAsync is a single atomic conditional UPDATE ... WHERE ... AND EXISTS (...) AND EXISTS (...) with two EXISTS subqueries - the eligibility checks and the status flip happen in ONE SQL statement (same pattern as TryReserveInventoryAsync), so there is no "check eligibility, then write Active" window a concurrent KYC or compliance change could race:

  1. EXISTS (SELECT 1 FROM SellerKycRecords k INNER JOIN Sellers s ON s.OwnerUserId = k.SellerId WHERE s.Id = SellerProducts.SellerId AND k.Status = 'Approved' AND s.Status = 'Approved' AND s.KycStatus = 'Verified') - LEG-03, extended by the P1-1 remediation to require ALL THREE seller-side fields, not SellerKycRecord.Status alone. What each means, and why all three are required together (see "Relationship to seller onboarding" below for the full picture):
    • Seller.Status (PendingVerification/Approved/Suspended/Rejected) - the marketplace's own onboarding-approval state for whether this seller account may operate AT ALL, set by SellersController's admin-only onboard/approve/suspend/reject endpoints. This is the field an admin actually mutates for day-to-day seller administration (suspending a problem seller, rejecting a failed onboarding) - see "Seller administration cascades to offer eligibility" below for why it MUST gate selling directly, not just gate Approve().
    • Seller.KycStatus (NotStarted/Submitted/Verified) - the legacy onboarding-time identity check Seller.Approve() itself requires (Verified) before allowing Status -> Approved. Checked explicitly here too (not merely relied upon transitively through Status == Approved) so the rule stays correct even against a hypothetical future code path that could set Status without going through Approve().
    • SellerKycRecord.Status (NotSubmitted/Submitted/UnderReview/Approved/ Rejected) - this PR's own, separate, more rigorous, ongoing statutory PAN/GSTIN compliance record (see "Overview" above and "Relationship to seller onboarding" below). A listing is customer-buyable only when all three read Approved/Verified/Approved simultaneously - an Approved SellerKycRecord never overrides a Suspended/Rejected Seller.Status, and vice versa; neither is a substitute for the other, and this is the single reusable rule (CheckoutEligibilityService.EvaluateEligibility, called from every authoritative boundary below) rather than each boundary re-deriving a subtly different combination.
  2. EXISTS (SELECT 1 FROM ProductCompliances pc INNER JOIN Products p ON p.Id = pc.ProductId WHERE pc.ProductId = SellerProducts.ProductId AND pc.Status = 'Approved' AND p.Price <= pc.MaximumRetailPrice) - LEG-02, added during the post-#44 integration. Checking ProductCompliances.Status = 'Approved' (rather than re-deriving every individual declaration-completeness rule in SQL) is deliberate and sound: ProductCompliance.Update() unconditionally resets Status away from Approved the instant any declared field changes, and Approve() itself re-validates every declaration before allowing the transition - so Status == Approved is exactly equivalent to "every declaration was valid as of the most recent approval, and nothing has edited them since." Product.Price, living on a different entity and mutable independently of ProductCompliance before a listing's first activation, is the one fact that can still drift on its own - hence the explicit Price <= MaximumRetailPrice predicate alongside the status check.

Both must hold simultaneously - Approved KYC alone or Approved, price-consistent compliance alone is not sufficient. ActivateSellerProduct calls this instead of listing.Activate() directly. Applies equally to Admin - there is no documented emergency bypass in this tranche; an Admin activating a listing on a seller's behalf is held to the exact same combined gate a seller acting on their own listing is. An Admin who needs to unblock a seller/product urgently must do so through the KYC review or compliance review endpoints (the actual sources of truth), never by bypassing activation. PublishProductCommandHandler runs both ISellerEligibilityService .EnsureApprovedForSellingAsync and LEG-02's own pre-existing compliance check before product.Publish() - both are documented defense-in-depth at the shared-catalog level, not the authoritative boundary this section describes.

Covered by SellerProductActivationEligibilityTests.cs (the KYC axis, compliance held constant at Approved): denied for NotSubmitted/Submitted/UnderReview/Rejected, succeeds for Approved, idempotent (not an error) when already Active, Seller A cannot exploit Seller B's approval, and a nonexistent listing returns false cleanly. CombinedActivationEligibilityTests.cs covers the full truth table (both axes varying together), the Price > MaximumRetailPrice denial, Admin-cannot- bypass-either-gate via the real controller action, and that Seller A's KYC/Product A's compliance cannot authorize Seller B's/Product B's offer.

Testability note - the Price <= MaximumRetailPrice predicate specifically: EF Core's Sqlite provider stores decimal with TEXT affinity and compares two TEXT-affinity columns byte-wise, not numerically - confirmed empirically (a raw "1500.0" <= "999.0" returns TRUE on SQLite). Real MySQL DECIMAL columns compare numerically and have no such issue. SQLite tests of this predicate use equal-digit-length values so lexicographic and numeric ordering happen to agree; MySqlPriceComplianceActivationTests.cs (mysql-smoke) independently proves the real, numerically-correct behavior against MySQL, including the inclusive <= boundary and the exact digit-length shape that produces a false positive on SQLite.

Seller administration cascades to offer eligibility (P1-1 fix)

SellersController's pre-existing, admin-only suspend/reject endpoints mutate Seller.Status directly and, before this fix, did nothing further - an admin suspending a problem seller (fraud, policy violation, a support escalation) left every one of that seller's already-Active listings fully customer-buyable, because the only thing TryActivateSellerProductAsync/ CheckoutEligibilityService checked was SellerKycRecord.Status, which the suspension never touched. Fixed by mirroring ReviewSellerKycCommandHandler's own established pattern exactly: SellersController.Suspend/.Reject now call ISellerOfferSuspensionService .SuspendActiveOffersForIneligibleSellerAsync in the SAME database transaction as the Seller.Status write (via IUnitOfWork.BeginTransactionAsync) - both commit together or neither does, so there is never an observable moment where Seller.Status shows Suspended/Rejected but an offer is still Active. That single call already does two things atomically (see ISellerOfferSuspensionService's own doc comment):

  1. Suspends every Active SellerProduct this seller owns (UPDATE ... WHERE Status = 'Active' AND SellerId IN (SELECT Id FROM Sellers WHERE OwnerUserId = @sellerOwnerUserId)).
  2. Revokes every Active CheckoutEligibilityFence line referencing this seller (RevocationReason = 'SELLER_KYC_INVALIDATED') - so an in-flight checkout that already acquired a fence for this seller's listing cannot proceed to payment verification even if the fence was acquired moments before the suspension committed.

Payment verification needs no separate code path for this: VerifyAndCreateOrderCommandHandler's existing authoritative reassertion (ICheckoutEligibilityService.ReassertAuthoritativeEligibilityAsync -> IsCustomerBuyableAsync -> EvaluateEligibility) already checks Seller.Status/Seller.KycStatus as part of the SAME single predicate every other boundary uses, so a seller suspended between checkout-fence acquisition and payment verification fails closed automatically and runs the existing captured-payment compensation/refund path (see payment-production-hardening.md) exactly as it already does for a KYC/compliance invalidation - no bespoke "seller suspended" branch was added.

Covered by SellerAdministrationEligibilityCascadeTests.cs (Status/KycStatus truth table for activation, suspend-revokes-existing-offer-and-fence, reject-revokes, legacy-KYC-invalidation without a Seller.Status change, and the MySQL concurrency cases - suspension racing fence consumption/payment verification can never leave an unauthorized completed order).

Continuing enforcement (P1-4 fix, extended to LEG-02 during post-#44 integration, and to

Seller.Status/KycStatus by the P1-1 remediation above)

Gating NEW activations is not enough on its own - a seller/product that WAS eligible and already has a live offer must not keep it live once that eligibility is later invalidated. The invariant this tranche now maintains by construction, across all three seller-side fields plus LEG-02:

SellerProduct.Status == Active implies its owning seller currently holds Seller.Status == Approved, Seller.KycStatus == Verified, and Approved SellerKycRecord, and its underlying Product currently has Approved, price-consistent ProductCompliance.

Three halves keep this true:

  1. Into Active: only TryActivateSellerProductAsync's atomic conditional UPDATE (above), which checks every axis together.
  2. Out of Active, seller axis (KYC record invalidation OR Seller.Status/KycStatus leaving eligible): ISellerOfferSuspensionService .SuspendActiveOffersForIneligibleSellerAsync - a single conditional UPDATE SellerProducts SET Status = 'Suspended' WHERE Status = 'Active' AND SellerId IN (SELECT Id FROM Sellers WHERE OwnerUserId = @sellerOwnerUserId). Called whenever SellerKyc transitions AWAY from Approved: UpsertSellerKycCommandHandler (edit invalidated it) and ReviewSellerKycCommandHandler (on Reject()).
  3. Out of Active, compliance axis: ISellerOfferSuspensionService .SuspendActiveOffersForIneligibleProductAsync - the identical pattern, scoped by ProductId instead of SellerId (a shared Product's compliance leaving Approved affects EVERY seller currently offering it, not just whichever seller happened to trigger the edit). Called from UpsertProductComplianceCommandHandler (edit invalidated it) and ReviewProductComplianceCommandHandler's reject branch (defense-in-depth, unconditional on every rejection - mirroring the KYC side's own pattern, even though ProductCompliance.Reject() cannot currently be called directly from Approved, the same way SellerKyc.Reject() cannot).

All four call sites wrap their suspension call and their own entity's SaveChangesAsync in one real database transaction (ISellerKycRepository.BeginTransactionAsync / IUnitOfWork.BeginTransactionAsync) - both commit together or neither does, so there is never an observable moment where the KYC/compliance record shows invalidated but the offer is still Active (or the reverse).

Suspended (not Inactive) is used deliberately - it is a distinct, pre-existing SellerProductStatus value, so a platform-forced takedown for a compliance/KYC reason reads differently in seller/admin UI than the seller's own voluntary pause, even though both are equally blocked from reactivation by the same atomic, dual-gated TryActivateSellerProductAsync.

Cart/checkout/SEO need no separate check, for either axis. PaymentFeatures .ResolveCheckoutItemAsync already fails closed on any non-Active SellerProductStatus, and SeoFeedService.ResolvePublicOffer already filters to Active only - both pre-existing, unrelated-to-eligibility guards, and both flip on the SAME Status field regardless of which axis suspended it. Confirmed end-to-end (real gate + real invalidation handlers, not a shortcut .Deactivate() call) by CheckoutCartSeoFailClosedForCombinedGateTests.cs: a genuinely-eligible, already-cart'd offer is rejected at cart-add and fails closed at checkout (zero Razorpay calls, no reservation) once either axis is invalidated via the real production command handlers.

The TOCTOU race ("eligibility checked as Approved, then invalidated concurrently, but activation still succeeds") cannot happen on either axis: TryActivateSellerProductAsync's EXISTS subqueries are evaluated by the database AT THE MOMENT the UPDATE runs, as part of the same statement - there is no separate "read eligibility" step earlier in the request whose result could go stale. Covered by SellerOfferContinuingEnforcementTests.cs (KYC axis, including ConcurrentInvalidationDuringActivation_NeverLeavesAnUnauthorizedLiveOffer) and Leg02ContinuingEnforcementTests.cs (compliance axis, the equivalent race plus multi-seller invalidation reach and the price/MRP boundary).

Relationship to seller onboarding's existing Gstin/Pan/KycStatus

The pre-existing Seller entity already has Gstin/Pan/KycStatus fields (NotStarted -> Submitted -> Verified) populated at onboarding time (SellersController.Onboard) and admin-gated verify/approve/reject endpoints (SellersController.VerifyKyc/Approve/Reject, [Authorize(Roles = "Admin")]). That flow is intentionally left untouched by this PR. It answers a narrower question - "is this seller account allowed to exist/operate in the marketplace at all" - with no format validation, no audit trail, no rejection-reason capture, and no resubmission workflow.

SellerKyc (this document) is a deliberately more rigorous, ongoing, statutory-grade compliance record - PAN/GSTIN format validation, full audit trail, structured rejection reason codes, resubmission, and concurrency protection - suited to gating actions with real legal/financial weight (selling, and a natural future candidate: settlement payout release). Consolidating the two concepts (e.g. retiring the legacy fields once this is adopted marketplace-wide) is recommended future work, deliberately out of scope here to keep this PR isolated and independently reviewable.

Migration

AddSellerKyc adds SellerKycRecords, SellerKycAuditRecords, CheckoutEligibilityFences, and CheckoutEligibilityFenceLines tables along with their indexes. No fake KYC or compliance data is fabricated for pre-existing sellers.

Fail-Closed Grandfathered Reconciliation & Migration Ledger

Under the combined invariant (P1-1 remediation - see "Selling eligibility & the real activation boundary" above), no SellerProduct may remain customer-buyable Active unless its owning seller has Approved SellerKyc AND the pre-existing Seller.Status = Approved AND Seller.KycStatus = Verified, its underlying Product has Approved ProductCompliance, and Price <= MaximumRetailPrice. Pre-existing Active listings that do not satisfy this combined rule are fail-closed suspended during AddSellerKyc.Up() - a seller who was already administratively Suspended/Rejected (legacy Seller.Status) before this migration ever runs is NOT grandfathered in merely because a SellerKycRecord happens to be Approved.

To guarantee safe operational rollbacks without blindly reactivating unrelated suspended listings: 1. Durable Reconciliation Ledger (_Migration_SellerProductReconciliations): Before suspending an Active SellerProduct, AddSellerKyc.Up() records the target SellerProductId, OriginalStatus ('Active'), OriginalUpdatedAt, ReconciledAt (timestamp of migration execution), and MigrationName ('20260908080759_AddSellerKyc'). The ledger contains zero PAN/GSTIN secrets or sensitive seller identifiers. 2. Deterministic Suspension: Only rows recorded in _Migration_SellerProductReconciliations are updated to Status = 'Suspended' with UpdatedAt = ReconciledAt. 3. Selective Down() Rollback: During AddSellerKyc.Down(), the migration selectively restores Status = 'Active' ONLY for rows recorded in _Migration_SellerProductReconciliations where current Status == 'Suspended' AND UpdatedAt == ReconciledAt (unmodified since migration suspension). 4. Fail-Closed for Modified Listings: If a listing was modified, reactivated, or updated by an admin/seller post-migration (UpdatedAt != ReconciledAt), Down() fails closed and leaves the listing Suspended to prevent accidental invalid state restoration. 5. Clean Ledger Cleanup: The ledger table is dropped only after safe restoration SQL executes.

Operational Migration Runbooks

  • Pre-Migration Runbook:
  • Query active seller listings lacking Approved SellerKyc or ProductCompliance.
  • Send proactive operational notifications to affected sellers advising them to submit KYC and statutory compliance details.
  • Post-Migration Runbook:
  • Monitor migration execution and inspect _Migration_SellerProductReconciliations table for count of grandfathered suspended listings.
  • Sellers complete KYC submission (POST /api/sellers/kyc/submit) and statutory compliance; admin approval (POST /api/sellers/kyc/review) automatically allows subsequent atomic listing activation via TryActivateSellerProductAsync.
  • Rollback Runbook:
  • Execute dotnet ef database update 20260907182159_AddProductStatutoryCompliance.
  • Down() restores unmodified grandfathered listings to Active state; modified/reactivated listings remain Suspended.
  • Drop of _Migration_SellerProductReconciliations, CheckoutEligibilityFenceLines, CheckoutEligibilityFences, SellerKycAuditRecords, and SellerKycRecords completes cleanly.

Final chain order (post-#44 rebase): ... -> AddCheckoutIdempotencyRecords -> AddProductStatutoryCompliance (#44) -> AddSellerKyc (#46). dotnet ef migrations has-pending-model-changes reports 0 pending changes.

Known limitations (not fixed here, documented)

  • A true process crash between two admins' concurrent decisions is still resolved correctly (see "Concurrency") - there is no known gap here, unlike the payment-idempotency crash windows documented in payment-production-hardening.md.
  • No real external PAN/GSTIN verification provider is implemented - by design, see "Provider abstraction".
  • The legacy Seller.Gstin/Pan/KycStatus fields are not deprecated or removed - see "Relationship to seller onboarding". The admin sellers LIST endpoint (GET /api/sellers/admin) now masks them (same principle as the KYC review queue); single-seller responses (a seller's own profile, or the legacy verify-kyc/approve/reject actions' own response) still return them in full, since that is the caller's own data or a single record an admin just acted on directly, not a bulk list.
  • SellerKycAuditRecord immutability is an application-level guarantee (the entity has no Update method, and nothing in this codebase calls one) - there is no database-level append-only/WORM constraint or cryptographic tamper-evidence. If that stronger guarantee is needed later, it is out of scope for this tranche; do not represent this table as tamper-proof beyond "application code never updates it."
  • No foreign key exists from SellerKycAuditRecords to SellerKycRecords (only plain, indexed Guid columns) - deliberate, mirroring ProductComplianceAuditRecord's (LEG-02) same choice, so the audit trail for a given seller survives even in the hypothetical case its live SellerKyc row is later removed by a future data-retention process. The FK to Users on SellerKycRecords itself IS enforced (see "Migration").