chore(release): v1.0.6.1 — partial UNIQUE on refunds.hyperswitch_refund_id
Hotfix surfaced by the v1.0.6 refund smoke test. Migration 978's plain
UNIQUE constraint on hyperswitch_refund_id collided on empty strings
— two refunds in the same post-Phase-1 / pre-Phase-2 state (or a
previous Phase-2 failure leaving '') would violate the constraint at
INSERT time on the second attempt, even though the refunds were for
different orders.
* Migration 979_refunds_unique_partial.sql replaces the plain
UNIQUE with a partial index excluding empty and NULL values.
Idempotency for successful refunds is preserved — duplicate
Hyperswitch webhooks land on the same row because the PSP-
assigned refund_id is non-empty.
* No Go code change. The bug was purely in the DB constraint shape.
Smoke test that caught it — 5/5 scenarios re-verified end-to-end:
happy path, idempotent replay (succeeded_at + balance strictly
invariant), PSP error rollback, webhook refund.failed, double-submit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a4d2ffd123
commit
5e3964b989
3 changed files with 68 additions and 1 deletions
37
CHANGELOG.md
37
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
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1.0.6
|
||||
1.0.6.1
|
||||
|
|
|
|||
30
veza-backend-api/migrations/979_refunds_unique_partial.sql
Normal file
30
veza-backend-api/migrations/979_refunds_unique_partial.sql
Normal file
|
|
@ -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 <> '';
|
||||
Loading…
Reference in a new issue