feat(kyc): monthly cumulative volume caps per KYC level - #322
Conversation
Extends Micopay#314's gate middleware (assertKycTierSufficient) with a race-safe monthly cumulative volume check per user, so a user can't stay under the per-operation limit while moving arbitrary volume by splitting it into many small operations (the structuring/pitufeo pattern the monitoring layer is meant to detect — this prevents most of it before detection is needed). - New user_monthly_volume table (user_id, month_key 'YYYY-MM' UTC, amount_mxn) tracks a running per-user-per-calendar-month total. - Race-safety: the read -> decide -> write critical section runs inside a new per-user in-process mutex (lib/keyedMutex.ts), so two concurrent operations for the same user are fully serialized and cannot jointly exceed the cap. Documented limitation: this only serializes within one running process — acceptable given this backend runs as a single instance today (same assumption db/schema.ts's in-memory fallback already makes). - Ceilings are config-driven per level (KYC_MONTHLY_VOLUME_CEILINGS_MXN_JSON, same override pattern as Micopay#314's KYC_OPERATION_THRESHOLDS_JSON), keyed by the user's actual current level, not the level a specific operation needs. - New KycMonthlyCapExceededError reports remaining monthly allowance and the UTC reset date, both as structured fields and in the user-facing message. - Honors the same config.kycGateEnabled audit-only-by-default toggle as Micopay#314's tier check — logs the decision, only throws once explicitly enabled. - Cap decisions go through Micopay#314's existing audit trail (logAuditEvent / platform_risk_events, action 'kyc_gate.decision') as their own event alongside the tier decision, distinguished by a new `check_type` field. getKycAuditTrail gained a `checkType` filter so callers can isolate one check's decisions; the pre-existing Micopay#314 test now passes checkType:'tier' to keep its original tier-only assertions correct (this test's three sequential calls now also produce their own monthly-volume audit events, which — left unfiltered — would have doubled its expected counts). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ericmt-98
left a comment
There was a problem hiding this comment.
Really solid work, @Kaizer4show — the withKeyedLock race-safety (with the honest "single-process only, cross-instance needs a DB guard" note), the "failed/over-cap op does not mutate recorded volume" invariant, recording the monthly-volume decision as its own check_type: 'monthly_volume' audit row on the same table (no parallel logging path), and reusing #314's exact config pattern with Level 0 = 0 aligned to the first-peso rule — all exactly right. Two things before merge:
1. Rebase onto current main (this PR is CONFLICTING)
#321 (Didit) merged after you branched, and there's a threshold-config fix on main (fbc8c7b). Your PR now conflicts on config.ts, kyc-gate.service.ts, kyc-gate.service.test.ts, and utils/errors.ts. Please rebase and re-run cd micopay/backend && npx tsc --noEmit && npm run test:kyc-gate && npm run test:kyc-monthly-volume (CI doesn't run on fork branches, so local verification is the gate).
2. Design: volume is recorded at gate-check time, but the gate runs at quote time
This is partly on the #314 wiring I wrote, not just your code — flagging it because your change is what makes it bite. recordMonthlyVolumeAndCheckCap increments user_monthly_volume inside assertKycTierSufficient, and on main that function is called:
- in
routes/ramp.tsat the/defi/ramp/quoteendpoint — so requesting an onramp quote (a read-only price check, before any order exists) would consume the user's monthly allowance. Ten $3,000 quotes = $30k counted, with zero transactions. - in
trade.service.tsat trade creation — before the HTLC locks/settles, so a cancelled or expired trade also consumes allowance.
Because the gate is audit-only right now (KYC_GATE_ENABLED=false), this isn't user-facing yet — but it inflates the tracked monthly totals, and once enforcement is turned on it would wrongly block users who only requested quotes or had trades fail.
The clean fix is to separate check from record: at quote/create, do a read-only check (does amount + current month total exceed the ceiling?) with no write; then record the volume only when the operation actually settles (trade completion / order confirmation). Your recordMonthlyVolumeAndCheckCap is already 90% of the way there — it mostly needs splitting into a read-only checkMonthlyVolume and a recordMonthlyVolume, with the record call moved to the settlement path.
Happy to pair on where the settlement hooks should go since that touches the #314 wiring I added. The core mechanism here is right — this is about when it fires, not how. Thanks!
# Conflicts: # micopay/backend/package.json
Summary
Extends #314's gate middleware (
assertKycTierSufficient) with a monthly cumulative volume cap per user, per the compliance plan's "límite por operación + acumulado mensual" (Fase 1). Without this, a user can stay under #314's per-operation limit while moving arbitrary volume by splitting it into many small operations — the exact structuring pattern (pitufeo) the monitoring/reporting layer is meant to detect. This prevents most of it before detection is needed, per the issue.user_monthly_volumetable:(user_id, month_key 'YYYY-MM' UTC, amount_mxn), one row per user per calendar month.lib/keyedMutex.ts), so two concurrent operations for the same user are fully serialized — the second call always observes the first's write before deciding, so they cannot jointly exceed the cap. Verified directly with a concurrency test (Promise.allSettledon two operations that individually fit but jointly exceed the cap — exactly one succeeds, final stored total reflects exactly the one that went through).UPDATE ... WHERE). Acceptable here because this backend runs as a single instance today, same assumptiondb/schema.ts's in-memory fallback already makes.KYC_MONTHLY_VOLUME_CEILINGS_MXN_JSON(same override pattern as [4a] Tiered KYC Gate Engine + Operation-Level Audit Trail #314'sKYC_OPERATION_THRESHOLDS_JSON), keyed by the user's actual current level — a Level 2 user's monthly allowance is Level 2's ceiling regardless of what any single operation needs.KycMonthlyCapExceededError— reports remaining monthly allowance and the UTC reset date as structured fields (remainingMxn,resetAt) and in the Spanish user-facing message.config.kycGateEnabledexactly like [4a] Tiered KYC Gate Engine + Operation-Level Audit Trail #314's tier check: audit-only by default (never blocks), only enforces once explicitly turned on.logAuditEvent/platform_risk_events, actionkyc_gate.decision) — no parallel logging path, as the issue asks. Recorded as their own event alongside the tier decision (rather than merged into one event) so each event'sgate_decisionfield stays unambiguous per check.A note on the audit trail shape (worth flagging for review)
Because cap decisions are now a second event per gated operation,
getKycAuditTrailgained acheckType: 'tier' | 'monthly_volume'filter so callers can isolate one check's decisions. I had to update the pre-existing, already-mergedkyc-gate.service.test.ts(testAuditTrailWritesAndQueries) to passcheckType: 'tier'in its queries — its three sequential calls for one seeded user now also produce their own monthly-volume audit events, which would have doubled its expected counts. This is the one file in this PR that isn't new #316 code; I've explained the change inline in that test and would call this out for extra scrutiny in review since it touches #314's shipped test.Test plan
npx tsc --noEmit— cleannpm run test:kyc-monthly-volume(new, 7 cases) — config override, under cap, exactly at cap (inclusive boundary), over cap (blocks + does not mutate + reports remaining/reset), month rollover (previous month's volume doesn't carry over), concurrent operations cannot jointly exceed the cap, and the full check enforced through the realassertKycTierSufficiententry pointnpm run test:kyc-gate— still passes after thecheckTypefix (was broken by the new audit events before the fix, see note above)npm run test:abuse,npm run test:security,npm run test:rate-limit— unaffected, still passing (broader regression check since this touches sharedconfig.ts/errors.ts)Closes #316