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>
84 lines
3.6 KiB
Go
84 lines
3.6 KiB
Go
package hyperswitch
|
|
|
|
import (
|
|
"context"
|
|
|
|
"veza-backend-api/internal/core/marketplace"
|
|
"veza-backend-api/internal/core/subscription"
|
|
)
|
|
|
|
// Ensure Provider implements marketplace.PaymentProvider
|
|
var _ marketplace.PaymentProvider = (*Provider)(nil)
|
|
|
|
// Ensure Provider implements subscription.PaymentProvider (v1.0.9 item G).
|
|
var _ subscription.PaymentProvider = (*Provider)(nil)
|
|
|
|
// Provider adapts the Hyperswitch client to marketplace.PaymentProvider.
|
|
type Provider struct {
|
|
client *Client
|
|
}
|
|
|
|
// NewProvider creates a new Hyperswitch payment provider.
|
|
func NewProvider(client *Client) *Provider {
|
|
return &Provider{client: client}
|
|
}
|
|
|
|
// CreatePayment creates a payment in Hyperswitch.
|
|
func (p *Provider) CreatePayment(ctx context.Context, idempotencyKey string, amount int64, currency, orderID, returnURL string, metadata map[string]string) (paymentID, clientSecret string, err error) {
|
|
return p.client.CreatePaymentSimple(ctx, idempotencyKey, amount, currency, orderID, returnURL, metadata)
|
|
}
|
|
|
|
// GetPayment retrieves payment status from Hyperswitch.
|
|
func (p *Provider) GetPayment(ctx context.Context, paymentID string) (string, error) {
|
|
return p.client.GetPaymentStatus(ctx, paymentID)
|
|
}
|
|
|
|
// GetPaymentStatus is the ReconcileHyperswitchWorker-facing name for
|
|
// GetPayment — keeps the marketplace-side interface named by intent
|
|
// ("status") rather than by endpoint. Returns the PSP-reported
|
|
// payment status string.
|
|
func (p *Provider) GetPaymentStatus(ctx context.Context, paymentID string) (string, error) {
|
|
return p.client.GetPaymentStatus(ctx, paymentID)
|
|
}
|
|
|
|
// GetRefundStatus retrieves refund status + error_message from
|
|
// Hyperswitch (v1.0.7 item C — reconciliation worker). Returns
|
|
// (status, errorMessage, err). Used to synthesise a webhook payload
|
|
// when the sweep finds a stuck pending refund.
|
|
func (p *Provider) GetRefundStatus(ctx context.Context, refundID string) (string, string, error) {
|
|
r, err := p.client.GetRefund(ctx, refundID)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return r.Status, r.ErrorMessage, nil
|
|
}
|
|
|
|
// CreateRefund creates a refund in Hyperswitch and returns the PSP refund
|
|
// id and synchronous status (v1.0.6, v1.0.7 item D). The marketplace
|
|
// service persists the refund_id as the idempotency key for the webhook
|
|
// handler — every later refund.* notification can be correlated back to
|
|
// the pending Refund row via `hyperswitch_refund_id`.
|
|
//
|
|
// idempotencyKey (v1.0.7 item D): passed through to the
|
|
// `Idempotency-Key` HTTP header. Marketplace passes the pending Refund
|
|
// row's UUID — stable across HTTP retries of the same logical call.
|
|
//
|
|
// Matches marketplace.refundProvider interface.
|
|
func (p *Provider) CreateRefund(ctx context.Context, idempotencyKey, paymentID string, amount *int64, reason string) (string, string, error) {
|
|
resp, err := p.client.CreateRefund(ctx, idempotencyKey, paymentID, amount, reason)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return resp.RefundID, resp.Status, nil
|
|
}
|
|
|
|
// CreateSubscriptionPayment opens a payment intent for a subscription
|
|
// invoice (v1.0.9 item G). Same wire shape as CreatePayment — the
|
|
// difference is semantic: the metadata carries `subscription_id` and
|
|
// the idempotency key is the new subscription row's UUID, so the row
|
|
// can be correlated back from the subsequent webhook.
|
|
//
|
|
// Implements subscription.PaymentProvider.
|
|
func (p *Provider) CreateSubscriptionPayment(ctx context.Context, idempotencyKey string, amountCents int, currency, subscriptionID, returnURL string, metadata map[string]string) (paymentID, clientSecret string, err error) {
|
|
return p.client.CreatePaymentSimple(ctx, idempotencyKey, int64(amountCents), currency, subscriptionID, returnURL, metadata)
|
|
}
|