veza/veza-backend-api/migrations/978_refunds_table.sql

36 lines
1.9 KiB
MySQL
Raw Normal View History

feat(backend,marketplace): refund reverse-charge with idempotent webhook Fourth item of the v1.0.6 backlog, and the structuring one — the pre- v1.0.6 RefundOrder wrote `status='refunded'` to the DB and called Hyperswitch synchronously in the same transaction, treating the API ack as terminal confirmation. In reality Hyperswitch returns `pending` and only finalizes via webhook. Customers could see "refunded" in the UI while their bank was still uncredited, and the seller balance stayed credited even on successful refunds. v1.0.6 flow Phase 1 — open a pending refund (short row-locked transaction): * validate permissions + 14-day window + double-submit guard * persist Refund{status=pending} * flip order to `refund_pending` (not `refunded` — that's the webhook's job) Phase 2 — call PSP outside the transaction: * Provider.CreateRefund returns (refund_id, status, err). The refund_id is the unique idempotency key for the webhook. * on PSP error: mark Refund{status=failed}, roll order back to `completed` so the buyer can retry. * on success: persist hyperswitch_refund_id, stay in `pending` even if the sync status is "succeeded". The webhook is the only authoritative signal. (Per customer guidance: "ne jamais flipper à succeeded sur la réponse synchrone du POST".) Phase 3 — webhook drives terminal state: * ProcessRefundWebhook looks up by hyperswitch_refund_id (UNIQUE constraint in the new `refunds` table guarantees idempotency). * terminal-state short-circuit: IsTerminal() returns 200 without mutating anything, so a Hyperswitch retry storm is safe. * on refund.succeeded: flip refund + order to succeeded/refunded, revoke licenses, debit seller balance, mark every SellerTransfer for the order as `reversed`. All within a row-locked tx. * on refund.failed: flip refund to failed, order back to `completed`. Seller-side reconciliation * SellerBalance.DebitSellerBalance was using Postgres-only GREATEST, which silently failed on SQLite tests. Ported to a portable CASE WHEN that clamps at zero in both DBs. * SellerTransfer.Status = "reversed" captures the refund event in the ledger. The actual Stripe Connect Transfers:reversal call is flagged TODO(v1.0.7) — requires wiring through TransferService with connected-account context that the current transfer worker doesn't expose. The internal balance is corrected here so the buyer and seller views match as soon as the PSP confirms; the missing piece is purely the money-movement round-trip at Stripe. Webhook routing * HyperswitchWebhookPayload extended with event_type + refund_id + error_message, with flat and nested (object.*) shapes supported (same tolerance as the existing payment fields). * New IsRefundEvent() discriminator: matches any event_type containing "refund" (case-insensitive) or presence of refund_id. routes_webhooks.go peeks the payload once and dispatches to ProcessRefundWebhook or ProcessPaymentWebhook. * No signature-verification changes — the same HMAC-SHA512 check protects both paths. Handler response * POST /marketplace/orders/:id/refund now returns `{ refund: { id, status: "pending" }, message }` so the UI can surface the in-flight state. A new ErrRefundAlreadyRequested maps to 400 with a "already in progress" message instead of silently creating a duplicate row (the double-submit guard checks order status = `refund_pending` *before* the existing-row check so the error is explicit). Schema * Migration 978_refunds_table.sql adds the `refunds` table with UNIQUE(hyperswitch_refund_id). The uniqueness constraint is the load-bearing idempotency guarantee — a duplicate PSP notification lands on the same DB row, and the webhook handler's FOR UPDATE + IsTerminal() check turns it into a no-op. * hyperswitch_refund_id is nullable (NULL between Phase 1 and Phase 2) so the UNIQUE index ignores rows that haven't been assigned a PSP id yet. Partial refunds * The Provider.CreateRefund signature carries `amount *int64` already (nil = full), but the service call-site passes nil. Full refunds only for v1.0.6 — partial-refund UX needs a product decision and is deferred to v1.0.7. Flagged in the ErrRefund* section. Tests (15 cases, all sqlite-in-memory + httptest-style mock provider) * RefundOrder phase 1 - OpensPendingRefund: pending state, refund_id captured, order → refund_pending, licenses untouched - PSPErrorRollsBack: failed state, order reverts to completed - DoubleRequestRejected: second call returns ErrRefundAlreadyRequested, not a generic ErrOrderNotRefundable - NotCompleted / NoPaymentID / Forbidden / SellerCanRefund - ExpiredRefundWindow / FallbackExpiredNoDeadline * ProcessRefundWebhook - SucceededFinalizesState: refund + order + licenses + seller balance + seller transfer all reconciled in one tx - FailedRollsOrderBack: order returns to completed for retry - IsRefundEventIdempotentOnReplay: second webhook asserts succeeded_at timestamp is *unchanged*, proving the second invocation bailed out on IsTerminal (not re-ran) - UnknownRefundIDReturnsOK: never-issued refund_id → 200 silent (avoids a Hyperswitch retry storm on stale events) - MissingRefundID: explicit 400 error - NonTerminalStatusIgnored: pending/processing leave the row alone * HyperswitchWebhookPayload.IsRefundEvent: 6 dispatcher cases (flat event_type, mixed case, payment event, refund_id alone, empty, nested object.refund_id) Backward compat * hyperswitch.Provider still exposes the old Refund(ctx,...) error method for any call-site that only cared about success/failure. * Old mockRefundPaymentProvider replaced; external mocks need to add CreateRefund — the interface is now (refundID, status, err). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 00:02:57 +00:00
-- Migration 978: Refund state machine for Hyperswitch reverse-charge (v1.0.6)
-- Before v1.0.6, RefundOrder marked orders as `refunded` immediately after
-- POSTing to /refunds — treating Hyperswitch's synchronous API ack as
-- confirmation that the money had moved. In reality the PSP returns `pending`
-- and only confirms via webhook (`refund_succeeded` / `refund_failed`).
-- Customers could see "refunded" in the UI while their bank account still
-- hadn't been credited.
--
-- This migration introduces an auditable refund row that tracks the full
-- lifecycle: pending → (succeeded | failed). Uniqueness on
-- hyperswitch_refund_id is the load-bearing constraint — it guarantees that
-- the webhook handler can safely retry (idempotent 200) because a duplicate
-- PSP notification lands on the same DB row.
CREATE TABLE IF NOT EXISTS public.refunds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES public.orders(id) ON DELETE CASCADE,
initiator_id UUID NOT NULL REFERENCES public.users(id) ON DELETE SET NULL,
hyperswitch_payment_id TEXT NOT NULL,
hyperswitch_refund_id TEXT UNIQUE,
amount_cents BIGINT NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
reason TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
error_message TEXT,
succeeded_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_refunds_order_id ON public.refunds(order_id);
CREATE INDEX IF NOT EXISTS idx_refunds_status ON public.refunds(status);
CREATE INDEX IF NOT EXISTS idx_refunds_hyperswitch_payment_id
ON public.refunds(hyperswitch_payment_id);