diff --git a/convex/engine/effects/collectionAttempt.ts b/convex/engine/effects/collectionAttempt.ts index 7b41b688..c5b58532 100644 --- a/convex/engine/effects/collectionAttempt.ts +++ b/convex/engine/effects/collectionAttempt.ts @@ -1,5 +1,6 @@ +import { WorkflowManager } from "@convex-dev/workflow"; import { v } from "convex/values"; -import { internal } from "../../_generated/api"; +import { components, internal } from "../../_generated/api"; import type { Id } from "../../_generated/dataModel"; import type { MutationCtx } from "../../_generated/server"; import { internalMutation } from "../../_generated/server"; @@ -13,6 +14,8 @@ import { executeTransition } from "../transition"; import type { CommandSource } from "../types"; import { effectPayloadValidator } from "../validators"; +const workflow = new WorkflowManager(components.workflow); + const collectionAttemptEffectValidator = { ...effectPayloadValidator, entityId: v.id("collectionAttempts"), @@ -202,50 +205,38 @@ export const notifyAdmin = internalMutation({ }); /** - * Cross-entity effect: triggers cash ledger reversal cascade. - * Triggered when a collection attempt transitions to `reversed` via PAYMENT_REVERSED. - * - * Iterates all obligationIds in the plan entry, delegating reversal of each - * obligation's ledger entries to postPaymentReversalCascade(). Unlike - * emitPaymentReceived (which tracks partial amounts and breaks early), this - * unconditionally reverses every obligation — partial reversal would leave - * the cash ledger inconsistent. + * Mutation step: executes the per-obligation reversal cascade within a + * durable workflow. Each obligation's reversal is idempotent via + * postingGroupId, so retries are safe. * - * Each call is idempotent via posting-group deduplication in the cash ledger. - * Return value (including clawbackRequired) is currently discarded — - * payout clawback handling is deferred to ENG-175+. + * Called by `reversalCascadeWorkflow` — not directly by the scheduler. */ -export const emitPaymentReversed = internalMutation({ - args: collectionAttemptEffectValidator, +export const executeReversalCascadeStep = internalMutation({ + args: { + entityId: v.id("collectionAttempts"), + source: effectPayloadValidator.source, + reason: v.string(), + effectiveDate: v.string(), + }, handler: async (ctx, args) => { - const { planEntry } = await loadAttemptAndPlanEntry( - ctx, - args, - "emitPaymentReversed" - ); - - let reason: string; - if (typeof args.payload?.reason === "string") { - reason = args.payload.reason; - } else { - reason = "payment_reversed"; - console.warn( - `[emitPaymentReversed] No valid reason in payload for attempt=${args.entityId}. Defaulting to "${reason}".` + const attempt = await ctx.db.get(args.entityId); + if (!attempt) { + throw new Error( + `[executeReversalCascadeStep] Collection attempt not found: ${args.entityId}` + ); + } + const planEntry = await ctx.db.get(attempt.planEntryId); + if (!planEntry) { + throw new Error( + `[executeReversalCascadeStep] Plan entry not found: ${attempt.planEntryId} (attempt=${args.entityId})` ); } - - // Prefer effectiveDate from event payload (set at event-receive time); - // fall back to current date if not provided. - const effectiveDate = - typeof args.payload?.effectiveDate === "string" - ? args.payload.effectiveDate - : new Date().toISOString().slice(0, 10); for (const obligationId of planEntry.obligationIds) { const obligation = await ctx.db.get(obligationId); if (!obligation) { throw new Error( - `[emitPaymentReversed] Obligation not found: ${obligationId} ` + + `[executeReversalCascadeStep] Obligation not found: ${obligationId} ` + `(attempt=${args.entityId}). Cannot complete reversal — ` + "the cash ledger would be left in an inconsistent state." ); @@ -257,20 +248,20 @@ export const emitPaymentReversed = internalMutation({ const shouldCreateCorrective = obligation.status === "settled"; console.info( - `[emitPaymentReversed] Starting reversal cascade for attempt=${args.entityId}, obligation=${obligationId}` + `[executeReversalCascadeStep] Starting reversal cascade for attempt=${args.entityId}, obligation=${obligationId}` ); const cascadeResult = await postPaymentReversalCascade(ctx, { attemptId: args.entityId, obligationId, mortgageId: obligation.mortgageId, - effectiveDate, + effectiveDate: args.effectiveDate, source: args.source, - reason, + reason: args.reason, }); console.info( - `[emitPaymentReversed] Reversal cascade complete for attempt=${args.entityId}, obligation=${obligationId}` + `[executeReversalCascadeStep] Reversal cascade complete for attempt=${args.entityId}, obligation=${obligationId}` ); // Schedule corrective obligation creation (ENG-180) @@ -289,7 +280,7 @@ export const emitPaymentReversed = internalMutation({ ); if (!cashReceivedReversal) { throw new Error( - "[emitPaymentReversed] No CASH_RECEIVED reversal entry found in cascade result " + + "[executeReversalCascadeStep] No CASH_RECEIVED reversal entry found in cascade result " + `for attempt=${args.entityId}, obligation=${obligationId}. ` + "Cannot determine reversedAmount for corrective obligation." ); @@ -303,16 +294,105 @@ export const emitPaymentReversed = internalMutation({ { originalObligationId: obligationId, reversedAmount, - reason, + reason: args.reason, postingGroupId: cascadeResult.postingGroupId, source: args.source, } ); console.info( - `[emitPaymentReversed] Scheduled corrective obligation for attempt=${args.entityId}, obligation=${obligationId}` + `[executeReversalCascadeStep] Scheduled corrective obligation for attempt=${args.entityId}, obligation=${obligationId}` ); } } }, }); + +/** + * Durable workflow for payment reversal cascade. + * + * Wraps executeReversalCascadeStep with automatic retries via the workflow + * component. The cascade is idempotent via postingGroupId, so retries are + * safe and will not create duplicate ledger entries. + * + * Follows the same pattern as hashChainJournalEntry in engine/hashChain.ts. + */ +export const reversalCascadeWorkflow = workflow.define({ + args: { + entityId: v.id("collectionAttempts"), + source: effectPayloadValidator.source, + reason: v.string(), + effectiveDate: v.string(), + }, + handler: async (step, args) => { + await step.runMutation( + internal.engine.effects.collectionAttempt.executeReversalCascadeStep, + { + entityId: args.entityId, + source: args.source, + reason: args.reason, + effectiveDate: args.effectiveDate, + } + ); + }, +}); + +/** + * Cross-entity effect: triggers cash ledger reversal cascade via durable workflow. + * Triggered when a collection attempt transitions to `reversed` via PAYMENT_REVERSED. + * + * Starts a durable workflow (with automatic retries) that iterates all + * obligationIds in the plan entry, delegating reversal of each obligation's + * ledger entries to postPaymentReversalCascade(). Unlike emitPaymentReceived + * (which tracks partial amounts and breaks early), the workflow unconditionally + * reverses every obligation — partial reversal would leave the cash ledger + * inconsistent. + * + * Each call is idempotent via posting-group deduplication in the cash ledger, + * so workflow retries are safe and will not create duplicate entries. + * + * Return value (including clawbackRequired) is currently discarded — + * payout clawback handling is deferred to ENG-175+. + */ +export const emitPaymentReversed = internalMutation({ + args: collectionAttemptEffectValidator, + handler: async (ctx, args) => { + let reason: string; + if (typeof args.payload?.reason === "string") { + reason = args.payload.reason; + } else { + reason = "payment_reversed"; + console.warn( + `[emitPaymentReversed] No valid reason in payload for attempt=${args.entityId}. Defaulting to "${reason}".` + ); + } + + // Prefer effectiveDate from event payload (set at event-receive time); + // fall back to current date if not provided. + const effectiveDate = + typeof args.payload?.effectiveDate === "string" + ? args.payload.effectiveDate + : new Date().toISOString().slice(0, 10); + + // Start durable workflow — the workflow component handles retries + // automatically if the reversal cascade step fails. The cascade is + // idempotent via postingGroupId so retries are safe. + await workflow.start( + ctx, + internal.engine.effects.collectionAttempt.reversalCascadeWorkflow, + { + entityId: args.entityId, + source: args.source, + reason, + effectiveDate, + }, + { + startAsync: true, + } + ); + + console.info( + `[emitPaymentReversed] Started durable reversal cascade workflow for attempt=${args.entityId}` + ); + }, +}); diff --git a/convex/http.ts b/convex/http.ts index 55545b5d..9dd49f3e 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -1,8 +1,21 @@ import { httpRouter } from "convex/server"; import { authKit } from "./auth"; +import { rotessaWebhook } from "./payments/webhooks/rotessa"; +import { stripeWebhook } from "./payments/webhooks/stripe"; const http = httpRouter(); authKit.registerRoutes(http); +http.route({ + path: "/webhooks/rotessa", + method: "POST", + handler: rotessaWebhook, +}); +http.route({ + path: "/webhooks/stripe", + method: "POST", + handler: stripeWebhook, +}); + export default http; diff --git a/convex/payments/webhooks/__tests__/handleReversal.test.ts b/convex/payments/webhooks/__tests__/handleReversal.test.ts new file mode 100644 index 00000000..b0b9373a --- /dev/null +++ b/convex/payments/webhooks/__tests__/handleReversal.test.ts @@ -0,0 +1,120 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { verifyRotessaSignature, verifyStripeSignature } from "../verification"; + +// ── Test helpers ───────────────────────────────────────────────────── + +function createTestRotessaSignature(body: string, secret: string): string { + return createHmac("sha256", secret).update(body).digest("hex"); +} + +function createTestStripeSignature( + body: string, + secret: string, + timestamp?: number +): string { + const ts = timestamp ?? Math.floor(Date.now() / 1000); + return `t=${ts},v1=${createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex")}`; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +describe("verifyRotessaSignature", () => { + const SECRET = "test-rotessa-secret-key"; + const BODY = JSON.stringify({ + event_type: "transaction.nsf", + data: { transaction_id: "txn_001", amount: 150.0 }, + }); + + it("returns true for valid HMAC-SHA256 signature", () => { + const signature = createTestRotessaSignature(BODY, SECRET); + expect(verifyRotessaSignature(BODY, signature, SECRET)).toBe(true); + }); + + it("returns false for invalid signature", () => { + const wrongSignature = createTestRotessaSignature(BODY, "wrong-secret"); + expect(verifyRotessaSignature(BODY, wrongSignature, SECRET)).toBe(false); + }); + + it("returns false for empty signature", () => { + // An empty string produces a zero-length buffer, which won't match the + // 32-byte expected buffer. The length check causes an early return false. + expect(verifyRotessaSignature(BODY, "", SECRET)).toBe(false); + }); + + it("returns false when body has been tampered with", () => { + const signature = createTestRotessaSignature(BODY, SECRET); + const tamperedBody = `${BODY} extra`; + expect(verifyRotessaSignature(tamperedBody, signature, SECRET)).toBe(false); + }); + + it("returns true for different valid body+signature pairs", () => { + const body2 = '{"event_type":"transaction.returned"}'; + const sig2 = createTestRotessaSignature(body2, SECRET); + expect(verifyRotessaSignature(body2, sig2, SECRET)).toBe(true); + }); +}); + +describe("verifyStripeSignature", () => { + const SECRET = "whsec_test_stripe_secret"; + const BODY = JSON.stringify({ + id: "evt_123", + type: "charge.refunded", + data: { object: { id: "ch_abc", amount: 5000 } }, + }); + + it("returns true for valid stripe-signature header", () => { + const header = createTestStripeSignature(BODY, SECRET); + expect(verifyStripeSignature(BODY, header, SECRET)).toBe(true); + }); + + it("returns false for invalid signature", () => { + const header = createTestStripeSignature(BODY, "wrong-secret"); + expect(verifyStripeSignature(BODY, header, SECRET)).toBe(false); + }); + + it("returns false for expired timestamp beyond tolerance", () => { + // Timestamp from 10 minutes ago, with 5 min tolerance + const staleTimestamp = Math.floor(Date.now() / 1000) - 600; + const header = createTestStripeSignature(BODY, SECRET, staleTimestamp); + expect(verifyStripeSignature(BODY, header, SECRET, 300)).toBe(false); + }); + + it("handles missing v1= prefix gracefully", () => { + const ts = Math.floor(Date.now() / 1000); + // Header with no v1= component + const header = `t=${ts}`; + expect(verifyStripeSignature(BODY, header, SECRET)).toBe(false); + }); + + it("returns false for missing timestamp", () => { + const sig = createHmac("sha256", SECRET) + .update(`12345.${BODY}`) + .digest("hex"); + // Header with no t= component + const header = `v1=${sig}`; + expect(verifyStripeSignature(BODY, header, SECRET)).toBe(false); + }); + + it("returns false for completely malformed header", () => { + expect(verifyStripeSignature(BODY, "garbage-header", SECRET)).toBe(false); + }); + + it("accepts timestamp within tolerance", () => { + // Timestamp from 2 minutes ago, tolerance 5 minutes + const recentTimestamp = Math.floor(Date.now() / 1000) - 120; + const header = createTestStripeSignature(BODY, SECRET, recentTimestamp); + expect(verifyStripeSignature(BODY, header, SECRET, 300)).toBe(true); + }); + + it("returns false for future timestamp beyond tolerance", () => { + // Timestamp 10 minutes in the future + const futureTimestamp = Math.floor(Date.now() / 1000) + 600; + const header = createTestStripeSignature(BODY, SECRET, futureTimestamp); + expect(verifyStripeSignature(BODY, header, SECRET, 300)).toBe(false); + }); + + it("returns false for empty header string", () => { + expect(verifyStripeSignature(BODY, "", SECRET)).toBe(false); + }); +}); diff --git a/convex/payments/webhooks/__tests__/reversalIntegration.test.ts b/convex/payments/webhooks/__tests__/reversalIntegration.test.ts new file mode 100644 index 00000000..afc8ad4d --- /dev/null +++ b/convex/payments/webhooks/__tests__/reversalIntegration.test.ts @@ -0,0 +1,697 @@ +import auditLogTest from "convex-audit-log/test"; +import { describe, expect, it } from "vitest"; +import { internal } from "../../../_generated/api"; +import type { Id } from "../../../_generated/dataModel"; +import { + createHarness, + SYSTEM_SOURCE, + seedMinimalEntities, + type TestHarness, +} from "../../cashLedger/__tests__/testUtils"; +import { + findCashAccount, + getCashAccountBalance, +} from "../../cashLedger/accounts"; +import { + postCashReceiptForObligation, + postObligationAccrued, + postSettlementAllocation, +} from "../../cashLedger/integrations"; + +const modules = import.meta.glob("/convex/**/*.ts"); + +// ── Amount constants ──────────────────────────────────────────────── +const TOTAL_AMOUNT = 100_000; +const LENDER_A_AMOUNT = 54_000; +const LENDER_B_AMOUNT = 36_000; +const SERVICING_FEE_AMOUNT = 10_000; + +// ── Pipeline state ────────────────────────────────────────────────── + +interface PipelineState { + attemptId: Id<"collectionAttempts">; + borrowerId: Id<"borrowers">; + dispersalEntryAId: Id<"dispersalEntries">; + dispersalEntryBId: Id<"dispersalEntries">; + lenderAId: Id<"lenders">; + lenderBId: Id<"lenders">; + mortgageId: Id<"mortgages">; + obligationId: Id<"obligations">; + planEntryId: Id<"collectionPlanEntries">; +} + +// ── Seed helper: full settlement pipeline through confirmed ───────── +// Seeds entities, creates a confirmed collectionAttempt with providerRef, +// posts accrual + cash receipt + allocation entries. + +async function seedConfirmedAttemptPipeline( + t: TestHarness +): Promise { + const { borrowerId, lenderAId, lenderBId, mortgageId } = + await seedMinimalEntities(t); + + // Create obligation (settled) + const obligationId = await t.run(async (ctx) => { + return ctx.db.insert("obligations", { + status: "settled", + machineContext: {}, + lastTransitionAt: Date.now(), + mortgageId, + borrowerId, + paymentNumber: 1, + type: "regular_interest", + amount: TOTAL_AMOUNT, + amountSettled: TOTAL_AMOUNT, + dueDate: Date.parse("2026-03-01T00:00:00Z"), + gracePeriodEnd: Date.parse("2026-03-16T00:00:00Z"), + settledAt: Date.parse("2026-03-01T00:00:00Z"), + createdAt: Date.now(), + }); + }); + + // Create collectionPlanEntry + confirmed collectionAttempt with providerRef + const { attemptId, planEntryId } = await t.run(async (ctx) => { + const planEntryId = await ctx.db.insert("collectionPlanEntries", { + obligationIds: [obligationId], + amount: TOTAL_AMOUNT, + method: "manual", + scheduledDate: Date.now(), + status: "completed", + source: "default_schedule", + createdAt: Date.now(), + }); + + const attemptId = await ctx.db.insert("collectionAttempts", { + status: "confirmed", + machineContext: { attemptId: "", retryCount: 0, maxRetries: 3 }, + lastTransitionAt: Date.now(), + planEntryId, + method: "manual", + amount: TOTAL_AMOUNT, + providerRef: "txn_test_reversal_001", + initiatedAt: Date.now() - 120_000, + settledAt: Date.now() - 60_000, + }); + + return { attemptId, planEntryId }; + }); + + // Create dispersalEntry records (one per lender) + const { dispersalEntryAId, dispersalEntryBId } = await t.run(async (ctx) => { + const ledgerAccounts = await ctx.db + .query("ledger_accounts") + .filter((q) => q.eq(q.field("mortgageId"), mortgageId)) + .collect(); + const lenderAccountA = ledgerAccounts[0]; + const lenderAccountB = ledgerAccounts[1]; + + const dispersalEntryAId = await ctx.db.insert("dispersalEntries", { + mortgageId, + lenderId: lenderAId, + lenderAccountId: lenderAccountA._id, + amount: LENDER_A_AMOUNT, + dispersalDate: "2026-03-01", + obligationId, + servicingFeeDeducted: 0, + status: "pending", + idempotencyKey: `dispersal-a-${obligationId}`, + calculationDetails: { + settledAmount: TOTAL_AMOUNT, + servicingFee: SERVICING_FEE_AMOUNT, + distributableAmount: TOTAL_AMOUNT - SERVICING_FEE_AMOUNT, + ownershipUnits: 6000, + totalUnits: 10_000, + ownershipFraction: 0.6, + rawAmount: LENDER_A_AMOUNT, + roundedAmount: LENDER_A_AMOUNT, + }, + createdAt: Date.now(), + }); + + const dispersalEntryBId = await ctx.db.insert("dispersalEntries", { + mortgageId, + lenderId: lenderBId, + lenderAccountId: lenderAccountB._id, + amount: LENDER_B_AMOUNT, + dispersalDate: "2026-03-01", + obligationId, + servicingFeeDeducted: 0, + status: "pending", + idempotencyKey: `dispersal-b-${obligationId}`, + calculationDetails: { + settledAmount: TOTAL_AMOUNT, + servicingFee: SERVICING_FEE_AMOUNT, + distributableAmount: TOTAL_AMOUNT - SERVICING_FEE_AMOUNT, + ownershipUnits: 4000, + totalUnits: 10_000, + ownershipFraction: 0.4, + rawAmount: LENDER_B_AMOUNT, + roundedAmount: LENDER_B_AMOUNT, + }, + createdAt: Date.now(), + }); + + return { dispersalEntryAId, dispersalEntryBId }; + }); + + // Post accrual entries + await t.run(async (ctx) => { + return postObligationAccrued(ctx, { + obligationId, + source: SYSTEM_SOURCE, + }); + }); + + // Post cash receipt + await t.run(async (ctx) => { + return postCashReceiptForObligation(ctx, { + obligationId, + amount: TOTAL_AMOUNT, + idempotencyKey: `cash-ledger:cash-receipt-reversal-int-${attemptId}`, + attemptId, + source: SYSTEM_SOURCE, + }); + }); + + // Post settlement allocation + await t.run(async (ctx) => { + return postSettlementAllocation(ctx, { + obligationId, + mortgageId, + settledDate: "2026-03-01", + servicingFee: SERVICING_FEE_AMOUNT, + entries: [ + { + dispersalEntryId: dispersalEntryAId, + lenderId: lenderAId, + amount: LENDER_A_AMOUNT, + }, + { + dispersalEntryId: dispersalEntryBId, + lenderId: lenderBId, + amount: LENDER_B_AMOUNT, + }, + ], + source: SYSTEM_SOURCE, + }); + }); + + return { + borrowerId, + lenderAId, + lenderBId, + mortgageId, + obligationId, + attemptId, + planEntryId, + dispersalEntryAId, + dispersalEntryBId, + }; +} + +// ═══════════════════════════════════════════════════════════════════ +// Reversal webhook → GT transition integration tests +// ═══════════════════════════════════════════════════════════════════ + +describe("Reversal webhook integration: GT transition (confirmed → reversed)", () => { + // ── T-101: Confirmed attempt transitions to reversed ───────── + it("T-101: processReversalCascade transitions confirmed attempt to reversed", async () => { + const t = createHarness(modules); + auditLogTest.register(t, "auditLog"); + const state = await seedConfirmedAttemptPipeline(t); + + // Verify attempt is in confirmed state before reversal + const beforeAttempt = await t.run(async (ctx) => { + return ctx.db.get(state.attemptId); + }); + expect(beforeAttempt?.status).toBe("confirmed"); + + // Fire the GT transition via processReversalCascade (same as handlePaymentReversal calls) + const result = await t.mutation( + internal.payments.webhooks.processReversal.processReversalCascade, + { + attemptId: state.attemptId, + effectiveDate: "2026-03-10", + reason: "NSF — integration test", + provider: "rotessa" as const, + providerEventId: "evt_nsf_001", + } + ); + + expect(result.success).toBe(true); + expect(result.newState).toBe("reversed"); + + // Verify the entity was persisted with reversed status + const afterAttempt = await t.run(async (ctx) => { + return ctx.db.get(state.attemptId); + }); + expect(afterAttempt?.status).toBe("reversed"); + }); + + // ── T-102: getAttemptByProviderRef look-up ─────────────────── + it("T-102: getAttemptByProviderRef returns the seeded attempt", async () => { + const t = createHarness(modules); + auditLogTest.register(t, "auditLog"); + const state = await seedConfirmedAttemptPipeline(t); + + const found = await t.query( + internal.payments.webhooks.handleReversal.getAttemptByProviderRef, + { providerRef: "txn_test_reversal_001" } + ); + + expect(found).not.toBeNull(); + expect(found?._id).toBe(state.attemptId); + expect(found?.status).toBe("confirmed"); + }); + + it("T-102b: getAttemptByProviderRef returns null for unknown ref", async () => { + const t = createHarness(modules); + auditLogTest.register(t, "auditLog"); + await seedConfirmedAttemptPipeline(t); + + const found = await t.query( + internal.payments.webhooks.handleReversal.getAttemptByProviderRef, + { providerRef: "txn_unknown_ref" } + ); + + expect(found).toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Duplicate / idempotent webhook handling +// ═══════════════════════════════════════════════════════════════════ + +describe("Reversal webhook integration: duplicate/idempotent handling", () => { + // ── T-103: Second reversal on already-reversed attempt ────── + it("T-103: second processReversalCascade on reversed attempt is rejected (GT rejects PAYMENT_REVERSED in reversed state)", async () => { + const t = createHarness(modules); + auditLogTest.register(t, "auditLog"); + const state = await seedConfirmedAttemptPipeline(t); + + // First reversal succeeds + const first = await t.mutation( + internal.payments.webhooks.processReversal.processReversalCascade, + { + attemptId: state.attemptId, + effectiveDate: "2026-03-10", + reason: "NSF — first call", + provider: "rotessa" as const, + providerEventId: "evt_nsf_dup_001", + } + ); + expect(first.success).toBe(true); + expect(first.newState).toBe("reversed"); + + // Second reversal — the GT engine rejects PAYMENT_REVERSED in "reversed" (final state) + const second = await t.mutation( + internal.payments.webhooks.processReversal.processReversalCascade, + { + attemptId: state.attemptId, + effectiveDate: "2026-03-10", + reason: "NSF — duplicate call", + provider: "rotessa" as const, + providerEventId: "evt_nsf_dup_002", + } + ); + expect(second.success).toBe(false); + }); + + // ── T-104: handlePaymentReversal logic — already_reversed shortcut ── + // Tests the state-check logic that handlePaymentReversal performs: + // if attempt.status === "reversed", it returns { success: true, reason: "already_reversed" } + // We simulate this by checking the attempt status after first reversal. + it("T-104: after reversal, attempt status is 'reversed' enabling idempotent return path", async () => { + const t = createHarness(modules); + auditLogTest.register(t, "auditLog"); + const state = await seedConfirmedAttemptPipeline(t); + + await t.mutation( + internal.payments.webhooks.processReversal.processReversalCascade, + { + attemptId: state.attemptId, + effectiveDate: "2026-03-10", + reason: "NSF — idempotency path", + provider: "stripe" as const, + providerEventId: "evt_stripe_idem_001", + } + ); + + // The handler checks attempt.status === "reversed" and returns early + const attempt = await t.run(async (ctx) => { + return ctx.db.get(state.attemptId); + }); + expect(attempt?.status).toBe("reversed"); + // handlePaymentReversal would return { success: true, reason: "already_reversed" } + // for this attempt on a subsequent call — verified via the status check. + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Out-of-order webhook handling +// ═══════════════════════════════════════════════════════════════════ + +describe("Reversal webhook integration: out-of-order rejection", () => { + // ── T-105: Reversal on initiated (pre-confirmed) attempt ──── + it("T-105: PAYMENT_REVERSED rejected on 'initiated' attempt (not yet confirmed)", async () => { + const t = createHarness(modules); + auditLogTest.register(t, "auditLog"); + + // Seed minimal entities but create an attempt in "initiated" state (no providerRef) + const { borrowerId, mortgageId } = await seedMinimalEntities(t); + + const obligationId = await t.run(async (ctx) => { + return ctx.db.insert("obligations", { + status: "due", + machineContext: {}, + lastTransitionAt: Date.now(), + mortgageId, + borrowerId, + paymentNumber: 1, + type: "regular_interest", + amount: TOTAL_AMOUNT, + amountSettled: 0, + dueDate: Date.parse("2026-04-01T00:00:00Z"), + gracePeriodEnd: Date.parse("2026-04-16T00:00:00Z"), + createdAt: Date.now(), + }); + }); + + const attemptId = await t.run(async (ctx) => { + const planEntryId = await ctx.db.insert("collectionPlanEntries", { + obligationIds: [obligationId], + amount: TOTAL_AMOUNT, + method: "manual", + scheduledDate: Date.now(), + status: "planned", + source: "default_schedule", + createdAt: Date.now(), + }); + + return ctx.db.insert("collectionAttempts", { + status: "initiated", + machineContext: { attemptId: "", retryCount: 0, maxRetries: 3 }, + lastTransitionAt: Date.now(), + planEntryId, + method: "manual", + amount: TOTAL_AMOUNT, + initiatedAt: Date.now(), + }); + }); + + // PAYMENT_REVERSED should be rejected — the machine only accepts it in "confirmed" + const result = await t.mutation( + internal.payments.webhooks.processReversal.processReversalCascade, + { + attemptId, + effectiveDate: "2026-03-10", + reason: "NSF — out-of-order test", + provider: "rotessa" as const, + providerEventId: "evt_nsf_ooo_001", + } + ); + + expect(result.success).toBe(false); + + // Attempt status should remain "initiated" + const attempt = await t.run(async (ctx) => { + return ctx.db.get(attemptId); + }); + expect(attempt?.status).toBe("initiated"); + }); + + // ── T-106: Reversal on pending attempt ────────────────────── + it("T-106: PAYMENT_REVERSED rejected on 'pending' attempt", async () => { + const t = createHarness(modules); + auditLogTest.register(t, "auditLog"); + + const { borrowerId, mortgageId } = await seedMinimalEntities(t); + + const obligationId = await t.run(async (ctx) => { + return ctx.db.insert("obligations", { + status: "due", + machineContext: {}, + lastTransitionAt: Date.now(), + mortgageId, + borrowerId, + paymentNumber: 1, + type: "regular_interest", + amount: TOTAL_AMOUNT, + amountSettled: 0, + dueDate: Date.parse("2026-04-01T00:00:00Z"), + gracePeriodEnd: Date.parse("2026-04-16T00:00:00Z"), + createdAt: Date.now(), + }); + }); + + const attemptId = await t.run(async (ctx) => { + const planEntryId = await ctx.db.insert("collectionPlanEntries", { + obligationIds: [obligationId], + amount: TOTAL_AMOUNT, + method: "manual", + scheduledDate: Date.now(), + status: "executing", + source: "default_schedule", + createdAt: Date.now(), + }); + + return ctx.db.insert("collectionAttempts", { + status: "pending", + machineContext: { attemptId: "", retryCount: 0, maxRetries: 3 }, + lastTransitionAt: Date.now(), + planEntryId, + method: "manual", + amount: TOTAL_AMOUNT, + providerRef: "txn_pending_001", + initiatedAt: Date.now(), + }); + }); + + // PAYMENT_REVERSED should be rejected — machine only accepts it in "confirmed" + const result = await t.mutation( + internal.payments.webhooks.processReversal.processReversalCascade, + { + attemptId, + effectiveDate: "2026-03-10", + reason: "NSF — pending out-of-order", + provider: "stripe" as const, + providerEventId: "evt_nsf_ooo_pending_001", + } + ); + + expect(result.success).toBe(false); + + // Attempt status should remain "pending" + const attempt = await t.run(async (ctx) => { + return ctx.db.get(attemptId); + }); + expect(attempt?.status).toBe("pending"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// emitPaymentReversed effect → journal entries +// +// These tests call executeReversalCascadeStep directly (the mutation +// that postPaymentReversalCascade calls per-obligation) to verify +// journal entry creation without requiring the durable workflow +// component. This is the actual business logic that processes +// reversal entries in the cash ledger. +// ═══════════════════════════════════════════════════════════════════ + +describe("Reversal webhook integration: emitPaymentReversed journal entries", () => { + // ── T-107: executeReversalCascadeStep creates REVERSAL journal entries ── + it("T-107: executeReversalCascadeStep posts reversal journal entries for each obligation", async () => { + const t = createHarness(modules); + auditLogTest.register(t, "auditLog"); + const state = await seedConfirmedAttemptPipeline(t); + + // Transition to reversed first (so the attempt is in the correct state) + await t.mutation( + internal.payments.webhooks.processReversal.processReversalCascade, + { + attemptId: state.attemptId, + effectiveDate: "2026-03-10", + reason: "NSF — journal entry test", + provider: "rotessa" as const, + providerEventId: "evt_journal_001", + } + ); + + // Call executeReversalCascadeStep directly — this is the mutation that + // the durable workflow invokes to process the reversal cascade. + await t.mutation( + internal.engine.effects.collectionAttempt.executeReversalCascadeStep, + { + entityId: state.attemptId, + source: SYSTEM_SOURCE, + reason: "NSF — journal entry test", + effectiveDate: "2026-03-10", + } + ); + + // Verify REVERSAL entries were created in the journal. + // All reversal entries share a postingGroupId of `reversal-group:${attemptId}`. + const expectedPostingGroupId = `reversal-group:${state.attemptId}`; + const reversalEntries = await t.run(async (ctx) => { + return ctx.db + .query("cash_ledger_journal_entries") + .withIndex("by_posting_group", (q) => + q.eq("postingGroupId", expectedPostingGroupId) + ) + .collect(); + }); + + // Should have at least 4 reversal entries: + // 1x CASH_RECEIVED reversal, 2x LENDER_PAYABLE_CREATED reversals, 1x SERVICING_FEE_RECOGNIZED reversal + expect(reversalEntries.length).toBeGreaterThanOrEqual(4); + + // All entries should be REVERSAL type + for (const entry of reversalEntries) { + expect(entry.entryType).toBe("REVERSAL"); + } + + // CASH_RECEIVED reversal should reference our attempt + const cashReceivedReversal = reversalEntries.find( + (e) => e.attemptId === state.attemptId + ); + expect(cashReceivedReversal).toBeDefined(); + + // Verify each has causedBy linking to original entry + for (const entry of reversalEntries) { + expect(entry.causedBy).toBeDefined(); + } + }); + + // ── T-108: Cash balances return to pre-receipt state after reversal ── + it("T-108: TRUST_CASH balance returns to zero after reversal cascade", async () => { + const t = createHarness(modules); + auditLogTest.register(t, "auditLog"); + const state = await seedConfirmedAttemptPipeline(t); + + // Verify TRUST_CASH has a non-zero balance before reversal + const trustCashBefore = await t.run(async (ctx) => { + const account = await findCashAccount(ctx.db, { + family: "TRUST_CASH", + mortgageId: state.mortgageId, + }); + return account ? getCashAccountBalance(account) : 0n; + }); + expect(trustCashBefore).not.toBe(0n); + + // Transition to reversed + await t.mutation( + internal.payments.webhooks.processReversal.processReversalCascade, + { + attemptId: state.attemptId, + effectiveDate: "2026-03-10", + reason: "NSF — balance test", + provider: "rotessa" as const, + providerEventId: "evt_balance_001", + } + ); + + // Execute the reversal cascade step directly + await t.mutation( + internal.engine.effects.collectionAttempt.executeReversalCascadeStep, + { + entityId: state.attemptId, + source: SYSTEM_SOURCE, + reason: "NSF — balance test", + effectiveDate: "2026-03-10", + } + ); + + // TRUST_CASH should be back to zero (received then reversed) + const trustCashAfter = await t.run(async (ctx) => { + const account = await findCashAccount(ctx.db, { + family: "TRUST_CASH", + mortgageId: state.mortgageId, + }); + return account ? getCashAccountBalance(account) : 0n; + }); + expect(trustCashAfter).toBe(0n); + + // BORROWER_RECEIVABLE should be back to full amount (accrual debited, receipt credited, reversal debited again) + const brBalance = await t.run(async (ctx) => { + const account = await findCashAccount(ctx.db, { + family: "BORROWER_RECEIVABLE", + mortgageId: state.mortgageId, + obligationId: state.obligationId, + }); + return account ? getCashAccountBalance(account) : 0n; + }); + expect(brBalance).toBe(BigInt(TOTAL_AMOUNT)); + + // LENDER_PAYABLE balances should be zero (created then reversed) + for (const lenderId of [state.lenderAId, state.lenderBId]) { + const lpBalance = await t.run(async (ctx) => { + const account = await findCashAccount(ctx.db, { + family: "LENDER_PAYABLE", + mortgageId: state.mortgageId, + lenderId, + }); + return account ? getCashAccountBalance(account) : 0n; + }); + expect(lpBalance).toBe(0n); + } + + // SERVICING_REVENUE should be zero (recognized then reversed) + const srBalance = await t.run(async (ctx) => { + const account = await findCashAccount(ctx.db, { + family: "SERVICING_REVENUE", + mortgageId: state.mortgageId, + }); + return account ? getCashAccountBalance(account) : 0n; + }); + expect(srBalance).toBe(0n); + }); + + // ── T-109: Reversal posting group contains all expected entries ── + it("T-109: reversal entries share a single postingGroupId", async () => { + const t = createHarness(modules); + auditLogTest.register(t, "auditLog"); + const state = await seedConfirmedAttemptPipeline(t); + + // Transition + execute cascade step + await t.mutation( + internal.payments.webhooks.processReversal.processReversalCascade, + { + attemptId: state.attemptId, + effectiveDate: "2026-03-10", + reason: "NSF — posting group test", + provider: "rotessa" as const, + providerEventId: "evt_pg_001", + } + ); + + await t.mutation( + internal.engine.effects.collectionAttempt.executeReversalCascadeStep, + { + entityId: state.attemptId, + source: SYSTEM_SOURCE, + reason: "NSF — posting group test", + effectiveDate: "2026-03-10", + } + ); + + // All reversal entries share postingGroupId = `reversal-group:${attemptId}` + const expectedPostingGroupId = `reversal-group:${state.attemptId}`; + const reversalEntries = await t.run(async (ctx) => { + return ctx.db + .query("cash_ledger_journal_entries") + .withIndex("by_posting_group", (q) => + q.eq("postingGroupId", expectedPostingGroupId) + ) + .collect(); + }); + + expect(reversalEntries.length).toBeGreaterThanOrEqual(4); + + // All entries should be REVERSAL type and belong to the same group + for (const entry of reversalEntries) { + expect(entry.entryType).toBe("REVERSAL"); + expect(entry.postingGroupId).toBe(expectedPostingGroupId); + } + }); +}); diff --git a/convex/payments/webhooks/__tests__/rotessaWebhook.test.ts b/convex/payments/webhooks/__tests__/rotessaWebhook.test.ts new file mode 100644 index 00000000..60879499 --- /dev/null +++ b/convex/payments/webhooks/__tests__/rotessaWebhook.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; +import type { RotessaWebhookEvent } from "../rotessa"; +import { + buildReversalCode, + buildReversalReason, + REVERSAL_EVENT_TYPES, + toPayload, +} from "../rotessa"; + +// ── Constants ──────────────────────────────────────────────────────── + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +// ── Helpers ────────────────────────────────────────────────────────── + +function makeEvent( + overrides: Partial & { event_type: string } +): RotessaWebhookEvent { + return { + data: { + amount: 150.0, + transaction_id: "txn_001", + ...(overrides.data ?? {}), + }, + event_type: overrides.event_type, + }; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +describe("Rotessa webhook handler", () => { + // ── Event filtering ────────────────────────────────────────────── + + describe("event type filtering", () => { + it("recognizes transaction.nsf as a reversal event", () => { + expect(REVERSAL_EVENT_TYPES.has("transaction.nsf")).toBe(true); + }); + + it("recognizes transaction.returned as a reversal event", () => { + expect(REVERSAL_EVENT_TYPES.has("transaction.returned")).toBe(true); + }); + + it("recognizes transaction.reversed as a reversal event", () => { + expect(REVERSAL_EVENT_TYPES.has("transaction.reversed")).toBe(true); + }); + + it("does not recognize transaction.created as a reversal event", () => { + expect(REVERSAL_EVENT_TYPES.has("transaction.created")).toBe(false); + }); + + it("does not recognize transaction.completed as a reversal event", () => { + expect(REVERSAL_EVENT_TYPES.has("transaction.completed")).toBe(false); + }); + + it("contains exactly 3 event types", () => { + expect(REVERSAL_EVENT_TYPES.size).toBe(3); + }); + }); + + // ── Payload mapping ────────────────────────────────────────────── + + describe("toPayload", () => { + it("converts dollar amounts to cents correctly", () => { + const event = makeEvent({ + event_type: "transaction.nsf", + data: { amount: 150.75, transaction_id: "txn_001" }, + }); + const payload = toPayload(event); + expect(payload.originalAmount).toBe(15_075); + }); + + it("handles whole dollar amounts", () => { + const event = makeEvent({ + event_type: "transaction.nsf", + data: { amount: 200, transaction_id: "txn_002" }, + }); + const payload = toPayload(event); + expect(payload.originalAmount).toBe(20_000); + }); + + it("extracts providerRef from transaction_id", () => { + const event = makeEvent({ + event_type: "transaction.nsf", + data: { amount: 100, transaction_id: "txn_abc_123" }, + }); + const payload = toPayload(event); + expect(payload.providerRef).toBe("txn_abc_123"); + }); + + it("uses event_id for providerEventId when present", () => { + const event = makeEvent({ + event_type: "transaction.nsf", + data: { + amount: 100, + transaction_id: "txn_001", + event_id: "evt_rotessa_456", + }, + }); + const payload = toPayload(event); + expect(payload.providerEventId).toBe("evt_rotessa_456"); + }); + + it("falls back to transaction_id for providerEventId when event_id missing", () => { + const event = makeEvent({ + event_type: "transaction.nsf", + data: { amount: 100, transaction_id: "txn_001" }, + }); + const payload = toPayload(event); + expect(payload.providerEventId).toBe("txn_001"); + }); + + it("sets provider to rotessa", () => { + const event = makeEvent({ event_type: "transaction.nsf" }); + const payload = toPayload(event); + expect(payload.provider).toBe("rotessa"); + }); + + it("uses data.date for reversalDate when present", () => { + const event = makeEvent({ + event_type: "transaction.nsf", + data: { + amount: 100, + transaction_id: "txn_001", + date: "2026-03-15", + }, + }); + const payload = toPayload(event); + expect(payload.reversalDate).toBe("2026-03-15"); + }); + + it("falls back to today when data.date is missing", () => { + const event = makeEvent({ + event_type: "transaction.nsf", + data: { amount: 100, transaction_id: "txn_001" }, + }); + const payload = toPayload(event); + // Should be a YYYY-MM-DD string for today + expect(payload.reversalDate).toMatch(DATE_PATTERN); + }); + }); + + // ── Reason mapping ─────────────────────────────────────────────── + + describe("buildReversalReason", () => { + it("maps NSF reason correctly", () => { + const event = makeEvent({ + event_type: "transaction.nsf", + data: { amount: 100, transaction_id: "txn_001" }, + }); + const reason = buildReversalReason(event); + expect(reason).toBe("NSF: Non-Sufficient Funds"); + }); + + it("maps NSF with custom reason", () => { + const event = makeEvent({ + event_type: "transaction.nsf", + data: { + amount: 100, + transaction_id: "txn_001", + reason: "Account frozen", + }, + }); + const reason = buildReversalReason(event); + expect(reason).toBe("NSF: Account frozen"); + }); + + it("maps PAD return with return_code correctly", () => { + const event = makeEvent({ + event_type: "transaction.returned", + data: { + amount: 100, + transaction_id: "txn_001", + return_code: "R01", + reason: "Insufficient funds", + }, + }); + const reason = buildReversalReason(event); + expect(reason).toBe("PAD Return: R01 — Insufficient funds"); + }); + + it("maps PAD return with unknown code when return_code missing", () => { + const event = makeEvent({ + event_type: "transaction.returned", + data: { amount: 100, transaction_id: "txn_001" }, + }); + const reason = buildReversalReason(event); + expect(reason).toBe("PAD Return: unknown — "); + }); + + it("maps manual reversal reason", () => { + const event = makeEvent({ + event_type: "transaction.reversed", + data: { + amount: 100, + transaction_id: "txn_001", + reason: "Duplicate payment", + }, + }); + const reason = buildReversalReason(event); + expect(reason).toBe("Manual Reversal: Duplicate payment"); + }); + + it("maps manual reversal with empty reason", () => { + const event = makeEvent({ + event_type: "transaction.reversed", + data: { amount: 100, transaction_id: "txn_001" }, + }); + const reason = buildReversalReason(event); + expect(reason).toBe("Manual Reversal: "); + }); + + it("maps unknown event type to fallback reason", () => { + const event = makeEvent({ + event_type: "transaction.unknown", + data: { + amount: 100, + transaction_id: "txn_001", + reason: "Something else", + }, + }); + const reason = buildReversalReason(event); + expect(reason).toBe("Something else"); + }); + }); + + // ── Reversal code mapping ──────────────────────────────────────── + + describe("buildReversalCode", () => { + it("returns NSF for transaction.nsf", () => { + const event = makeEvent({ event_type: "transaction.nsf" }); + expect(buildReversalCode(event)).toBe("NSF"); + }); + + it("returns return_code for transaction.returned", () => { + const event = makeEvent({ + event_type: "transaction.returned", + data: { + amount: 100, + transaction_id: "txn_001", + return_code: "R03", + }, + }); + expect(buildReversalCode(event)).toBe("R03"); + }); + + it("returns undefined return_code when missing for transaction.returned", () => { + const event = makeEvent({ + event_type: "transaction.returned", + data: { amount: 100, transaction_id: "txn_001" }, + }); + expect(buildReversalCode(event)).toBeUndefined(); + }); + + it("returns MANUAL_REVERSAL for transaction.reversed", () => { + const event = makeEvent({ event_type: "transaction.reversed" }); + expect(buildReversalCode(event)).toBe("MANUAL_REVERSAL"); + }); + + it("returns undefined for unknown event type", () => { + const event = makeEvent({ event_type: "transaction.created" }); + expect(buildReversalCode(event)).toBeUndefined(); + }); + }); +}); diff --git a/convex/payments/webhooks/__tests__/stripeWebhook.test.ts b/convex/payments/webhooks/__tests__/stripeWebhook.test.ts new file mode 100644 index 00000000..ee66c211 --- /dev/null +++ b/convex/payments/webhooks/__tests__/stripeWebhook.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from "vitest"; +import type { StripeWebhookEvent } from "../stripe"; +import { + buildReversalCode, + buildReversalReason, + extractProviderRef, + REVERSAL_EVENT_TYPES, + toPayload, +} from "../stripe"; + +// ── Helpers ────────────────────────────────────────────────────────── + +function makeEvent( + overrides: Partial & { type: string } +): StripeWebhookEvent { + return { + id: overrides.id ?? "evt_test_001", + type: overrides.type, + created: overrides.created ?? 1_711_929_600, // 2024-04-01 00:00:00 UTC + data: overrides.data ?? { + object: { + id: "ch_test_001", + amount: 5000, + }, + }, + }; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +describe("Stripe webhook handler", () => { + // ── Event filtering ────────────────────────────────────────────── + + describe("event type filtering", () => { + it("recognizes charge.refunded as a reversal event", () => { + expect(REVERSAL_EVENT_TYPES.has("charge.refunded")).toBe(true); + }); + + it("recognizes charge.dispute.created as a reversal event", () => { + expect(REVERSAL_EVENT_TYPES.has("charge.dispute.created")).toBe(true); + }); + + it("recognizes payment_intent.payment_failed as a reversal event", () => { + expect(REVERSAL_EVENT_TYPES.has("payment_intent.payment_failed")).toBe( + true + ); + }); + + it("does not recognize charge.succeeded as a reversal event", () => { + expect(REVERSAL_EVENT_TYPES.has("charge.succeeded")).toBe(false); + }); + + it("does not recognize payment_intent.succeeded as a reversal event", () => { + expect(REVERSAL_EVENT_TYPES.has("payment_intent.succeeded")).toBe(false); + }); + + it("contains exactly 3 event types", () => { + expect(REVERSAL_EVENT_TYPES.size).toBe(3); + }); + }); + + // ── Provider ref extraction ────────────────────────────────────── + + describe("extractProviderRef", () => { + it("extracts providerRef from metadata.provider_ref for charge.refunded", () => { + const event = makeEvent({ + type: "charge.refunded", + data: { + object: { + id: "ch_abc", + amount: 5000, + metadata: { provider_ref: "ref_from_metadata" }, + }, + }, + }); + expect(extractProviderRef(event)).toBe("ref_from_metadata"); + }); + + it("extracts providerRef from metadata.providerRef (camelCase) for charge.refunded", () => { + const event = makeEvent({ + type: "charge.refunded", + data: { + object: { + id: "ch_abc", + amount: 5000, + metadata: { providerRef: "ref_camel_case" }, + }, + }, + }); + expect(extractProviderRef(event)).toBe("ref_camel_case"); + }); + + it("falls back to object ID when metadata missing for charge.refunded", () => { + const event = makeEvent({ + type: "charge.refunded", + data: { + object: { + id: "ch_fallback", + amount: 5000, + }, + }, + }); + expect(extractProviderRef(event)).toBe("ch_fallback"); + }); + + it("falls back to object ID when metadata has no provider_ref", () => { + const event = makeEvent({ + type: "charge.refunded", + data: { + object: { + id: "ch_no_ref", + amount: 5000, + metadata: { other_key: "other_value" }, + }, + }, + }); + expect(extractProviderRef(event)).toBe("ch_no_ref"); + }); + + it("uses charge field for dispute providerRef", () => { + const event = makeEvent({ + type: "charge.dispute.created", + data: { + object: { + id: "dp_001", + amount: 3000, + charge: "ch_disputed_charge", + }, + }, + }); + expect(extractProviderRef(event)).toBe("ch_disputed_charge"); + }); + + it("falls back to dispute object ID when charge missing", () => { + const event = makeEvent({ + type: "charge.dispute.created", + data: { + object: { + id: "dp_002", + amount: 3000, + }, + }, + }); + expect(extractProviderRef(event)).toBe("dp_002"); + }); + + it("uses object ID for payment_intent.payment_failed", () => { + const event = makeEvent({ + type: "payment_intent.payment_failed", + data: { + object: { + id: "pi_failed_001", + amount: 7500, + }, + }, + }); + expect(extractProviderRef(event)).toBe("pi_failed_001"); + }); + + it("uses object ID for unknown event types", () => { + const event = makeEvent({ + type: "some.unknown.event", + data: { + object: { + id: "obj_unknown", + amount: 1000, + }, + }, + }); + expect(extractProviderRef(event)).toBe("obj_unknown"); + }); + }); + + // ── Payload mapping ────────────────────────────────────────────── + + describe("toPayload", () => { + it("converts Stripe timestamp to YYYY-MM-DD date", () => { + // 1711929600 = 2024-04-01T00:00:00Z + const event = makeEvent({ + type: "charge.refunded", + created: 1_711_929_600, + }); + const payload = toPayload(event); + expect(payload.reversalDate).toBe("2024-04-01"); + }); + + it("sets provider to stripe", () => { + const event = makeEvent({ type: "charge.refunded" }); + const payload = toPayload(event); + expect(payload.provider).toBe("stripe"); + }); + + it("uses event.id as providerEventId", () => { + const event = makeEvent({ + type: "charge.refunded", + id: "evt_unique_123", + }); + const payload = toPayload(event); + expect(payload.providerEventId).toBe("evt_unique_123"); + }); + + it("passes amount directly (Stripe already uses cents)", () => { + const event = makeEvent({ + type: "charge.refunded", + data: { + object: { + id: "ch_001", + amount: 15_075, + }, + }, + }); + const payload = toPayload(event); + expect(payload.originalAmount).toBe(15_075); + }); + }); + + // ── Reversal reason ────────────────────────────────────────────── + + describe("buildReversalReason", () => { + it("formats ACH Return reason for charge.refunded", () => { + const event = makeEvent({ + type: "charge.refunded", + data: { + object: { + id: "ch_001", + amount: 5000, + reason: "fraudulent", + }, + }, + }); + expect(buildReversalReason(event)).toBe("ACH Return: fraudulent"); + }); + + it("falls back to status for charge.refunded when no reason", () => { + const event = makeEvent({ + type: "charge.refunded", + data: { + object: { + id: "ch_001", + amount: 5000, + status: "refunded", + }, + }, + }); + expect(buildReversalReason(event)).toBe("ACH Return: refunded"); + }); + + it("defaults to 'refunded' when no reason or status for charge.refunded", () => { + const event = makeEvent({ + type: "charge.refunded", + data: { + object: { + id: "ch_001", + amount: 5000, + }, + }, + }); + expect(buildReversalReason(event)).toBe("ACH Return: refunded"); + }); + + it("formats dispute reason", () => { + const event = makeEvent({ + type: "charge.dispute.created", + data: { + object: { + id: "dp_001", + amount: 3000, + reason: "product_not_received", + }, + }, + }); + expect(buildReversalReason(event)).toBe("Dispute: product_not_received"); + }); + + it("defaults to 'opened' for dispute when no reason", () => { + const event = makeEvent({ + type: "charge.dispute.created", + data: { + object: { + id: "dp_001", + amount: 3000, + }, + }, + }); + expect(buildReversalReason(event)).toBe("Dispute: opened"); + }); + + it("formats ACH Failure with failure_code and failure_message", () => { + const event = makeEvent({ + type: "payment_intent.payment_failed", + data: { + object: { + id: "pi_001", + amount: 7500, + failure_code: "insufficient_funds", + failure_message: "The account has insufficient funds.", + }, + }, + }); + expect(buildReversalReason(event)).toBe( + "ACH Failure: insufficient_funds — The account has insufficient funds." + ); + }); + + it("defaults to 'unknown' for failed payment when no failure_code", () => { + const event = makeEvent({ + type: "payment_intent.payment_failed", + data: { + object: { + id: "pi_001", + amount: 7500, + }, + }, + }); + expect(buildReversalReason(event)).toBe("ACH Failure: unknown — "); + }); + + it("returns 'Unknown reversal' for unrecognized event types", () => { + const event = makeEvent({ + type: "some.other.event", + }); + expect(buildReversalReason(event)).toBe("Unknown reversal"); + }); + }); + + // ── Reversal code ──────────────────────────────────────────────── + + describe("buildReversalCode", () => { + it("sets reversalCode to reason for charge.refunded", () => { + const event = makeEvent({ + type: "charge.refunded", + data: { + object: { + id: "ch_001", + amount: 5000, + reason: "fraudulent", + }, + }, + }); + expect(buildReversalCode(event)).toBe("fraudulent"); + }); + + it("defaults to REFUND when no reason for charge.refunded", () => { + const event = makeEvent({ + type: "charge.refunded", + data: { + object: { + id: "ch_001", + amount: 5000, + }, + }, + }); + expect(buildReversalCode(event)).toBe("REFUND"); + }); + + it("sets reversalCode to DISPUTE for dispute events", () => { + const event = makeEvent({ + type: "charge.dispute.created", + data: { + object: { + id: "dp_001", + amount: 3000, + }, + }, + }); + expect(buildReversalCode(event)).toBe("DISPUTE"); + }); + + it("maps failure_code for failed payments", () => { + const event = makeEvent({ + type: "payment_intent.payment_failed", + data: { + object: { + id: "pi_001", + amount: 7500, + failure_code: "insufficient_funds", + }, + }, + }); + expect(buildReversalCode(event)).toBe("insufficient_funds"); + }); + + it("returns undefined failure_code when missing for failed payments", () => { + const event = makeEvent({ + type: "payment_intent.payment_failed", + data: { + object: { + id: "pi_001", + amount: 7500, + }, + }, + }); + expect(buildReversalCode(event)).toBeUndefined(); + }); + + it("returns undefined for unknown event types", () => { + const event = makeEvent({ + type: "charge.succeeded", + }); + expect(buildReversalCode(event)).toBeUndefined(); + }); + }); +}); diff --git a/convex/payments/webhooks/handleReversal.ts b/convex/payments/webhooks/handleReversal.ts new file mode 100644 index 00000000..2ccc30c2 --- /dev/null +++ b/convex/payments/webhooks/handleReversal.ts @@ -0,0 +1,94 @@ +import { v } from "convex/values"; +import { internal } from "../../_generated/api"; +import { type ActionCtx, internalQuery } from "../../_generated/server"; +import type { ReversalResult, ReversalWebhookPayload } from "./types"; + +// ── Internal Queries ───────────────────────────────────────────────── + +/** Look up a collection attempt by its provider reference. */ +export const getAttemptByProviderRef = internalQuery({ + args: { providerRef: v.string() }, + handler: async (ctx, args) => { + return ctx.db + .query("collectionAttempts") + .withIndex("by_provider_ref", (q) => + q.eq("providerRef", args.providerRef) + ) + .first(); + }, +}); + +// ── Main Handler ───────────────────────────────────────────────────── + +/** + * Shared reversal handler used by both Rotessa and Stripe httpAction handlers. + * + * Orchestrates: + * 1. Looks up the collection attempt by providerRef + * 2. Validates attempt state (must be `confirmed`) + * 3. Fires the GT transition once via `processReversalCascade` + * + * The GT transition's `emitPaymentReversed` effect handles the per-obligation + * cash ledger reversal cascade automatically. This handler fires the transition + * exactly once — never per-obligation. + * + * This is a plain async function (NOT a Convex registered function) that + * receives an ActionCtx from the calling httpAction. + */ +export async function handlePaymentReversal( + ctx: ActionCtx, + payload: ReversalWebhookPayload +): Promise { + // 1. Look up collection attempt by providerRef + const attempt = await ctx.runQuery( + internal.payments.webhooks.handleReversal.getAttemptByProviderRef, + { providerRef: payload.providerRef } + ); + + if (!attempt) { + return { success: false, reason: "attempt_not_found" }; + } + + // 2. Validate attempt state + if (attempt.status === "reversed") { + return { + success: true, + reason: "already_reversed", + attemptId: attempt._id, + }; + } + + if (attempt.status !== "confirmed") { + return { + success: false, + reason: "invalid_state", + attemptId: attempt._id, + }; + } + + // 3. Fire the GT transition once — the emitPaymentReversed effect + // handles per-obligation cash ledger reversal cascade automatically. + const result = await ctx.runMutation( + internal.payments.webhooks.processReversal.processReversalCascade, + { + attemptId: attempt._id, + effectiveDate: payload.reversalDate, + reason: payload.reversalReason, + provider: payload.provider, + providerEventId: payload.providerEventId, + } + ); + + if (!result.success) { + return { + success: false, + reason: "transition_failed", + attemptId: attempt._id, + }; + } + + return { + success: true, + attemptId: attempt._id, + }; +} diff --git a/convex/payments/webhooks/processReversal.ts b/convex/payments/webhooks/processReversal.ts new file mode 100644 index 00000000..931183f8 --- /dev/null +++ b/convex/payments/webhooks/processReversal.ts @@ -0,0 +1,54 @@ +import { v } from "convex/values"; +import { internalMutation } from "../../_generated/server"; +import { executeTransition } from "../../engine/transition"; +import type { CommandSource } from "../../engine/types"; + +/** + * Internal mutation that fires the GT transition `confirmed → reversed`. + * + * The GT engine's `emitPaymentReversed` effect (registered in ENG-173) + * handles the per-obligation cash-ledger reversal cascade automatically + * by iterating `planEntry.obligationIds` and calling + * `postPaymentReversalCascade()` for each one. Each call is idempotent + * via `postingGroupId`. + * + * This mutation fires the transition exactly ONCE per collection attempt. + * It should NOT be called per-obligation. + */ +export const processReversalCascade = internalMutation({ + args: { + attemptId: v.id("collectionAttempts"), + effectiveDate: v.string(), + reason: v.string(), + provider: v.union(v.literal("rotessa"), v.literal("stripe")), + providerEventId: v.string(), + }, + handler: async (ctx, args) => { + const source: CommandSource = { + actorType: "system", + channel: "api_webhook", + actorId: `webhook:${args.provider}`, + }; + + // Fire the GT transition: confirmed → reversed + // The emitPaymentReversed effect handles the per-obligation + // cash ledger reversal cascade (via postPaymentReversalCascade). + const transitionResult = await executeTransition(ctx, { + entityType: "collectionAttempt", + entityId: args.attemptId, + eventType: "PAYMENT_REVERSED", + payload: { + reason: args.reason, + provider: args.provider, + providerEventId: args.providerEventId, + effectiveDate: args.effectiveDate, + }, + source, + }); + + return { + success: transitionResult.success, + newState: transitionResult.newState, + }; + }, +}); diff --git a/convex/payments/webhooks/rotessa.ts b/convex/payments/webhooks/rotessa.ts new file mode 100644 index 00000000..ebd87b33 --- /dev/null +++ b/convex/payments/webhooks/rotessa.ts @@ -0,0 +1,132 @@ +import { internal } from "../../_generated/api"; +import { httpAction } from "../../_generated/server"; +import { handlePaymentReversal } from "./handleReversal"; +import type { ReversalWebhookPayload } from "./types"; +import { jsonResponse } from "./utils"; +import type { VerificationResult } from "./verification"; + +// ── Rotessa-specific types ────────────────────────────────────────── + +export interface RotessaWebhookEvent { + data: { + amount: number; + date?: string; + event_id?: string; + reason?: string; + return_code?: string; + transaction_id: string; + }; + event_type: string; +} + +// ── Constants ─────────────────────────────────────────────────────── + +export const REVERSAL_EVENT_TYPES = new Set([ + "transaction.nsf", + "transaction.returned", + "transaction.reversed", +]); + +// ── Helpers ───────────────────────────────────────────────────────── + +export function buildReversalReason(event: RotessaWebhookEvent): string { + const { event_type, data } = event; + + switch (event_type) { + case "transaction.nsf": + return `NSF: ${data.reason ?? "Non-Sufficient Funds"}`; + case "transaction.returned": + return `PAD Return: ${data.return_code ?? "unknown"} — ${data.reason ?? ""}`; + case "transaction.reversed": + return `Manual Reversal: ${data.reason ?? ""}`; + default: + return data.reason ?? "Unknown reversal"; + } +} + +export function buildReversalCode( + event: RotessaWebhookEvent +): string | undefined { + switch (event.event_type) { + case "transaction.nsf": + return "NSF"; + case "transaction.returned": + return event.data.return_code; + case "transaction.reversed": + return "MANUAL_REVERSAL"; + default: + return undefined; + } +} + +export function toPayload(event: RotessaWebhookEvent): ReversalWebhookPayload { + const today = new Date().toISOString().slice(0, 10); + + return { + originalAmount: Math.round((event.data.amount + Number.EPSILON) * 100), + provider: "rotessa", + providerEventId: event.data.event_id ?? event.data.transaction_id, + providerRef: event.data.transaction_id, + reversalCode: buildReversalCode(event), + reversalDate: event.data.date ?? today, + reversalReason: buildReversalReason(event), + }; +} + +// ── HTTP Action ───────────────────────────────────────────────────── + +export const rotessaWebhook = httpAction(async (ctx, request) => { + const body = await request.text(); + const signature = request.headers.get("X-Rotessa-Signature"); + + // 1. Verify signature (delegates to Node runtime via internalAction) + if (!signature) { + console.warn("[Rotessa Webhook] Missing signature header"); + return jsonResponse({ error: "invalid_signature" }, 401); + } + + const verification: VerificationResult = await ctx.runAction( + internal.payments.webhooks.verification.verifyRotessaSignatureAction, + { body, signature } + ); + + if (!verification.ok) { + if (verification.error === "missing_secret") { + console.error("[Rotessa Webhook] ROTESSA_WEBHOOK_SECRET not configured"); + return jsonResponse({ error: "server_configuration_error" }, 500); + } + console.warn("[Rotessa Webhook] Signature verification failed"); + return jsonResponse({ error: "invalid_signature" }, 401); + } + + // 2. Parse event + let event: RotessaWebhookEvent; + try { + event = JSON.parse(body) as RotessaWebhookEvent; + } catch { + console.warn("[Rotessa Webhook] Malformed JSON body"); + return jsonResponse({ error: "malformed_json" }, 400); + } + + // 3. Filter for reversal events only + if (!REVERSAL_EVENT_TYPES.has(event.event_type)) { + return jsonResponse({ ignored: true, event_type: event.event_type }); + } + + // 4. Map to payload and process + const payload = toPayload(event); + + try { + const result = await handlePaymentReversal(ctx, payload); + return jsonResponse({ ...result }); + } catch (err) { + // Always return 200 to prevent Rotessa retry storms. + // Non-200 responses are reserved for signature/JSON parsing failures only. + console.error("[Rotessa Webhook] Reversal processing failed:", err); + return jsonResponse({ + accepted: true, + error: "processing_failed", + message: err instanceof Error ? err.message : "Unknown error", + }); + } +}); diff --git a/convex/payments/webhooks/stripe.ts b/convex/payments/webhooks/stripe.ts new file mode 100644 index 00000000..5267cfd1 --- /dev/null +++ b/convex/payments/webhooks/stripe.ts @@ -0,0 +1,166 @@ +import { internal } from "../../_generated/api"; +import { httpAction } from "../../_generated/server"; +import { handlePaymentReversal } from "./handleReversal"; +import type { ReversalWebhookPayload } from "./types"; +import { jsonResponse } from "./utils"; +import type { VerificationResult } from "./verification"; + +// ── Stripe-specific types ─────────────────────────────────────────── + +export interface StripeWebhookEvent { + created: number; + data: { + object: { + amount: number; + charge?: string; + failure_code?: string; + failure_message?: string; + id: string; + metadata?: Record; + payment_intent?: string; + reason?: string; + status?: string; + }; + }; + id: string; + type: string; +} + +// ── Constants ─────────────────────────────────────────────────────── + +export const REVERSAL_EVENT_TYPES = new Set([ + "charge.dispute.created", + "charge.refunded", + "payment_intent.payment_failed", +]); + +// ── Helpers ───────────────────────────────────────────────────────── + +export function extractProviderRef(event: StripeWebhookEvent): string { + const obj = event.data.object; + + switch (event.type) { + case "charge.refunded": + return obj.metadata?.provider_ref ?? obj.metadata?.providerRef ?? obj.id; + case "charge.dispute.created": + return obj.charge ?? obj.id; + case "payment_intent.payment_failed": + return obj.id; + default: + return obj.id; + } +} + +export function buildReversalReason(event: StripeWebhookEvent): string { + const obj = event.data.object; + + switch (event.type) { + case "charge.refunded": + return `ACH Return: ${obj.reason ?? obj.status ?? "refunded"}`; + case "charge.dispute.created": + return `Dispute: ${obj.reason ?? "opened"}`; + case "payment_intent.payment_failed": + return `ACH Failure: ${obj.failure_code ?? "unknown"} — ${obj.failure_message ?? ""}`; + default: + return "Unknown reversal"; + } +} + +export function buildReversalCode( + event: StripeWebhookEvent +): string | undefined { + const obj = event.data.object; + + switch (event.type) { + case "charge.refunded": + return obj.reason ?? "REFUND"; + case "charge.dispute.created": + return "DISPUTE"; + case "payment_intent.payment_failed": + return obj.failure_code; + default: + return undefined; + } +} + +export function toPayload(event: StripeWebhookEvent): ReversalWebhookPayload { + const reversalDate = new Date(event.created * 1000) + .toISOString() + .slice(0, 10); + + return { + originalAmount: event.data.object.amount, + provider: "stripe", + providerEventId: event.id, + providerRef: extractProviderRef(event), + reversalCode: buildReversalCode(event), + reversalDate, + reversalReason: buildReversalReason(event), + }; +} + +// ── HTTP Action ───────────────────────────────────────────────────── + +export const stripeWebhook = httpAction(async (ctx, request) => { + const body = await request.text(); + const signatureHeader = request.headers.get("stripe-signature"); + + // 1. Verify signature (delegates to Node runtime via internalAction) + if (!signatureHeader) { + console.warn("[Stripe Webhook] Missing signature header"); + return jsonResponse({ error: "invalid_signature" }, 401); + } + + const verification: VerificationResult = await ctx.runAction( + internal.payments.webhooks.verification.verifyStripeSignatureAction, + { body, signatureHeader } + ); + + if (!verification.ok) { + if (verification.error === "missing_secret") { + console.error("[Stripe Webhook] STRIPE_WEBHOOK_SECRET not configured"); + return jsonResponse({ error: "server_configuration_error" }, 500); + } + console.warn("[Stripe Webhook] Signature verification failed"); + return jsonResponse({ error: "invalid_signature" }, 401); + } + + // 2. Parse event + let event: StripeWebhookEvent; + try { + event = JSON.parse(body) as StripeWebhookEvent; + } catch { + console.warn("[Stripe Webhook] Malformed JSON body"); + return jsonResponse({ error: "malformed_json" }, 400); + } + + // 3. Filter for reversal events only + if (!REVERSAL_EVENT_TYPES.has(event.type)) { + return jsonResponse({ ignored: true, event_type: event.type }); + } + + // Foot Gun P4: Log warning for disputes + if (event.type === "charge.dispute.created") { + console.warn( + `[Stripe Webhook] DISPUTE received for ${event.data.object.id}. ` + + "Manual review may be required." + ); + } + + // 4. Map to payload and process + const payload = toPayload(event); + + try { + const result = await handlePaymentReversal(ctx, payload); + return jsonResponse({ ...result }); + } catch (err) { + // Always return 200 for processing errors to prevent Stripe retry storms. + // Non-200 responses are reserved for signature/JSON validation failures only. + console.error("[Stripe Webhook] Reversal processing failed:", err); + return jsonResponse({ + error: "processing_failed", + message: err instanceof Error ? err.message : "Unknown error", + providerEventId: payload.providerEventId, + }); + } +}); diff --git a/convex/payments/webhooks/types.ts b/convex/payments/webhooks/types.ts new file mode 100644 index 00000000..c7d137c9 --- /dev/null +++ b/convex/payments/webhooks/types.ts @@ -0,0 +1,28 @@ +import type { Id } from "../../_generated/dataModel"; + +/** Normalized payload from any payment provider's reversal webhook. */ +export interface ReversalWebhookPayload { + /** Original amount in cents */ + originalAmount: number; + provider: "rotessa" | "stripe"; + /** For idempotency dedup */ + providerEventId: string; + /** Maps to collectionAttempts.providerRef */ + providerRef: string; + /** Provider-specific code (e.g., "NSF", "R01") */ + reversalCode?: string; + /** YYYY-MM-DD */ + reversalDate: string; + /** Human-readable reason */ + reversalReason: string; +} + +/** Result from processing a reversal. */ +export interface ReversalResult { + attemptId?: Id<"collectionAttempts">; + clawbackRequired?: boolean; + postingGroupId?: string; + /** If not successful, why */ + reason?: string; + success: boolean; +} diff --git a/convex/payments/webhooks/utils.ts b/convex/payments/webhooks/utils.ts new file mode 100644 index 00000000..2c5b3e56 --- /dev/null +++ b/convex/payments/webhooks/utils.ts @@ -0,0 +1,10 @@ +/** Shared JSON response helper for webhook httpAction handlers. */ +export function jsonResponse( + data: Record, + status = 200 +): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/convex/payments/webhooks/verification.ts b/convex/payments/webhooks/verification.ts new file mode 100644 index 00000000..3f5fd95a --- /dev/null +++ b/convex/payments/webhooks/verification.ts @@ -0,0 +1,161 @@ +"use node"; + +import { createHmac, timingSafeEqual } from "node:crypto"; +import { v } from "convex/values"; +import { internalAction } from "../../_generated/server"; + +/** + * Verify a Rotessa webhook signature (HMAC-SHA256). + * + * Rotessa sends the signature in the `X-Rotessa-Signature` header as a + * hex-encoded HMAC-SHA256 digest of the raw request body. + */ +export function verifyRotessaSignature( + body: string, + signature: string, + secret: string +): boolean { + const expected = createHmac("sha256", secret).update(body).digest("hex"); + const sigBuffer = Buffer.from(signature, "hex"); + const expectedBuffer = Buffer.from(expected, "hex"); + + if (sigBuffer.length !== expectedBuffer.length) { + return false; + } + + return timingSafeEqual(sigBuffer, expectedBuffer); +} + +/** + * Verify a Stripe webhook signature (`stripe-signature` header). + * + * The header format is `t=,v1=`. + * The signed payload is `${timestamp}.${body}`, HMAC-SHA256 hex-encoded. + * An optional tolerance (default 300 s / 5 min) rejects stale events. + */ +export function verifyStripeSignature( + body: string, + signatureHeader: string, + secret: string, + toleranceSeconds = 300 +): boolean { + const parts = parseStripeSignatureHeader(signatureHeader); + if (!parts) { + return false; + } + + const { timestamp, signatures } = parts; + + // Timestamp tolerance check + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - timestamp) > toleranceSeconds) { + return false; + } + + const expectedPayload = `${timestamp}.${body}`; + const expected = createHmac("sha256", secret) + .update(expectedPayload) + .digest("hex"); + const expectedBuffer = Buffer.from(expected, "hex"); + + // Check if any v1 signature matches (Stripe may send multiple) + return signatures.some((sig) => { + const sigBuffer = Buffer.from(sig, "hex"); + if (sigBuffer.length !== expectedBuffer.length) { + return false; + } + return timingSafeEqual(sigBuffer, expectedBuffer); + }); +} + +// ── Internal helpers ───────────────────────────────────────────────── + +interface ParsedStripeHeader { + signatures: string[]; + timestamp: number; +} + +function parseStripeSignatureHeader(header: string): ParsedStripeHeader | null { + let timestamp: number | undefined; + const signatures: string[] = []; + + for (const part of header.split(",")) { + const [key, value] = part.split("=", 2); + if (!(key && value)) { + continue; + } + + if (key.trim() === "t") { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isNaN(parsed)) { + return null; + } + timestamp = parsed; + } else if (key.trim() === "v1") { + signatures.push(value.trim()); + } + } + + if (timestamp === undefined || signatures.length === 0) { + return null; + } + + return { timestamp, signatures }; +} + +// ── Verification result types ─────────────────────────────────────── + +export type VerificationResult = + | { ok: true } + | { ok: false; error: "missing_secret" } + | { ok: false; error: "invalid_signature" }; + +// ── Internal Actions (callable from httpActions via ctx.runAction) ─── + +/** + * Verify a Rotessa webhook signature via internalAction. + * Runs in the Node.js runtime so it can use node:crypto. + * + * Returns a structured result distinguishing missing secrets from bad signatures. + */ +export const verifyRotessaSignatureAction = internalAction({ + args: { + body: v.string(), + signature: v.string(), + }, + handler: async (_ctx, args): Promise => { + const secret = process.env.ROTESSA_WEBHOOK_SECRET; + if (!secret) { + console.error("[Rotessa Webhook] ROTESSA_WEBHOOK_SECRET not configured"); + return { ok: false, error: "missing_secret" }; + } + const valid = verifyRotessaSignature(args.body, args.signature, secret); + return valid ? { ok: true } : { ok: false, error: "invalid_signature" }; + }, +}); + +/** + * Verify a Stripe webhook signature via internalAction. + * Runs in the Node.js runtime so it can use node:crypto. + * + * Returns a structured result distinguishing missing secrets from bad signatures. + */ +export const verifyStripeSignatureAction = internalAction({ + args: { + body: v.string(), + signatureHeader: v.string(), + }, + handler: async (_ctx, args): Promise => { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) { + console.error("[Stripe Webhook] STRIPE_WEBHOOK_SECRET not configured"); + return { ok: false, error: "missing_secret" }; + } + const valid = verifyStripeSignature( + args.body, + args.signatureHeader, + secret + ); + return valid ? { ok: true } : { ok: false, error: "invalid_signature" }; + }, +}); diff --git a/specs/ENG-175/chunks/chunk-01-infrastructure-core/context.md b/specs/ENG-175/chunks/chunk-01-infrastructure-core/context.md new file mode 100644 index 00000000..9db77083 --- /dev/null +++ b/specs/ENG-175/chunks/chunk-01-infrastructure-core/context.md @@ -0,0 +1,330 @@ +# Chunk 01 Context: Infrastructure + Core Reversal Logic + +## Overview +Create the foundational webhook infrastructure: signature verification, shared types, internal reversal mutation, and shared reversal handler action. These are the building blocks used by Rotessa and Stripe webhook handlers in chunk-02. + +--- + +## T-001: Create Webhook Signature Verification Utilities + +**File:** `convex/payments/webhooks/verification.ts` (new) + +Create two signature verification functions: + +### Rotessa PAD Signature +- Header: `X-Rotessa-Signature` +- Algorithm: HMAC-SHA256 +- Secret: `ROTESSA_WEBHOOK_SECRET` environment variable +- Constant-time comparison to prevent timing attacks + +### Stripe Signature +- Header: `stripe-signature` +- Format: `t=timestamp,v1=signature` +- Algorithm: HMAC-SHA256 of `${timestamp}.${body}` +- Secret: `STRIPE_WEBHOOK_SECRET` environment variable +- Constant-time comparison +- Optional: timestamp tolerance check (5 minutes) + +```typescript +// Signature verification for Rotessa + Stripe webhook handlers +import { createHmac, timingSafeEqual } from "node:crypto"; + +export function verifyRotessaSignature( + body: string, + signature: string, + secret: string +): boolean { + // HMAC-SHA256, constant-time comparison +} + +export function verifyStripeSignature( + body: string, + signatureHeader: string, + secret: string, + toleranceSeconds?: number // default 300 (5 min) +): boolean { + // Parse t=timestamp,v1=signature format + // HMAC-SHA256 of `${timestamp}.${body}` + // Constant-time comparison + optional timestamp tolerance +} +``` + +**IMPORTANT:** These run inside Convex `httpAction` handlers. `node:crypto` is available in Convex actions. + +--- + +## T-002: Create Shared Reversal Types + +**File:** `convex/payments/webhooks/types.ts` (new) + +Define the shared types used across webhook handlers: + +```typescript +import type { Id } from "../../_generated/dataModel"; + +/** Normalized payload from any payment provider's reversal webhook */ +export interface ReversalWebhookPayload { + providerRef: string; // Maps to collectionAttempts.providerRef + provider: "rotessa" | "stripe"; + reversalReason: string; // Human-readable reason + reversalCode?: string; // Provider-specific code (e.g., "NSF", "R01") + originalAmount: number; // cents + reversalDate: string; // YYYY-MM-DD + providerEventId: string; // For idempotency dedup +} + +/** Result from processing a reversal */ +export interface ReversalResult { + success: boolean; + reason?: string; // If not successful, why + attemptId?: Id<"collectionAttempts">; + postingGroupId?: string; + clawbackRequired?: boolean; +} +``` + +--- + +## T-003: Create Internal Reversal Mutation + +**File:** `convex/payments/webhooks/processReversal.ts` (new) + +This is an `internalMutation` that fires the GT transition `confirmed → reversed` for a collection attempt. The cash-ledger reversal cascade is handled entirely by the effect-driven architecture — not by a direct call in this mutation. + +**EFFECT-DRIVEN ARCHITECTURE:** The `emitPaymentReversed` effect (registered in ENG-173) handles the per-obligation cash-ledger reversal cascade automatically. When `executeTransition()` fires the `PAYMENT_REVERSED` event, the effect handler iterates `planEntry.obligationIds` and calls `postPaymentReversalCascade()` for each obligation. Each call is idempotent via `postingGroupId`, so retries are safe. This mutation fires the transition exactly ONCE per collection attempt — it should NOT be called per-obligation. + +### Key imports and contracts: + +```typescript +import { internalMutation } from "../../_generated/server"; +import { v } from "convex/values"; +import { executeTransition } from "../../engine/transition"; +import type { CommandSource } from "../../engine/types"; +``` + +### Function signature: +```typescript +export const processReversalCascade = internalMutation({ + args: { + attemptId: v.id("collectionAttempts"), + effectiveDate: v.string(), // YYYY-MM-DD + reason: v.string(), + provider: v.union(v.literal("rotessa"), v.literal("stripe")), + providerEventId: v.string(), + }, + handler: async (ctx, args) => { + const source: CommandSource = { + actorType: "system", + channel: "api_webhook", + actorId: `webhook:${args.provider}`, + }; + + // Fire the GT transition: confirmed → reversed + // The emitPaymentReversed effect handles the per-obligation + // cash ledger reversal cascade (via postPaymentReversalCascade). + const transitionResult = await executeTransition(ctx, { + entityType: "collectionAttempt", + entityId: args.attemptId, + eventType: "PAYMENT_REVERSED", + payload: { + reason: args.reason, + provider: args.provider, + providerEventId: args.providerEventId, + effectiveDate: args.effectiveDate, + }, + source, + }); + + return { + success: transitionResult.success, + newState: transitionResult.newState, + }; + }, +}); +``` + +### EntityType for collectionAttempt: +The GT engine uses `"collectionAttempt"` as the entityType string. + +### CommandSource channel: +Use `"api_webhook"` which is a valid `CommandChannel` value (see `convex/engine/types.ts` line 35). + +--- + +## T-004: Create Shared Reversal Handler Action + +**File:** `convex/payments/webhooks/handleReversal.ts` (new) + +This is a plain async function (NOT a Convex function) used by both Rotessa and Stripe httpAction handlers. It orchestrates: +1. Look up collection attempt by providerRef +2. Validate attempt state (must be `confirmed`) +3. Load related plan entry for obligationIds +4. Call the internal mutation `processReversalCascade` + +```typescript +import type { ActionCtx } from "../../_generated/server"; +import { internal } from "../../_generated/api"; +import type { ReversalWebhookPayload, ReversalResult } from "./types"; +``` + +### Implementation: +```typescript +export async function handlePaymentReversal( + ctx: ActionCtx, + payload: ReversalWebhookPayload +): Promise { + // 1. Look up collection attempt by providerRef + // Use ctx.runQuery to query by_provider_ref index + // If not found → return { success: false, reason: "attempt_not_found" } + + // 2. Check attempt state + // If already "reversed" → idempotent skip, return success + // If not "confirmed" → return { success: false, reason: "invalid_state" } + // (Return 200 to provider anyway to prevent retry storms) + + // 3. Load plan entry from attempt.planEntryId + // Get obligationIds from planEntry + // Get mortgageId from first obligation + + // 4. Call processReversalCascade via ctx.runMutation + // ctx.runMutation(internal.payments.webhooks.processReversal.processReversalCascade, { + // attemptId, obligationId, mortgageId, effectiveDate, reason, provider, providerEventId + // }) + // Note: Call once per obligation in planEntry.obligationIds + // (processReversalCascade handles one obligation at a time) + + // 5. Return result +} +``` + +### Query for attempt lookup: +You'll need a helper query. Create an `internalQuery` in the same file or a separate queries file: + +```typescript +import { internalQuery } from "../../_generated/server"; +import { v } from "convex/values"; + +export const getAttemptByProviderRef = internalQuery({ + args: { providerRef: v.string() }, + handler: async (ctx, args) => { + return ctx.db + .query("collectionAttempts") + .withIndex("by_provider_ref", (q) => q.eq("providerRef", args.providerRef)) + .first(); + }, +}); +``` + +### Plan entry loading: +```typescript +export const getAttemptWithPlanEntry = internalQuery({ + args: { attemptId: v.id("collectionAttempts") }, + handler: async (ctx, args) => { + const attempt = await ctx.db.get(args.attemptId); + if (!attempt) return null; + const planEntry = attempt.planEntryId ? await ctx.db.get(attempt.planEntryId) : null; + return { attempt, planEntry }; + }, +}); +``` + +--- + +## Codebase Patterns to Follow + +### internalMutation pattern (from `convex/payments/cashLedger/mutations.ts`): +```typescript +import { internalMutation, type MutationCtx } from "../../_generated/server"; +import { v } from "convex/values"; + +export const functionName = internalMutation({ + args: { /* validators */ }, + handler: async (ctx, args) => { /* implementation */ }, +}); +``` + +### httpAction pattern: +```typescript +import { httpAction } from "../../_generated/server"; + +export const webhookHandler = httpAction(async (ctx, request) => { + // ctx.runMutation, ctx.runQuery, ctx.runAction available + return new Response(JSON.stringify({ received: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +}); +``` + +### executeTransition signature (from `convex/engine/transition.ts:229`): +```typescript +executeTransition(ctx, { + entityType: "collectionAttempt", // EntityType string + entityId: attemptId, // String ID + eventType: "PAYMENT_REVERSED", // Event name from machine + payload: { reason: string, ... }, // Passed to effects + source: CommandSource, +}) +``` + +### CommandSource (from `convex/engine/types.ts:41`): +```typescript +interface CommandSource { + actorId?: string; + actorType?: "borrower" | "broker" | "member" | "admin" | "system"; + channel: "dashboard" | "api" | "api_webhook" | "scheduler" | "simulation"; + ip?: string; + sessionId?: string; +} +``` + +### collectionAttempts schema (from `convex/schema.ts:678`): +```typescript +collectionAttempts: defineTable({ + // ... other fields + providerRef: v.optional(v.string()), + status: v.string(), + planEntryId: v.id("planEntries"), + // ... +}).index("by_provider_ref", ["providerRef"]) +``` + +### Collection attempt PAYMENT_REVERSED event type (from machine): +```typescript +{ type: "PAYMENT_REVERSED"; reason: string } +``` + +### postPaymentReversalCascade (from `convex/payments/cashLedger/integrations.ts:1370`): +```typescript +export async function postPaymentReversalCascade( + ctx: MutationCtx, + args: { + attemptId?: Id<"collectionAttempts">; + transferRequestId?: Id<"transferRequests">; + obligationId: Id<"obligations">; + mortgageId: Id<"mortgages">; + effectiveDate: string; + source: CommandSource; + reason: string; + } +): Promise<{ + reversalEntries: Doc<"cash_ledger_journal_entries">[]; + postingGroupId: string; + clawbackRequired: boolean; +}> +``` + +### emitPaymentReversed effect (from `convex/engine/effects/collectionAttempt.ts:216`): +This effect is already registered and will fire automatically when `executeTransition` processes PAYMENT_REVERSED. It iterates `planEntry.obligationIds` and calls `postPaymentReversalCascade` for each. Since the cascade is idempotent, calling it directly in processReversalCascade AND having the effect call it again is safe. + +--- + +## File Organization +All new files go under `convex/payments/webhooks/`: +``` +convex/payments/webhooks/ + verification.ts ← T-001 + types.ts ← T-002 + processReversal.ts ← T-003 + handleReversal.ts ← T-004 +``` diff --git a/specs/ENG-175/chunks/chunk-01-infrastructure-core/tasks.md b/specs/ENG-175/chunks/chunk-01-infrastructure-core/tasks.md new file mode 100644 index 00000000..572558a8 --- /dev/null +++ b/specs/ENG-175/chunks/chunk-01-infrastructure-core/tasks.md @@ -0,0 +1,8 @@ +# Chunk 01: Infrastructure + Core Reversal Logic + +## Tasks + +- [ ] T-001: Create webhook signature verification utilities +- [ ] T-002: Create shared reversal types and ReversalWebhookPayload interface +- [ ] T-003: Create internal reversal mutation processReversalCascade +- [ ] T-004: Create shared reversal handler action handlePaymentReversal diff --git a/specs/ENG-175/chunks/chunk-02-provider-handlers-router/context.md b/specs/ENG-175/chunks/chunk-02-provider-handlers-router/context.md new file mode 100644 index 00000000..eb076d8e --- /dev/null +++ b/specs/ENG-175/chunks/chunk-02-provider-handlers-router/context.md @@ -0,0 +1,357 @@ +# Chunk 02 Context: Provider Handlers + Router Registration + +## Overview +Create provider-specific webhook httpAction handlers for Rotessa PAD and Stripe ACH, plus register the routes in the HTTP router. These handlers parse provider-specific payloads, verify signatures, and delegate to the shared `handlePaymentReversal` action from chunk-01. + +--- + +## T-005: Create Rotessa PAD Webhook Handler + +**File:** `convex/payments/webhooks/rotessa.ts` (new) + +### Rotessa PAD Reversal Events +Rotessa sends webhooks for these reversal-related events: +- `transaction.nsf` — Non-Sufficient Funds +- `transaction.returned` — PAD return +- `transaction.reversed` — Manual reversal + +### Implementation: + +```typescript +import { httpAction } from "../../_generated/server"; +import { handlePaymentReversal } from "./handleReversal"; +import { verifyRotessaSignature } from "./verification"; +import type { ReversalWebhookPayload } from "./types"; + +// Events we handle — all others return 200 but are ignored (Foot Gun P5) +const REVERSAL_EVENT_TYPES = new Set([ + "transaction.nsf", + "transaction.returned", + "transaction.reversed", +]); + +export const rotessaWebhook = httpAction(async (ctx, request) => { + const body = await request.text(); + const signature = request.headers.get("X-Rotessa-Signature") ?? ""; + + // 1. Get webhook secret from environment + const secret = process.env.ROTESSA_WEBHOOK_SECRET; + if (!secret) { + console.error("[rotessaWebhook] ROTESSA_WEBHOOK_SECRET not configured"); + return new Response(JSON.stringify({ error: "server_configuration_error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + // 2. Verify signature + if (!verifyRotessaSignature(body, signature, secret)) { + console.warn("[rotessaWebhook] Invalid signature"); + return new Response(JSON.stringify({ error: "invalid_signature" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + // 3. Parse event + let event: RotessaWebhookEvent; + try { + event = JSON.parse(body); + } catch { + return new Response(JSON.stringify({ error: "invalid_json" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // 4. Filter for reversal events only — return 200 for others (Foot Gun P5) + if (!REVERSAL_EVENT_TYPES.has(event.event_type)) { + return new Response(JSON.stringify({ received: true, ignored: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // 5. Map to normalized payload + const payload: ReversalWebhookPayload = { + providerRef: event.data.transaction_id, // or however Rotessa identifies the original transaction + provider: "rotessa", + reversalReason: mapRotessaReason(event.event_type, event.data), + reversalCode: event.data.return_code, + originalAmount: Math.round(event.data.amount * 100), // Convert dollars to cents + reversalDate: event.data.date || new Date().toISOString().slice(0, 10), + providerEventId: event.data.event_id || `rotessa:${event.event_type}:${event.data.transaction_id}`, + }; + + // 6. Process reversal + const result = await handlePaymentReversal(ctx, payload); + + // 7. Always return 200 to prevent retry storms (Foot Gun P6) + return new Response(JSON.stringify({ received: true, ...result }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +}); +``` + +### Rotessa Event Shape (approximate — see Open Question #1): +Since exact Rotessa payload format is an open question, define a reasonable interface based on common PAD webhook patterns: + +```typescript +interface RotessaWebhookEvent { + event_type: string; + data: { + transaction_id: string; // Our providerRef + amount: number; // Dollar amount (convert to cents) + return_code?: string; // NSF code etc. + date?: string; // YYYY-MM-DD + event_id?: string; // Unique event identifier + reason?: string; + }; +} +``` + +### Reason mapping: +```typescript +function mapRotessaReason(eventType: string, data: RotessaWebhookEvent["data"]): string { + switch (eventType) { + case "transaction.nsf": return `NSF: ${data.reason ?? "Non-Sufficient Funds"}`; + case "transaction.returned": return `PAD Return: ${data.return_code ?? "unknown"} — ${data.reason ?? ""}`; + case "transaction.reversed": return `Manual Reversal: ${data.reason ?? ""}`; + default: return `Rotessa reversal: ${eventType}`; + } +} +``` + +--- + +## T-006: Create Stripe ACH Webhook Handler + +**File:** `convex/payments/webhooks/stripe.ts` (new) + +### Stripe ACH Reversal Events +- `charge.dispute.created` — Dispute opened (Foot Gun P4: freeze + separate handling) +- `charge.refunded` — ACH return processed +- `payment_intent.payment_failed` — ACH failure after initial success + +### Implementation: + +```typescript +import { httpAction } from "../../_generated/server"; +import { handlePaymentReversal } from "./handleReversal"; +import { verifyStripeSignature } from "./verification"; +import type { ReversalWebhookPayload } from "./types"; + +const REVERSAL_EVENT_TYPES = new Set([ + "charge.dispute.created", + "charge.refunded", + "payment_intent.payment_failed", +]); + +export const stripeWebhook = httpAction(async (ctx, request) => { + const body = await request.text(); + const signatureHeader = request.headers.get("stripe-signature") ?? ""; + + // 1. Get webhook secret + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) { + console.error("[stripeWebhook] STRIPE_WEBHOOK_SECRET not configured"); + return new Response(JSON.stringify({ error: "server_configuration_error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + // 2. Verify signature + if (!verifyStripeSignature(body, signatureHeader, secret)) { + console.warn("[stripeWebhook] Invalid signature"); + return new Response(JSON.stringify({ error: "invalid_signature" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + // 3. Parse event + let event: StripeWebhookEvent; + try { + event = JSON.parse(body); + } catch { + return new Response(JSON.stringify({ error: "invalid_json" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // 4. Filter for reversal events + if (!REVERSAL_EVENT_TYPES.has(event.type)) { + return new Response(JSON.stringify({ received: true, ignored: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // 5. Map to normalized payload + // Stripe uses different object structures per event type + const payload = mapStripeEventToPayload(event); + if (!payload) { + return new Response(JSON.stringify({ received: true, error: "unmappable_event" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // 6. For disputes: log additional warning (Foot Gun P4) + if (event.type === "charge.dispute.created") { + console.warn( + `[stripeWebhook] Dispute received for charge ${event.data.object.id}. ` + + `Full dispute resolution is Phase 2+ (ENG-180 scope). ` + + `Processing as reversal + flagging obligation.` + ); + } + + // 7. Process reversal + const result = await handlePaymentReversal(ctx, payload); + + return new Response(JSON.stringify({ received: true, ...result }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +}); +``` + +### Stripe Event Shape: +```typescript +interface StripeWebhookEvent { + id: string; // evt_xxx — unique event ID + type: string; // e.g., "charge.refunded" + data: { + object: { + id: string; // ch_xxx or pi_xxx + amount: number; // cents + metadata?: Record; // Our providerRef stored here + payment_intent?: string; + failure_code?: string; + failure_message?: string; + // For disputes: + charge?: string; + reason?: string; + status?: string; + }; + }; + created: number; // Unix timestamp +} +``` + +### Stripe payload mapping: +```typescript +function mapStripeEventToPayload(event: StripeWebhookEvent): ReversalWebhookPayload | null { + const obj = event.data.object; + // providerRef is stored in charge/payment_intent metadata + const providerRef = obj.metadata?.provider_ref || obj.metadata?.providerRef || obj.id; + + switch (event.type) { + case "charge.refunded": + return { + providerRef, + provider: "stripe", + reversalReason: `ACH Return: ${obj.failure_message ?? "refunded"}`, + reversalCode: obj.failure_code, + originalAmount: obj.amount, + reversalDate: new Date(event.created * 1000).toISOString().slice(0, 10), + providerEventId: event.id, + }; + case "charge.dispute.created": + return { + providerRef: obj.charge || obj.id, + provider: "stripe", + reversalReason: `Dispute: ${obj.reason ?? "unknown"}`, + reversalCode: "DISPUTE", + originalAmount: obj.amount, + reversalDate: new Date(event.created * 1000).toISOString().slice(0, 10), + providerEventId: event.id, + }; + case "payment_intent.payment_failed": + return { + providerRef: obj.id, + provider: "stripe", + reversalReason: `ACH Failure: ${obj.failure_message ?? "payment_failed"}`, + reversalCode: obj.failure_code, + originalAmount: obj.amount, + reversalDate: new Date(event.created * 1000).toISOString().slice(0, 10), + providerEventId: event.id, + }; + default: + return null; + } +} +``` + +--- + +## T-007: Register Webhook Routes in HTTP Router + +**File:** `convex/http.ts` (modify) + +### Current state: +```typescript +import { httpRouter } from "convex/server"; +import { authKit } from "./auth"; + +const http = httpRouter(); +authKit.registerRoutes(http); +export default http; +``` + +### After modification: +```typescript +import { httpRouter } from "convex/server"; +import { authKit } from "./auth"; +import { rotessaWebhook } from "./payments/webhooks/rotessa"; +import { stripeWebhook } from "./payments/webhooks/stripe"; + +const http = httpRouter(); + +authKit.registerRoutes(http); + +// Payment provider webhook endpoints +http.route({ + path: "/webhooks/rotessa", + method: "POST", + handler: rotessaWebhook, +}); + +http.route({ + path: "/webhooks/stripe", + method: "POST", + handler: stripeWebhook, +}); + +export default http; +``` + +--- + +## Codebase Patterns + +### httpAction pattern (Convex HTTP actions): +- `httpAction` handlers receive `(ctx, request)` where `request` is a standard `Request` object +- `ctx` has `runMutation`, `runQuery`, `runAction` methods +- Must return a `Response` object +- Can access `process.env` for environment variables +- CANNOT directly call DB — must delegate to mutations/queries + +### Webhook handler conventions from implementation plan: +- Always return 200 for unrecognized events to prevent retry storms (Foot Gun P5) +- Verify signatures before any processing +- Log warnings for unexpected states but don't crash +- Use `console.warn` for issues, `console.error` for configuration problems +- Source attribution: `{ actorType: "system", channel: "api_webhook", actorId: "webhook:provider" }` + +### Environment variables needed: +- `ROTESSA_WEBHOOK_SECRET` — HMAC key for Rotessa +- `STRIPE_WEBHOOK_SECRET` — Stripe webhook signing secret + +### Dependencies from chunk-01: +- `handlePaymentReversal` from `./handleReversal` +- `verifyRotessaSignature`, `verifyStripeSignature` from `./verification` +- `ReversalWebhookPayload` from `./types` diff --git a/specs/ENG-175/chunks/chunk-02-provider-handlers-router/tasks.md b/specs/ENG-175/chunks/chunk-02-provider-handlers-router/tasks.md new file mode 100644 index 00000000..3a50e4a0 --- /dev/null +++ b/specs/ENG-175/chunks/chunk-02-provider-handlers-router/tasks.md @@ -0,0 +1,7 @@ +# Chunk 02: Provider Handlers + Router Registration + +## Tasks + +- [ ] T-005: Create Rotessa PAD webhook httpAction handler +- [ ] T-006: Create Stripe ACH webhook httpAction handler +- [ ] T-007: Register webhook routes in HTTP router diff --git a/specs/ENG-175/chunks/chunk-03-tests/context.md b/specs/ENG-175/chunks/chunk-03-tests/context.md new file mode 100644 index 00000000..2d9e6c0b --- /dev/null +++ b/specs/ENG-175/chunks/chunk-03-tests/context.md @@ -0,0 +1,230 @@ +# Chunk 03 Context: Tests + +## Overview +Write tests for the webhook infrastructure. Tests cover: signature verification, core reversal logic (handleReversal + processReversal), and provider-specific webhook handlers. + +--- + +## Test Framework & Patterns + +### Test framework: Vitest +```typescript +import { describe, expect, it, vi } from "vitest"; +``` + +### For Convex function tests: convex-test +```typescript +import { convexTest } from "convex-test"; +import schema from "../../../schema"; +``` + +### Project conventions: +- Test files in `__tests__/` directory alongside source +- Use `describe/it/expect` from vitest +- Use `convexTest(schema, modules)` for integration tests with Convex DB +- Use `vi.fn()` for mocks +- Follow existing test patterns in `convex/payments/cashLedger/__tests__/` + +--- + +## T-008: Core Reversal Logic Tests + +**File:** `convex/payments/webhooks/__tests__/handleReversal.test.ts` (new) + +### Test categories: + +#### Signature Verification Tests +```typescript +describe("verifyRotessaSignature", () => { + it("returns true for valid HMAC-SHA256 signature"); + it("returns false for invalid signature"); + it("returns false for empty signature"); + it("is resistant to timing attacks (uses constant-time comparison)"); +}); + +describe("verifyStripeSignature", () => { + it("returns true for valid stripe-signature header"); + it("returns false for invalid signature"); + it("returns false for expired timestamp beyond tolerance"); + it("handles missing v1= prefix gracefully"); +}); +``` + +#### Core Reversal Logic Tests (using convex-test) +```typescript +describe("handlePaymentReversal", () => { + // Happy path + it("processes reversal for confirmed attempt: transitions to reversed + posts cash ledger entries"); + + // Idempotency + it("returns success for already-reversed attempt (idempotent skip)"); + it("duplicate webhook with same providerEventId is a no-op"); + + // Out-of-order handling + it("returns failure for non-confirmed attempt (out-of-order webhook)"); + it("returns failure for attempt in 'executing' state"); + + // Not found + it("returns failure when providerRef doesn't match any attempt"); + + // Clawback + it("flags clawbackRequired when payout was already sent"); +}); +``` + +#### processReversalCascade Tests +```typescript +describe("processReversalCascade", () => { + it("calls postPaymentReversalCascade with correct arguments"); + it("calls executeTransition with PAYMENT_REVERSED event"); + it("passes effectiveDate and reason through to GT transition payload"); + it("logs warning when clawbackRequired is true"); +}); +``` + +### Test setup pattern for Convex integration tests: +```typescript +import { convexTest } from "convex-test"; +import { describe, expect, it } from "vitest"; +import schema from "../../../schema"; + +// Import the modules under test +const modules = import.meta.glob("../../../**/*.ts", { eager: true }); + +describe("handlePaymentReversal", () => { + it("happy path", async () => { + const t = convexTest(schema, modules); + + // Seed test data: + // 1. Create a mortgage + // 2. Create an obligation + // 3. Create a plan entry with obligationIds + // 4. Create a collection attempt in "confirmed" state with a providerRef + // 5. Create cash ledger accounts and CASH_RECEIVED entry (so reversal has something to reverse) + + // Call the handler + // Assert: attempt transitioned to "reversed" + // Assert: REVERSAL entries exist in cash_ledger_journal_entries + // Assert: postingGroupId matches expected pattern + }); +}); +``` + +### e2e helper patterns: +Look at `convex/payments/cashLedger/__tests__/e2eHelpers.ts` for existing test data seeding helpers. Reuse those where possible: +- `seedMortgage()`, `seedObligation()`, `seedCollectionAttempt()` etc. +- `seedCashLedgerAccounts()` for setting up the account chart + +--- + +## T-009: Rotessa Webhook Handler Tests + +**File:** `convex/payments/webhooks/__tests__/rotessaWebhook.test.ts` (new) + +### Test categories: + +```typescript +describe("rotessaWebhook", () => { + // Signature validation + it("returns 401 for invalid signature"); + it("returns 500 when ROTESSA_WEBHOOK_SECRET is not configured"); + + // Event filtering + it("returns 200 with ignored=true for non-reversal events"); + it("processes transaction.nsf event as reversal"); + it("processes transaction.returned event as reversal"); + it("processes transaction.reversed event as reversal"); + + // Payload parsing + it("returns 400 for invalid JSON body"); + it("correctly maps Rotessa amount (dollars) to cents"); + it("extracts providerRef from transaction_id"); + + // Integration + it("calls handlePaymentReversal with normalized payload"); + it("always returns 200 even when reversal processing fails (prevent retry storms)"); +}); +``` + +### Testing HTTP actions: +Rotessa/Stripe handlers are httpActions. To test them: +1. Unit test the payload mapping/parsing functions directly (no Convex needed) +2. Integration test the full handler via convex-test HTTP endpoint testing or by testing the internal functions they call + +For signature verification unit tests, create known HMAC signatures: +```typescript +import { createHmac } from "node:crypto"; + +function createTestRotessaSignature(body: string, secret: string): string { + return createHmac("sha256", secret).update(body).digest("hex"); +} +``` + +--- + +## T-010: Stripe Webhook Handler Tests + +**File:** `convex/payments/webhooks/__tests__/stripeWebhook.test.ts` (new) + +### Test categories: + +```typescript +describe("stripeWebhook", () => { + // Signature validation + it("returns 401 for invalid stripe-signature"); + it("returns 500 when STRIPE_WEBHOOK_SECRET is not configured"); + + // Event filtering + it("returns 200 with ignored=true for non-reversal events"); + it("processes charge.refunded event"); + it("processes charge.dispute.created event with dispute warning log"); + it("processes payment_intent.payment_failed event"); + + // Payload mapping + it("extracts providerRef from metadata.provider_ref"); + it("falls back to charge ID when metadata missing"); + it("converts Stripe timestamp to YYYY-MM-DD date"); + it("maps failure_code to reversalCode"); + + // Dispute-specific behavior (Foot Gun P4) + it("logs warning for dispute events"); + it("processes dispute as reversal (freeze + flag is ENG-180 scope)"); + + // Integration + it("always returns 200 to prevent retry storms"); +}); +``` + +### Stripe signature test helper: +```typescript +import { createHmac } from "node:crypto"; + +function createTestStripeSignature(body: string, secret: string, timestamp?: number): string { + const ts = timestamp ?? Math.floor(Date.now() / 1000); + const signedPayload = `${ts}.${body}`; + const signature = createHmac("sha256", secret).update(signedPayload).digest("hex"); + return `t=${ts},v1=${signature}`; +} +``` + +--- + +## Test Coverage Requirements (from Acceptance Criteria) + +1. **Both Rotessa and Stripe reversal webhooks handled** — T-009, T-010 +2. **Collection attempt transitions to reversed** — T-008 happy path +3. **Cash ledger reversal entries posted** — T-008 happy path +4. **Idempotent on duplicate webhooks** — T-008 idempotency tests +5. **Out-of-order webhooks handled gracefully** — T-008 out-of-order tests + +## Edge Case Coverage (from Foot Gun Registry) + +| Edge Case | Test | File | +|-----------|------|------| +| Already reversed → idempotent skip | T-008 | handleReversal.test.ts | +| Reversal after payout → clawback | T-008 | handleReversal.test.ts | +| NSF retry storm → dedup | T-008 | handleReversal.test.ts | +| Stripe dispute → flag obligation | T-010 | stripeWebhook.test.ts | +| Out-of-order → status check | T-008 | handleReversal.test.ts | +| Invalid signature → 401 | T-009, T-010 | both | +| Unknown event type → 200 ignore | T-009, T-010 | both | diff --git a/specs/ENG-175/chunks/chunk-03-tests/tasks.md b/specs/ENG-175/chunks/chunk-03-tests/tasks.md new file mode 100644 index 00000000..9e695017 --- /dev/null +++ b/specs/ENG-175/chunks/chunk-03-tests/tasks.md @@ -0,0 +1,7 @@ +# Chunk 03: Tests + +## Tasks + +- [ ] T-008: Core reversal logic tests (handleReversal + processReversal) +- [ ] T-009: Rotessa webhook handler tests +- [ ] T-010: Stripe webhook handler tests diff --git a/specs/ENG-175/chunks/manifest.md b/specs/ENG-175/chunks/manifest.md new file mode 100644 index 00000000..f95198e7 --- /dev/null +++ b/specs/ENG-175/chunks/manifest.md @@ -0,0 +1,16 @@ +# ENG-175: Chunk Manifest + +| Chunk | Label | Tasks | Status | +|-------|-------|-------|--------| +| chunk-01 | Infrastructure + Core Reversal Logic | T-001 → T-004 | pending | +| chunk-02 | Provider Handlers + Router Registration | T-005 → T-007 | pending | +| chunk-03 | Tests | T-008 → T-010 | pending | + +## Execution Order +1. **chunk-01** — Foundation: signature verification, types, internal mutation, shared action handler +2. **chunk-02** — Provider-specific: Rotessa + Stripe httpAction handlers, HTTP router registration +3. **chunk-03** — Tests: core reversal logic, Rotessa-specific, Stripe-specific + +## Dependencies +- chunk-02 depends on chunk-01 (uses verification, types, handleReversal) +- chunk-03 depends on chunk-01 + chunk-02 (tests everything) diff --git a/specs/ENG-175/tasks.md b/specs/ENG-175/tasks.md new file mode 100644 index 00000000..e5bc96ea --- /dev/null +++ b/specs/ENG-175/tasks.md @@ -0,0 +1,17 @@ +# ENG-175: Reversal Webhook Handlers — Master Task List + +## Chunk 1: Infrastructure + Core Reversal Logic +- [x] T-001: Create webhook signature verification utilities (`convex/payments/webhooks/verification.ts`) +- [x] T-002: Create shared reversal types and ReversalWebhookPayload interface (`convex/payments/webhooks/types.ts`) +- [x] T-003: Create internal reversal mutation processReversalCascade (`convex/payments/webhooks/processReversal.ts`) +- [x] T-004: Create shared reversal handler action handlePaymentReversal (`convex/payments/webhooks/handleReversal.ts`) + +## Chunk 2: Provider Handlers + Router Registration +- [x] T-005: Create Rotessa PAD webhook httpAction handler (`convex/payments/webhooks/rotessa.ts`) +- [x] T-006: Create Stripe ACH webhook httpAction handler (`convex/payments/webhooks/stripe.ts`) +- [x] T-007: Register webhook routes in HTTP router (`convex/http.ts`) + +## Chunk 3: Tests +- [x] T-008: Core reversal logic tests (`convex/payments/webhooks/__tests__/handleReversal.test.ts`) +- [x] T-009: Rotessa webhook handler tests (`convex/payments/webhooks/__tests__/rotessaWebhook.test.ts`) +- [x] T-010: Stripe webhook handler tests (`convex/payments/webhooks/__tests__/stripeWebhook.test.ts`)