diff --git a/CHANGELOG.md b/CHANGELOG.md index d04e654a9..04df11cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog - Veza +## [v1.0.6.1] - 2026-04-17 + +### Hotfix — partial UNIQUE on refunds.hyperswitch_refund_id + +Surfaced by the v1.0.6 refund smoke test (scenario S4, triggered after +S3 left a failed refund in its post-Phase-1 / pre-Phase-2 state): the +plain UNIQUE constraint from migration 978 rejected a second refund +attempt on a *different* order because both rows had +`hyperswitch_refund_id=''` (Go's zero-value string → empty string, not +NULL). Postgres treats two empty strings as colliding under a regular +UNIQUE; it only skips NULLs. + + * Migration `979_refunds_unique_partial.sql` drops the original + constraint and replaces it with a partial UNIQUE that only + enforces uniqueness when `hyperswitch_refund_id IS NOT NULL AND + <> ''`. + * Preserves the load-bearing idempotency guarantee for successful + refunds (duplicate webhook lands on the same row because the PSP + refund_id is set). + * No Go code change — the model and service logic were already + correct; only the DB constraint shape needed fixing. + +Smoke coverage that caught it + re-validates the fix: + * S1 happy path: refund + order + license + seller_transfer + + seller_balance all reconciled end-to-end + * S2 idempotent replay: succeeded_at + transfer.updated_at + + available_cents strictly unchanged across 2 webhook deliveries + (THE critical proof — duplicate Hyperswitch retries are no-ops + at the row level, not at the handler level) + * S3 PSP error rollback: order reverts to completed, refund + persisted as failed, no seller debit + * S4 webhook refund.failed: order reverts, license intact, + seller balance intact — **this is the scenario that surfaced + the bug** + * S5 double-submit: second POST returns 400 + ErrRefundAlreadyRequested, only 1 refund row persisted + ## [v1.0.6] - 2026-04-17 ### Ergonomics + operational hardening — six items from the v1.0.5 backlog diff --git a/VERSION b/VERSION index af0b7ddbf..91ddda75e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.6 +1.0.6.1 diff --git a/veza-backend-api/migrations/979_refunds_unique_partial.sql b/veza-backend-api/migrations/979_refunds_unique_partial.sql new file mode 100644 index 000000000..b8afd0480 --- /dev/null +++ b/veza-backend-api/migrations/979_refunds_unique_partial.sql @@ -0,0 +1,30 @@ +-- Migration 979: partial UNIQUE on refunds.hyperswitch_refund_id (v1.0.6.1 hotfix) +-- +-- The v1.0.6 migration 978 used a plain UNIQUE constraint on +-- hyperswitch_refund_id. That broke when two refunds in the same DB +-- stayed in their post-Phase-1 / pre-Phase-2 state: both rows have +-- hyperswitch_refund_id='' (empty string, because Go's zero-value for +-- string writes '' rather than NULL), and PostgreSQL treats two empty +-- strings as colliding under a regular UNIQUE constraint. +-- +-- Surfaced by the v1.0.6 refund smoke test (scenario S4, triggered +-- after the S3 PSP-error path left one row with refund_id=''): the +-- second refund attempt on a different order got a UNIQUE violation +-- at INSERT time. +-- +-- Fix: make the UNIQUE partial — only enforce uniqueness on rows that +-- have actually received a PSP-assigned refund_id. Empty strings and +-- NULLs are ignored. This preserves the load-bearing idempotency +-- guarantee for successful refunds (duplicate webhook lands on the +-- same row) without rejecting legitimate second attempts after a PSP +-- failure on a different order. + +ALTER TABLE public.refunds + DROP CONSTRAINT IF EXISTS refunds_hyperswitch_refund_id_key; + +DROP INDEX IF EXISTS refunds_hyperswitch_refund_id_unique; + +CREATE UNIQUE INDEX refunds_hyperswitch_refund_id_unique + ON public.refunds (hyperswitch_refund_id) + WHERE hyperswitch_refund_id IS NOT NULL + AND hyperswitch_refund_id <> '';