10 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c10d73da4e |
feat(subscription): webhook handler closes pending_payment state machine (v1.0.9 item G — Phase 2)
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 4m18s
Security Scan / Secret Scanning (gitleaks) (push) Successful in 1m22s
Veza CI / Frontend (Web) (push) Failing after 19m45s
E2E Playwright / e2e (full) (push) Failing after 20m45s
Veza CI / Backend (Go) (push) Failing after 22m38s
Veza CI / Notify on failure (push) Successful in 7s
Phase 1 (commit
|
||
|
|
2a96766ae3 |
feat(subscription): pending_payment state machine + mandatory provider (v1.0.9 item G — Phase 1)
First instalment of Item G from docs/audit-2026-04/v107-plan.md §G.
This commit lands the state machine + create-flow change. Phase 2
(webhook handler + recovery endpoint + reconciler sweep) follows.
What changes :
- **`models.go`** — adds `StatusPendingPayment` to the
SubscriptionStatus enum. Free-text VARCHAR(30) so no DDL needed
for the value itself; Phase 2's reconciler index lives in
migration 986 (additive, partial index on `created_at` WHERE
status='pending_payment').
- **`service.go`** — `PaymentProvider.CreateSubscriptionPayment`
interface gains an `idempotencyKey string` parameter, mirroring
the marketplace.refundProvider contract added in v1.0.7 item D.
Callers pass the new subscription row's UUID so a retried HTTP
request collapses to one PSP charge instead of duplicating it.
- **`createNewSubscription`** — refactored state machine :
* Free plan → StatusActive (unchanged, in subscribeToFreePlan).
* Paid plan, trial available, first-time user → StatusTrialing,
no PSP call (no invoice either — Phase 2 will create the
first paid invoice on trial expiry).
* Paid plan, no trial / repeat user → **StatusPendingPayment**
+ invoice + PSP CreateSubscriptionPayment with idempotency
key = subscription.ID.String(). Webhook
subscription.payment_succeeded (Phase 2) flips to active;
subscription.payment_failed flips to expired.
- **`if s.paymentProvider != nil` short-circuit removed**. Paid
plans now require a configured PaymentProvider — without one,
`createNewSubscription` returns ErrPaymentProviderRequired. The
handler maps this to HTTP 503 "Payment provider not configured —
paid plans temporarily unavailable", surfacing env misconfig to
ops instead of silently giving away paid plans (the v1.0.6.2
fantôme bug class).
- **`GetUserSubscription` query unchanged** — already filters on
`status IN ('active','trialing')`, so pending_payment rows
correctly read as "no active subscription" for feature-gate
purposes. The v1.0.6.2 hasEffectivePayment filter is kept as
defence-in-depth for legacy rows.
- **`hyperswitch.Provider`** — implements
`subscription.PaymentProvider` by delegating to the existing
`CreatePaymentSimple`. Compile-time interface assertion added
(`var _ subscription.PaymentProvider = (*Provider)(nil)`).
- **`routes_subscription.go`** — wires the Hyperswitch provider
into `subscription.NewService` when HyperswitchEnabled +
HyperswitchAPIKey + HyperswitchURL are all set. Without those,
the service falls back to no-provider mode (paid subscribes
return 503).
- **Tests** : new TestSubscribe_PendingPaymentStateMachine in
gate_test.go covers all five visible outcomes (free / paid+
provider / paid+no-provider / first-trial / repeat-trial) with a
fakePaymentProvider that records calls. Asserts on idempotency
key = subscription.ID.String(), PSP call counts, and the
Subscribe response shape (client_secret + payment_id surfaced).
5/5 green, sqlite :memory:.
Phase 2 backlog (next session) :
- `ProcessSubscriptionWebhook(ctx, payload)` — flip pending_payment
→ active on success / expired on failure, idempotent against
replays.
- Recovery endpoint `POST /api/v1/subscriptions/complete/:id` —
return the existing client_secret to resume a stalled flow.
- Reconciliation sweep for rows stuck in pending_payment past the
webhook-arrival window (uses the new partial index from
migration 986).
- Distribution.checkEligibility explicit pending_payment branch
(today it's already handled implicitly via the active/trialing
filter).
- E2E @critical : POST /subscribe → POST /distribution/submit
asserts 403 with "complete payment" until webhook fires.
Backward compat : clients on the previous flow that called
/subscribe expecting an immediately-active row will now see
status=pending_payment + a client_secret. They must drive the PSP
confirm step before the row is granted feature access. The
v1.0.6.2 voided_subscriptions cleanup migration (980) handles
pre-existing fantôme rows.
go build ./... clean. Subscription + handlers test suites green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
6773f66dd3 |
fix(webhooks): bump MaxWebhookPayloadBytes 64KB → 256KB — v1.0.7 pre-rc1 (task #44)
Closes task #44 ahead of v1.0.7-rc1 tag. Dispute-class webhooks (axis-1 P1.6, v1.0.8 scope) may carry metadata beyond the typical 1-5 KB event size — a 64KB cap created a non-zero risk of silent drops that exactly the wrong class of event to lose. 256KB gives 10x headroom above the inflated-dispute ceiling while staying tightly bounded against log-spam DoS: sustained ceiling at the rate-limit floor is ~25MB/s, cleaned daily. Rationale documented in the comment above the const so future readers see the reasoning before the number. The rate limit remains the primary DoS defense; this cap is defense in depth. No live Hyperswitch docs verification (no internet access in this session) — decision based on typical PSP webhook shapes + user's explicit flag that losing a legit dispute = weekend lost. Task #44 closed with that caveat noted; a proper docs review can re-tune if observed traffic shows the 256KB ceiling is also too aggressive (unlikely). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7e180a2c08 |
feat(workers): hyperswitch reconciliation sweep for stuck pending states — v1.0.7 item C
New ReconcileHyperswitchWorker sweeps for pending orders and refunds
whose terminal webhook never arrived. Pulls live PSP state for each
stuck row and synthesises a webhook payload to feed the normal
ProcessPaymentWebhook / ProcessRefundWebhook dispatcher. The existing
terminal-state guards on those handlers make reconciliation
idempotent against real webhooks — a late webhook after the reconciler
resolved the row is a no-op.
Three stuck-state classes covered:
1. Stuck orders (pending > 30m, non-empty payment_id) → GetPaymentStatus
+ synthetic payment.<status> webhook.
2. Stuck refunds with PSP id (pending > 30m, non-empty
hyperswitch_refund_id) → GetRefundStatus + synthetic
refund.<status> webhook (error_message forwarded).
3. Orphan refunds (pending > 5m, EMPTY hyperswitch_refund_id) →
mark failed + roll order back to completed + log ERROR. This
is the "we crashed between Phase 1 and Phase 2 of RefundOrder"
case, operator-attention territory.
New interfaces:
* marketplace.HyperswitchReadClient — read-only PSP surface the
worker depends on (GetPaymentStatus, GetRefundStatus). The
worker never calls CreatePayment / CreateRefund.
* hyperswitch.Client.GetRefund + RefundStatus struct added.
* hyperswitch.Provider gains GetRefundStatus + GetPaymentStatus
pass-throughs that satisfy the marketplace interface.
Configuration (all env-var tunable with sensible defaults):
* RECONCILE_WORKER_ENABLED=true
* RECONCILE_INTERVAL=1h (ops can drop to 5m during incident
response without a code change)
* RECONCILE_ORDER_STUCK_AFTER=30m
* RECONCILE_REFUND_STUCK_AFTER=30m
* RECONCILE_REFUND_ORPHAN_AFTER=5m (shorter because "app crashed"
is a different signal from "network hiccup")
Operational details:
* Batch limit 50 rows per phase per tick so a 10k-row backlog
doesn't hammer Hyperswitch. Next tick picks up the rest.
* PSP read errors leave the row untouched — next tick retries.
Reconciliation is always safe to replay.
* Structured log on every action so `grep reconcile` tells the
ops story: which order/refund got synced, against what status,
how long it was stuck.
* Worker wired in cmd/api/main.go, gated on
HyperswitchEnabled + HyperswitchAPIKey. Graceful shutdown
registered.
* RunOnce exposed as public API for ad-hoc ops trigger during
incident response.
Tests — 10 cases, all green (sqlite :memory:):
* TestReconcile_StuckOrder_SyncsViaSyntheticWebhook
* TestReconcile_RecentOrder_NotTouched
* TestReconcile_CompletedOrder_NotTouched
* TestReconcile_OrderWithEmptyPaymentID_NotTouched
* TestReconcile_PSPReadErrorLeavesRowIntact
* TestReconcile_OrphanRefund_AutoFails_OrderRollsBack
* TestReconcile_RecentOrphanRefund_NotTouched
* TestReconcile_StuckRefund_SyncsViaSyntheticWebhook
* TestReconcile_StuckRefund_FailureStatus_PassesErrorMessage
* TestReconcile_AllTerminalStates_NoOp
CHANGELOG v1.0.7-rc1 updated with the full item C section between D
and the existing E block, matching the order convention (ship order:
A → D → B → E → C, CHANGELOG order follows).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3c4d0148be |
feat(webhooks): persist raw hyperswitch payloads to audit log — v1.0.7 item E
Every POST /webhooks/hyperswitch delivery now writes a row to
`hyperswitch_webhook_log` regardless of signature-valid or
processing outcome. Captures both legitimate deliveries and attack
probes — a forensics query now has the actual bytes to read, not
just a "webhook rejected" log line. Disputes (axis-1 P1.6) ride
along: the log captures dispute.* events alongside payment and
refund events, ready for when disputes get a handler.
Table shape (migration 984):
* payload TEXT — readable in psql, invalid UTF-8 replaced with
empty (forensics value is in headers + ip + timing for those
attacks, not the binary body).
* signature_valid BOOLEAN + partial index for "show me attack
attempts" being instantaneous.
* processing_result TEXT — 'ok' / 'error: <msg>' /
'signature_invalid' / 'skipped'. Matches the P1.5 action
semantic exactly.
* source_ip, user_agent, request_id — forensics essentials.
request_id is captured from Hyperswitch's X-Request-Id header
when present, else a server-side UUID so every row correlates
to VEZA's structured logs.
* event_type — best-effort extract from the JSON payload, NULL
on malformed input.
Hardening:
* 64KB body cap via io.LimitReader rejects oversize with 413
before any INSERT — prevents log-spam DoS.
* Single INSERT per delivery with final state; no two-phase
update race on signature-failure path. signature_invalid and
processing-error rows both land.
* DB persistence failures are logged but swallowed — the
endpoint's contract is to ack Hyperswitch, not perfect audit.
Retention sweep:
* CleanupHyperswitchWebhookLog in internal/jobs, daily tick,
batched DELETE (10k rows + 100ms pause) so a large backlog
doesn't lock the table.
* HYPERSWITCH_WEBHOOK_LOG_RETENTION_DAYS (default 90).
* Same goroutine-ticker pattern as ScheduleOrphanTracksCleanup.
* Wired in cmd/api/main.go alongside the existing cleanup jobs.
Tests: 5 in webhook_log_test.go (persistence, request_id auto-gen,
invalid-JSON leaves event_type empty, invalid-signature capture,
extractEventType 5 sub-cases) + 4 in cleanup_hyperswitch_webhook_
log_test.go (deletes-older-than, noop, default-on-zero,
context-cancel). Migration 984 applied cleanly to local Postgres;
all indexes present.
Also (v107-plan.md):
* Item G acceptance gains an explicit Idempotency-Key threading
requirement with an empty-key loud-fail test — "literally
copy-paste D's 4-line test skeleton". Closes the risk that
item G silently reopens the HTTP-retry duplicate-charge
exposure D closed.
Out of scope for E (noted in CHANGELOG):
* Rate limit on the endpoint — pre-existing middleware covers
it at the router level; adding a per-endpoint limit is
separate scope.
* Readable-payload SQL view — deferred, the TEXT column is
already human-readable; a convenience view is a nice-to-have
not a ship-blocker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3cd82ba5be |
fix(hyperswitch): idempotency-key on create-payment and create-refund — v1.0.7 item D
Every outbound POST /payments and POST /refunds from the Hyperswitch
client now carries an Idempotency-Key HTTP header. Key values are
explicit parameters at every call site — no context-carrier magic,
no auto-generation. An empty key is a loud error from the client
(not silent header omission) so a future new call site that forgets
to supply one fails immediately, not months later under an obscure
replay scenario.
Key choices, both stable across HTTP retries of the same logical
call:
* CreatePayment → order.ID.String() (GORM BeforeCreate populates
order.ID before the PSP call in ConfirmOrder).
* CreateRefund → pendingRefund.ID.String() (populated by the
Phase 1 tx.Create in RefundOrder, available for the Phase 2 PSP
call).
Scope note (reproduced here for the next reader who grep-s the
commit log for "Idempotency-Key"):
Idempotency-Key covers HTTP-transport retry (TLS reconnect,
proxy retry, DNS flap) within a single CreatePayment /
CreateRefund invocation. It does NOT cover application-level
replay (user double-click, form double-submit, retry after crash
before DB write). That class of bug requires state-machine
preconditions on VEZA side — already addressed by the order
state machine + the handler-level guards on POST
/api/v1/payments (for payments) and the partial UNIQUE on
`refunds.hyperswitch_refund_id` landed in v1.0.6.1 (for refunds).
Hyperswitch TTL on Idempotency-Key: typically 24h-7d server-side
(verify against current PSP docs). Beyond TTL, a retry with the
same key is treated as a new request. Not a concern at current
volumes; document if retry logic ever extends beyond 1 hour.
Explicitly out of scope: item D does NOT add application-level
retry logic. The current "try once, fail loudly" behavior on PSP
errors is preserved. Adding retries is a separate design exercise
(backoff, max attempts, circuit breaker) not part of this commit.
Interfaces changed:
* hyperswitch.Client.CreatePayment(ctx, idempotencyKey, ...)
* hyperswitch.Client.CreatePaymentSimple(...) convenience wrapper
* hyperswitch.Client.CreateRefund(ctx, idempotencyKey, ...)
* hyperswitch.Provider.CreatePayment threads through
* hyperswitch.Provider.CreateRefund threads through
* marketplace.PaymentProvider interface — first param after ctx
* marketplace.refundProvider interface — first param after ctx
Removed:
* hyperswitch.Provider.Refund (zero callers, superseded by
CreateRefund which returns (refund_id, status, err) and is the
only method marketplace's refundProvider cares about).
Tests:
* Two new httptest.Server-backed tests (client_test.go) pin the
Idempotency-Key header value for CreatePayment and CreateRefund.
* Two new empty-key tests confirm the client errors rather than
silently sending no header.
* TestRefundOrder_OpensPendingRefund gains an assertion that
f.provider.lastIdempotencyKey == refund.ID.String() — if a
future refactor threads the key from somewhere else (paymentID,
uuid.New() per call, etc.) the test fails loudly.
* Four pre-existing test mocks updated for the new signature
(mockRefundPaymentProvider in marketplace, mockPaymentProvider
in tests/integration and tests/contract, mockRefundPayment
Provider in tests/integration/refund_flow).
Subscription's CreateSubscriptionPayment interface declares its own
shape and has no live Hyperswitch-backed implementation today —
v1.0.6.2 noted this as the payment-gate bypass surface, v1.0.7
item G will ship the real provider. When that lands, item G's
implementation threads the idempotency key through in the same
pattern (documented in v107-plan.md item G acceptance).
CHANGELOG v1.0.7-rc1 entry updated with the full item D scope note
and the "out of scope: retries" caveat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
92cf6d6f76 |
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>
|
||
|
|
2ed2bb9dcf | v0.9.4 | ||
|
|
51373b653f | feat(hyperswitch): add CreateRefund to client | ||
|
|
92f432fb9e | chore: consolidate pending changes (Hyperswitch, PostCard, dashboard, stream server, etc.) |