veza/veza-backend-api/internal/api/routes_webhooks.go

213 lines
8.2 KiB
Go
Raw Normal View History

package api
import (
"context"
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
"encoding/json"
"io"
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>
2026-04-18 00:44:58 +00:00
"net/http"
"unicode/utf8"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"veza-backend-api/internal/core/marketplace"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/response"
"veza-backend-api/internal/services"
"veza-backend-api/internal/services/hyperswitch"
"veza-backend-api/internal/workers"
)
// setupWebhookRoutes configure les routes pour les webhooks
func (r *APIRouter) setupWebhookRoutes(router *gin.RouterGroup) {
webhookService := services.NewWebhookService(r.db.GormDB, r.logger, r.config.JWTSecret)
webhookWorker := workers.NewWebhookWorker(
r.db.GormDB,
webhookService,
r.logger,
100,
5,
3,
)
go webhookWorker.Start(context.Background())
webhookHandler := handlers.NewWebhookHandler(webhookService, webhookWorker, r.logger)
webhooks := router.Group("/webhooks")
{
// Hyperswitch payment webhook - PUBLIC (no auth), called by Hyperswitch
webhooks.POST("/hyperswitch", r.hyperswitchWebhookHandler())
if r.config.AuthMiddleware != nil {
protected := webhooks.Group("")
protected.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(protected)
protected.POST("", webhookHandler.RegisterWebhook())
protected.GET("", webhookHandler.ListWebhooks())
protected.DELETE("/:id", webhookHandler.DeleteWebhook())
protected.GET("/stats", webhookHandler.GetWebhookStats())
protected.POST("/:id/test", webhookHandler.TestWebhook())
protected.POST("/:id/regenerate-key", webhookHandler.RegenerateAPIKey())
}
}
}
// hyperswitchWebhookHandler handles POST /webhooks/hyperswitch from Hyperswitch.
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>
2026-04-18 00:44:58 +00:00
//
// v1.0.7 item E: every delivery is persisted to hyperswitch_webhook_log,
// including deliveries that fail signature verification or processing
// — the log is the forensic audit trail for both legitimate activity
// and attack probes. A 64KB body cap protects the table from log-spam
// DoS (rejected with 413 before any INSERT).
func (r *APIRouter) hyperswitchWebhookHandler() gin.HandlerFunc {
marketService := r.getMarketplaceService()
webhookSecret := r.config.HyperswitchWebhookSecret
return func(c *gin.Context) {
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>
2026-04-18 00:44:58 +00:00
// v1.0.7 item E: cap the body read at MaxWebhookPayloadBytes+1
// so we can detect oversize without loading arbitrary megabytes
// into memory. An attacker posting 10MB gets 413 back; the log
// table never sees the row.
limited := io.LimitReader(c.Request.Body, hyperswitch.MaxWebhookPayloadBytes+1)
body, err := io.ReadAll(limited)
if err != nil {
r.logger.Error("Hyperswitch webhook: failed to read body", zap.Error(err))
response.InternalServerError(c, "Failed to read webhook body")
return
}
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>
2026-04-18 00:44:58 +00:00
if len(body) > hyperswitch.MaxWebhookPayloadBytes {
r.logger.Warn("Hyperswitch webhook: payload exceeds size cap, rejecting",
zap.Int("size_bytes", len(body)),
zap.Int("max_bytes", hyperswitch.MaxWebhookPayloadBytes))
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{
"error": "payload too large",
"limit": hyperswitch.MaxWebhookPayloadBytes,
})
return
}
// Payload as TEXT: Hyperswitch sends JSON, but a probe/attack
// might send anything. Reject invalid UTF-8 outright — the
// attack-forensics value is in headers + ip + timing, not the
// binary body.
payload := string(body)
if !utf8.ValidString(payload) {
r.logger.Warn("Hyperswitch webhook: payload contains invalid UTF-8, replacing with empty for log")
payload = ""
}
// Collect forensics context regardless of outcome.
requestID := c.GetHeader("X-Request-Id")
if requestID == "" {
requestID = c.GetHeader("X-Request-ID")
}
sigHeader := c.GetHeader("x-webhook-signature-512")
logRow := &hyperswitch.WebhookLog{
Payload: payload,
SignatureHeader: sigHeader,
SourceIP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
RequestID: requestID, // filled with UUID by LogWebhook if empty
}
// Signature verification. Regardless of outcome, the row lands
// with signature_valid set accordingly.
if webhookSecret == "" {
r.logger.Error("Hyperswitch webhook: HYPERSWITCH_WEBHOOK_SECRET not configured, rejecting webhook")
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>
2026-04-18 00:44:58 +00:00
logRow.SignatureValid = false
logRow.ProcessingResult = "error: webhook secret not configured"
r.persistWebhookLog(c.Request.Context(), logRow)
response.InternalServerError(c, "Webhook secret not configured")
return
}
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>
2026-04-18 00:44:58 +00:00
if err := hyperswitch.VerifyWebhookSignature(body, sigHeader, webhookSecret); err != nil {
r.logger.Warn("Hyperswitch webhook: signature verification failed",
zap.String("source_ip", logRow.SourceIP),
zap.Error(err))
logRow.SignatureValid = false
logRow.ProcessingResult = "signature_invalid"
r.persistWebhookLog(c.Request.Context(), logRow)
response.Unauthorized(c, "Invalid webhook signature")
return
}
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>
2026-04-18 00:44:58 +00:00
logRow.SignatureValid = true
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
// v1.0.6: dispatch refund events to ProcessRefundWebhook. Payment
// events keep flowing through ProcessPaymentWebhook unchanged.
var peek marketplace.HyperswitchWebhookPayload
if err := json.Unmarshal(body, &peek); err != nil {
r.logger.Warn("Hyperswitch webhook: payload not JSON — dispatching as payment",
zap.Error(err))
}
var procErr error
if peek.IsRefundEvent() {
procErr = marketService.ProcessRefundWebhook(c.Request.Context(), body)
} else {
procErr = marketService.ProcessPaymentWebhook(c.Request.Context(), body)
}
if procErr != nil {
r.logger.Error("Hyperswitch webhook: processing failed",
zap.Bool("is_refund_event", peek.IsRefundEvent()),
zap.Error(procErr))
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>
2026-04-18 00:44:58 +00:00
logRow.ProcessingResult = "error: " + procErr.Error()
r.persistWebhookLog(c.Request.Context(), logRow)
response.InternalServerError(c, "Webhook processing failed")
return
}
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>
2026-04-18 00:44:58 +00:00
logRow.ProcessingResult = "ok"
r.persistWebhookLog(c.Request.Context(), logRow)
response.Success(c, gin.H{"received": true})
}
}
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>
2026-04-18 00:44:58 +00:00
// persistWebhookLog writes the audit row. Any DB failure is logged and
// swallowed — the endpoint's primary contract is to ack Hyperswitch,
// not to persist audit perfectly. A persistent storage failure is
// operationally visible via this log line.
func (r *APIRouter) persistWebhookLog(ctx context.Context, row *hyperswitch.WebhookLog) {
if r.db == nil || r.db.GormDB == nil {
return
}
if err := hyperswitch.LogWebhook(ctx, r.db.GormDB, row); err != nil {
r.logger.Error("Hyperswitch webhook: failed to persist audit log row",
zap.String("request_id", row.RequestID),
zap.Bool("signature_valid", row.SignatureValid),
zap.Error(err))
}
}
// getMarketplaceService returns the marketplace service with Hyperswitch wiring.
// Used by webhook handler; mirrors setupMarketplaceRoutes service creation.
func (r *APIRouter) getMarketplaceService() marketplace.MarketplaceService {
if r.config.MarketplaceServiceOverride != nil {
return r.config.MarketplaceServiceOverride.(marketplace.MarketplaceService)
}
uploadDir := r.config.UploadDir
if uploadDir == "" {
uploadDir = "uploads/tracks"
}
storageService := services.NewTrackStorageService(uploadDir, false, r.logger)
opts := []marketplace.ServiceOption{}
if r.config.HyperswitchEnabled && r.config.HyperswitchAPIKey != "" && r.config.HyperswitchURL != "" {
hsClient := hyperswitch.NewClient(r.config.HyperswitchURL, r.config.HyperswitchAPIKey)
hsProvider := hyperswitch.NewProvider(hsClient)
opts = append(opts,
marketplace.WithPaymentProvider(hsProvider),
marketplace.WithHyperswitchConfig(true, r.config.CheckoutSuccessURL),
)
}
if r.config.StripeConnectEnabled && r.config.StripeConnectSecretKey != "" {
scs := services.NewStripeConnectService(r.db.GormDB, r.config.StripeConnectSecretKey, r.logger)
opts = append(opts, marketplace.WithTransferService(scs, r.config.PlatformFeeRate))
}
feat(subscription): webhook handler closes pending_payment state machine (v1.0.9 item G — Phase 2) Phase 1 (commit 2a96766a) opened the pending_payment status: a paid-plan subscribe path creates a UserSubscription row in pending_payment + subscription_invoices row carrying the Hyperswitch payment_id, then hands the client_secret back to the SPA. Phase 2 lands the webhook side: the PSP-driven state transition that closes the loop. State machine: - pending_payment + status=succeeded → invoice paid (paid_at=now), sub active - pending_payment + status=failed → invoice failed, sub expired - already terminal → idempotent no-op (paid_at NOT bumped) - payment_id not in subscription_invoices → marketplace.ErrNotASubscription (caller falls through to the order webhook flow) The processor only flips a subscription out of pending_payment. Rows that have already transitioned (concurrent flow, manual admin action, plan upgrade) are left alone — the invoice still gets the terminal status update so the audit trail stays consistent. New surface: - hyperswitch.SubscriptionWebhookProcessor — the actual handler. Reads subscription_invoices by hyperswitch_payment_id, looks up the parent user_subscriptions row, applies the transition in a single tx. - hyperswitch.IsSubscriptionEventType — exported helper for callers that want to skip the DB hit on clearly non-subscription events. - marketplace.SubscriptionWebhookHandler (interface) + marketplace.ErrNotASubscription (sentinel) — keeps marketplace from importing the hyperswitch package while still allowing ProcessPaymentWebhook to dispatch typed. - marketplace.WithSubscriptionWebhookHandler (option) — wired by routes_webhooks.getMarketplaceService so the prod webhook handler routes subscription events instead of swallowing them as "order not found". Dispatcher in ProcessPaymentWebhook: try subscription first, fall through to the order flow on ErrNotASubscription. Order events are unchanged. Tests (4, sqlite in-memory, all green): - Succeeded: pending_payment → active+paid, paid_at set - Failed: pending_payment → expired+failed - Idempotent replay: second succeeded webhook is a no-op, paid_at NOT re-stamped (locks down Hyperswitch's at-least-once delivery contract) - Unknown payment_id: returns marketplace.ErrNotASubscription so the dispatcher falls through to ProcessPaymentWebhook's order flow Removes the v1.0.6.2 "active row without PSP linkage" fantôme pattern that hasEffectivePayment had to filter retroactively — the Phase 1 + Phase 2 pair is now the canonical paid-plan creation path. E2E + recovery endpoint (POST /api/v1/subscriptions/complete/:id) + distribution gate land in Phase 3 (Day 3 of ROADMAP_V1.0_LAUNCH.md). SKIP_TESTS=1 rationale: this commit is backend-only (Go); the husky pre-commit hook only runs frontend typecheck/lint/vitest. Backend tests verified manually: $ go test -short -count=1 ./internal/services/hyperswitch/... ./internal/core/marketplace/... ./internal/core/subscription/... ok veza-backend-api/internal/services/hyperswitch ok veza-backend-api/internal/core/marketplace ok veza-backend-api/internal/core/subscription Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 03:39:59 +00:00
// v1.0.9 item G Phase 2: subscription webhook dispatcher. The
// processor reads/writes subscription_invoices + user_subscriptions
// directly via GORM; injecting it here makes ProcessPaymentWebhook
// route subscription events instead of swallowing them as "order not
// found".
subscriptionProcessor := hyperswitch.NewSubscriptionWebhookProcessor(r.db.GormDB, r.logger)
opts = append(opts, marketplace.WithSubscriptionWebhookHandler(subscriptionProcessor))
return marketplace.NewService(r.db.GormDB, r.logger, storageService, opts...)
}