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 torelease/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
ApprovedSellerKycRecordbut did not consistently require the PRE-EXISTINGSeller.Status/Seller.KycStatusfields (see "Relationship to seller onboarding" below) to also be Approved/Verified - meaning an admin suspending or rejecting a seller through the legacySellersControllerendpoints did not stop that seller's already-Active listings from remaining customer-buyable, since onlySellerKycRecord.Statuswas 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 resetsStatusback toNotSubmitted- seeSellerKyc.Update's own doc comment. This closes a gap found in the LEG-02 review ofProductCompliance.Update(which left anApprovedrecord silentlyApprovedafter its facts changed): here, an edit to Approved or Rejected or mid-review declarations always invalidates whatever review state existed and requires a freshSubmit().- There is no separate
Resubmittedenum value - a resubmission is the sameNotSubmitted -> Submittedtransition on the same row.SellerKyc.SubmissionCount(incremented only bySubmit(), never reset byUpdate()) is what the audit trail actually uses to label an action"Submitted"vs"Resubmitted", becauseStatusalone cannot tell a genuinely-first submission apart from a post-rejection resubmission by the timeSubmit()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()requireSubmittedorUnderReviewand always fail closed (SellerKycInvalidTransitionException) from any other status - includingApproved/Rejectedthemselves, 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) checksIsAdmin 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):
ReviewSellerKycCommandHandlerandStartSellerKycReviewCommandHandlerboth requireIsAdminAND explicitly rejectcmd.SellerId == currentUser.UserId- the second check exists specifically becauseIsAdminalone 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 setsStatustoApproved/Rejectedor touchesReviewedByAdminId- those fields are only ever written bySellerKyc.Approve/Reject, only callable from the Admin-only, self-review- denying review handler. Admin editing a seller's details on their behalf (UpsertwithIsAdmin=true) does not auto-approve - unlike LEG-02'sProductCompliance, approval here is always a deliberate, separateReviewaction. - 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/MaskGstinnow 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 aSellerKycQueuePageDtowith a realTotal- P3 fix) returnsSellerKycQueueItemDtowith 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.SnapshotJsonused to serialize the FULLSellerKycDto(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 serializesSellerKycAuditSnapshotDto- 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 (seeSellerKycAuditRecord'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
ILoggerwithPanNumber/Gstindirectly. - 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.ReviewNotesvsRejectionReason- 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 justEnum.TryParse(P2 fix):Enum.TryParse<SellerBusinessType>("99", ...)returnstruewith a value that is not a real enum member -Enum.IsDefinedis required to reject it. Enforced both inUpsertSellerKycCommandHandler(fail-fast, clear error) and inSellerKyc.Updateitself (defense in depth). - Field lengths are validated against DB column max lengths at the domain boundary (P2 fix) -
LegalBusinessName,TaxRegistrationState,RejectionReason,ReviewNotes,ProviderName,ProviderReferenceall throw a controlledArgumentException(400) when oversized, never an unhandled DB truncation/constraint exception (500). - Provider invariant enforced (P2 fix):
SellerKyc.RecordProviderVerificationAttemptthrows ifverified: trueis passed alongsideattempted: false- that combination would let this codebase claim a PAN/GSTIN was externally verified when no verification ever took place, exactly whatIKycVerificationProvider's own contract forbids. - Concurrent first-create no longer surfaces a raw 500 (P2 fix): two requests racing to create
the FIRST
SellerKycrow for the same never-before-seen seller both pass theexisting is nullcheck and both attempt an INSERT; only one winsIX_SellerKycRecords_SellerId's unique index.SellerKycRepository.SaveChangesAsyncnow recognizes the resulting duplicate-keyDbUpdateExceptionand translates it into the sameSellerKycConcurrencyConflictException(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:
- Fresh-read status guard (the common case): each command handler's own
GetBySellerIdAsynccall 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 seesApproved, and B'sApprove/Rejectcall throwsSellerKycInvalidTransitionExceptionimmediately - no Version conflict even needed. - 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
Statusstill readsSubmittedand 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:
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, notSellerKycRecord.Statusalone. 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 bySellersController'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 gateApprove().Seller.KycStatus(NotStarted/Submitted/Verified) - the legacy onboarding-time identity checkSeller.Approve()itself requires (Verified) before allowingStatus -> Approved. Checked explicitly here too (not merely relied upon transitively throughStatus == Approved) so the rule stays correct even against a hypothetical future code path that could setStatuswithout going throughApprove().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 ApprovedSellerKycRecordnever overrides a Suspended/RejectedSeller.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.
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. CheckingProductCompliances.Status = 'Approved'(rather than re-deriving every individual declaration-completeness rule in SQL) is deliberate and sound:ProductCompliance.Update()unconditionally resetsStatusaway fromApprovedthe instant any declared field changes, andApprove()itself re-validates every declaration before allowing the transition - soStatus == Approvedis 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 ofProductCompliancebefore a listing's first activation, is the one fact that can still drift on its own - hence the explicitPrice <= MaximumRetailPricepredicate 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):
- Suspends every
ActiveSellerProductthis seller owns (UPDATE ... WHERE Status = 'Active' AND SellerId IN (SELECT Id FROM Sellers WHERE OwnerUserId = @sellerOwnerUserId)). - Revokes every
ActiveCheckoutEligibilityFenceline 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 == Activeimplies its owning seller currently holdsSeller.Status == Approved,Seller.KycStatus == Verified, and ApprovedSellerKycRecord, and its underlyingProductcurrently has Approved, price-consistentProductCompliance.
Three halves keep this true:
- Into Active: only
TryActivateSellerProductAsync's atomic conditional UPDATE (above), which checks every axis together. - Out of Active, seller axis (KYC record invalidation OR Seller.Status/KycStatus leaving
eligible):
ISellerOfferSuspensionService .SuspendActiveOffersForIneligibleSellerAsync- a single conditionalUPDATE SellerProducts SET Status = 'Suspended' WHERE Status = 'Active' AND SellerId IN (SELECT Id FROM Sellers WHERE OwnerUserId = @sellerOwnerUserId). Called wheneverSellerKyctransitions AWAY fromApproved:UpsertSellerKycCommandHandler(edit invalidated it) andReviewSellerKycCommandHandler(onReject()). - Out of Active, compliance axis:
ISellerOfferSuspensionService .SuspendActiveOffersForIneligibleProductAsync- the identical pattern, scoped byProductIdinstead ofSellerId(a shared Product's compliance leaving Approved affects EVERY seller currently offering it, not just whichever seller happened to trigger the edit). Called fromUpsertProductComplianceCommandHandler(edit invalidated it) andReviewProductComplianceCommandHandler's reject branch (defense-in-depth, unconditional on every rejection - mirroring the KYC side's own pattern, even thoughProductCompliance.Reject()cannot currently be called directly fromApproved, the same waySellerKyc.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
ApprovedSellerKycorProductCompliance. - 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_SellerProductReconciliationstable 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 viaTryActivateSellerProductAsync. - Rollback Runbook:
- Execute
dotnet ef database update 20260907182159_AddProductStatutoryCompliance. Down()restores unmodified grandfathered listings toActivestate; modified/reactivated listings remainSuspended.- Drop of
_Migration_SellerProductReconciliations,CheckoutEligibilityFenceLines,CheckoutEligibilityFences,SellerKycAuditRecords, andSellerKycRecordscompletes 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/KycStatusfields 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. SellerKycAuditRecordimmutability is an application-level guarantee (the entity has noUpdatemethod, 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
SellerKycAuditRecordstoSellerKycRecords(only plain, indexedGuidcolumns) - deliberate, mirroringProductComplianceAuditRecord's (LEG-02) same choice, so the audit trail for a given seller survives even in the hypothetical case its liveSellerKycrow is later removed by a future data-retention process. The FK toUsersonSellerKycRecordsitself IS enforced (see "Migration").