Compare commits
2 commits
ed1bb4084a
...
1de016dfeb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1de016dfeb | ||
|
|
2a96766ae3 |
9 changed files with 385 additions and 49 deletions
49
.github/workflows/e2e.yml
vendored
49
.github/workflows/e2e.yml
vendored
|
|
@ -57,14 +57,16 @@ jobs:
|
|||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
redis:
|
||||
# Match docker-compose.yml (REM-023: password required even
|
||||
# in dev). Default redis:7-alpine entrypoint reads
|
||||
# REDIS_ARGS, so requirepass works without a `command:`.
|
||||
# No-auth redis for CI: act_runner services don't support a
|
||||
# `command:` field, and the redis:7-alpine entrypoint does
|
||||
# NOT read REDIS_ARGS (verified empirically) — so passing
|
||||
# --requirepass via env doesn't work. The dev/prod password
|
||||
# policy (REM-023) is enforced via docker-compose.yml only;
|
||||
# the CI service network is ephemeral and isolated, so
|
||||
# dropping auth here is acceptable.
|
||||
image: redis:7-alpine
|
||||
env:
|
||||
REDIS_ARGS: "--requirepass devpassword"
|
||||
options: >-
|
||||
--health-cmd "redis-cli -a devpassword ping"
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
|
@ -82,7 +84,7 @@ jobs:
|
|||
# Service hostnames + standard ports — no host-port mapping needed.
|
||||
env:
|
||||
DATABASE_URL: postgresql://veza:${{ secrets.E2E_DB_PASSWORD || 'devpassword' }}@postgres:5432/veza?sslmode=disable
|
||||
REDIS_URL: redis://:devpassword@redis:6379
|
||||
REDIS_URL: redis://redis:6379
|
||||
RABBITMQ_URL: ${{ secrets.E2E_RABBITMQ_URL || 'amqp://veza:devpassword@rabbitmq:5672/' }}
|
||||
|
||||
steps:
|
||||
|
|
@ -140,9 +142,36 @@ jobs:
|
|||
cd veza-backend-api
|
||||
go build -o veza-api ./cmd/api/main.go
|
||||
./veza-api > /tmp/backend.log 2>&1 &
|
||||
sleep 10
|
||||
curl -sf http://localhost:18080/api/v1/health > /tmp/health.json || (echo "Backend health check failed"; tail -50 /tmp/backend.log; exit 1)
|
||||
jq -e '.status == "ok"' /tmp/health.json || (echo "Health response invalid"; cat /tmp/health.json; exit 1)
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Poll for up to 30s — beats a fixed sleep on a cold start.
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf -m 2 http://localhost:18080/api/v1/health > /tmp/health.json 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
echo "::error::backend process died before becoming reachable"
|
||||
echo "--- /tmp/backend.log (last 200 lines) ---"
|
||||
tail -200 /tmp/backend.log
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Always print the response body so debugging doesn't
|
||||
# require re-running with extra logging. Artifact upload
|
||||
# is broken under Forgejo (GHES not supported), so the
|
||||
# log step output is our only diagnostic channel.
|
||||
echo "--- /api/v1/health response ---"
|
||||
cat /tmp/health.json
|
||||
echo
|
||||
|
||||
if ! jq -e '.status == "ok"' /tmp/health.json >/dev/null; then
|
||||
echo "::error::backend health is not ok"
|
||||
echo "--- /tmp/backend.log (last 200 lines) ---"
|
||||
tail -200 /tmp/backend.log
|
||||
exit 1
|
||||
fi
|
||||
echo "Backend healthy"
|
||||
|
||||
- name: Install Playwright browsers
|
||||
|
|
|
|||
|
|
@ -5,11 +5,23 @@ import (
|
|||
|
||||
"veza-backend-api/internal/core/subscription"
|
||||
"veza-backend-api/internal/handlers"
|
||||
"veza-backend-api/internal/services/hyperswitch"
|
||||
)
|
||||
|
||||
// setupSubscriptionRoutes configures routes for subscription plans management (v0.12.1)
|
||||
// setupSubscriptionRoutes configures routes for subscription plans management (v0.12.1).
|
||||
//
|
||||
// v1.0.9 item G — when Hyperswitch is configured, the subscription
|
||||
// service is built with a PaymentProvider. Without it, paid-plan
|
||||
// subscribe attempts fail with HTTP 503 "payment provider not
|
||||
// configured" (replaces the v1.0.6.2 silent fantôme creation).
|
||||
func (r *APIRouter) setupSubscriptionRoutes(router *gin.RouterGroup) {
|
||||
svc := subscription.NewService(r.db.GormDB, r.logger)
|
||||
opts := []subscription.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, subscription.WithPaymentProvider(hsProvider))
|
||||
}
|
||||
svc := subscription.NewService(r.db.GormDB, r.logger, opts...)
|
||||
handler := handlers.NewSubscriptionHandler(svc, r.logger)
|
||||
|
||||
group := router.Group("/subscriptions")
|
||||
|
|
|
|||
|
|
@ -167,3 +167,201 @@ func TestGetUserSubscription_PaymentGate(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakePaymentProvider records the calls it received so the tests can
|
||||
// assert on idempotency-key threading + arg shapes. Returns
|
||||
// deterministic payment_id / client_secret pairs.
|
||||
type fakePaymentProvider struct {
|
||||
calls []fakePaymentCall
|
||||
createErr error
|
||||
getStatusValue string
|
||||
}
|
||||
|
||||
type fakePaymentCall struct {
|
||||
idempotencyKey string
|
||||
amountCents int
|
||||
currency string
|
||||
subscriptionID string
|
||||
metadata map[string]string
|
||||
}
|
||||
|
||||
func (f *fakePaymentProvider) CreateSubscriptionPayment(_ context.Context, idempotencyKey string, amountCents int, currency, subscriptionID, _ string, metadata map[string]string) (string, string, error) {
|
||||
f.calls = append(f.calls, fakePaymentCall{
|
||||
idempotencyKey: idempotencyKey,
|
||||
amountCents: amountCents,
|
||||
currency: currency,
|
||||
subscriptionID: subscriptionID,
|
||||
metadata: metadata,
|
||||
})
|
||||
if f.createErr != nil {
|
||||
return "", "", f.createErr
|
||||
}
|
||||
return "pay_" + idempotencyKey[:8], "sec_" + idempotencyKey[:8], nil
|
||||
}
|
||||
|
||||
func (f *fakePaymentProvider) GetPayment(_ context.Context, _ string) (string, error) {
|
||||
return f.getStatusValue, nil
|
||||
}
|
||||
|
||||
// TestSubscribe_PendingPaymentStateMachine exercises v1.0.9 item G's
|
||||
// state machine on the new-subscription path. Covers the visible
|
||||
// outcomes : free plan stays active, paid plan with provider goes to
|
||||
// pending_payment + idempotency-key threaded, paid plan without
|
||||
// provider returns ErrPaymentProviderRequired (mapped to 503 in the
|
||||
// handler), trial-eligible first-time user gets trialing without a
|
||||
// PSP call, and a repeat user (already used trial) falls through to
|
||||
// pending_payment.
|
||||
func TestSubscribe_PendingPaymentStateMachine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
freePlan := Plan{
|
||||
ID: uuid.New(),
|
||||
Name: PlanFree,
|
||||
DisplayName: "Free",
|
||||
PriceMonthly: 0,
|
||||
IsActive: true,
|
||||
}
|
||||
paidPlan := Plan{
|
||||
ID: uuid.New(),
|
||||
Name: PlanCreator,
|
||||
DisplayName: "Creator",
|
||||
PriceMonthly: 999,
|
||||
PriceYearly: 9990,
|
||||
Currency: "USD",
|
||||
IsActive: true,
|
||||
}
|
||||
paidPlanWithTrial := Plan{
|
||||
ID: uuid.New(),
|
||||
Name: PlanPremium,
|
||||
DisplayName: "Premium",
|
||||
PriceMonthly: 1999,
|
||||
Currency: "USD",
|
||||
TrialDays: 14,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
type setup struct {
|
||||
plan Plan
|
||||
provider *fakePaymentProvider
|
||||
seedTrial bool // pre-seed a trial row so the trial-eligibility branch is exercised
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
setup setup
|
||||
expectStatus SubscriptionStatus
|
||||
expectPSP bool
|
||||
expectErr error
|
||||
}{
|
||||
{
|
||||
name: "free plan stays active and skips provider",
|
||||
setup: setup{
|
||||
plan: freePlan,
|
||||
provider: &fakePaymentProvider{},
|
||||
},
|
||||
expectStatus: StatusActive,
|
||||
expectPSP: false,
|
||||
},
|
||||
{
|
||||
name: "paid plan + provider configured -> pending_payment + PSP call",
|
||||
setup: setup{
|
||||
plan: paidPlan,
|
||||
provider: &fakePaymentProvider{},
|
||||
},
|
||||
expectStatus: StatusPendingPayment,
|
||||
expectPSP: true,
|
||||
},
|
||||
{
|
||||
name: "paid plan + no provider -> ErrPaymentProviderRequired",
|
||||
setup: setup{
|
||||
plan: paidPlan,
|
||||
provider: nil,
|
||||
},
|
||||
expectErr: ErrPaymentProviderRequired,
|
||||
},
|
||||
{
|
||||
name: "trial-eligible first-time user -> trialing, no PSP call",
|
||||
setup: setup{
|
||||
plan: paidPlanWithTrial,
|
||||
provider: &fakePaymentProvider{},
|
||||
},
|
||||
expectStatus: StatusTrialing,
|
||||
expectPSP: false,
|
||||
},
|
||||
{
|
||||
name: "trial repeat user -> falls through to pending_payment",
|
||||
setup: setup{
|
||||
plan: paidPlanWithTrial,
|
||||
provider: &fakePaymentProvider{},
|
||||
seedTrial: true,
|
||||
},
|
||||
expectStatus: StatusPendingPayment,
|
||||
expectPSP: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&Plan{}, &UserSubscription{}, &Invoice{}))
|
||||
require.NoError(t, db.Create(&tc.setup.plan).Error)
|
||||
|
||||
opts := []ServiceOption{}
|
||||
if tc.setup.provider != nil {
|
||||
opts = append(opts, WithPaymentProvider(tc.setup.provider))
|
||||
}
|
||||
svc := NewService(db, zap.NewNop(), opts...)
|
||||
|
||||
userID := uuid.New()
|
||||
if tc.setup.seedTrial {
|
||||
past := time.Now().Add(-30 * 24 * time.Hour)
|
||||
old := UserSubscription{
|
||||
UserID: userID, PlanID: tc.setup.plan.ID,
|
||||
Status: StatusExpired,
|
||||
BillingCycle: BillingMonthly,
|
||||
CurrentPeriodStart: past,
|
||||
CurrentPeriodEnd: past.Add(time.Hour),
|
||||
TrialStart: &past,
|
||||
TrialEnd: &past,
|
||||
}
|
||||
require.NoError(t, db.Create(&old).Error)
|
||||
}
|
||||
|
||||
resp, err := svc.Subscribe(ctx, userID, SubscribeRequest{
|
||||
PlanID: tc.setup.plan.ID,
|
||||
BillingCycle: BillingMonthly,
|
||||
})
|
||||
|
||||
if tc.expectErr != nil {
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, tc.expectErr),
|
||||
"expected %v, got %v", tc.expectErr, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.Subscription)
|
||||
require.Equal(t, tc.expectStatus, resp.Subscription.Status)
|
||||
|
||||
if tc.expectPSP {
|
||||
require.Len(t, tc.setup.provider.calls, 1,
|
||||
"expected exactly one PSP call")
|
||||
call := tc.setup.provider.calls[0]
|
||||
// Idempotency key must be the new subscription row's
|
||||
// UUID — protects against retried HTTP requests
|
||||
// collapsing into one PSP charge.
|
||||
require.Equal(t, resp.Subscription.ID.String(), call.idempotencyKey)
|
||||
require.NotEmpty(t, call.idempotencyKey)
|
||||
require.Equal(t, resp.Subscription.ID.String(), call.subscriptionID)
|
||||
require.Equal(t, "USD", call.currency)
|
||||
require.Equal(t, tc.setup.plan.PriceMonthly, call.amountCents)
|
||||
require.Equal(t, userID.String(), call.metadata["user_id"])
|
||||
require.NotEmpty(t, resp.ClientSecret, "client_secret returned to caller")
|
||||
require.NotEmpty(t, resp.PaymentID, "payment_id returned to caller")
|
||||
} else if tc.setup.provider != nil {
|
||||
require.Len(t, tc.setup.provider.calls, 0,
|
||||
"expected zero PSP calls when expectPSP=false")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ const (
|
|||
StatusCanceled SubscriptionStatus = "canceled"
|
||||
StatusPastDue SubscriptionStatus = "past_due"
|
||||
StatusExpired SubscriptionStatus = "expired"
|
||||
// StatusPendingPayment (v1.0.9 item G) — paid-plan subscription
|
||||
// row created but the PSP charge has not been confirmed yet.
|
||||
// Webhook subscription.payment_succeeded → flip to active.
|
||||
// Webhook subscription.payment_failed → flip to expired.
|
||||
// Replaces the v1.0.6.2 "active row without PSP linkage" fantôme
|
||||
// pattern that hasEffectivePayment had to filter retroactively.
|
||||
StatusPendingPayment SubscriptionStatus = "pending_payment"
|
||||
)
|
||||
|
||||
// BillingCycle represents the billing frequency
|
||||
|
|
|
|||
|
|
@ -27,13 +27,29 @@ var (
|
|||
// being invoked (e.g., HYPERSWITCH_ENABLED=false). Callers that gate features
|
||||
// by subscription should treat this as ineligible. The /me/subscription
|
||||
// handler surfaces a specific message so honest-path users know to contact
|
||||
// support.
|
||||
// support. Item G makes new code paths skip this state altogether by
|
||||
// using StatusPendingPayment instead — but the filter is kept as
|
||||
// defence-in-depth for legacy rows that pre-date the migration.
|
||||
ErrSubscriptionNoPayment = errors.New("subscription has no effective payment linkage")
|
||||
// ErrPaymentProviderRequired (v1.0.9 item G): a paid plan subscribe
|
||||
// attempt was made without a configured PaymentProvider. v1.0.6.2
|
||||
// silently let this through, leaving rows in `active` with no PSP
|
||||
// linkage. Item G fail-closes — the handler maps this to HTTP 503
|
||||
// "payment provider not configured" so an env misconfiguration is
|
||||
// loud instead of silently giving away paid plans.
|
||||
ErrPaymentProviderRequired = errors.New("paid plan requires a configured payment provider")
|
||||
)
|
||||
|
||||
// PaymentProvider defines the interface for subscription payments
|
||||
// PaymentProvider defines the interface for subscription payments.
|
||||
//
|
||||
// idempotencyKey (v1.0.9 item G) — must be unique per logical
|
||||
// subscription creation; passed through to the PSP's `Idempotency-Key`
|
||||
// HTTP header. Callers pass the new subscription row's UUID so a
|
||||
// retried HTTP request from the same Subscribe() call collapses to one
|
||||
// PSP charge. Empty key MUST cause a loud failure rather than a silent
|
||||
// header omission, mirroring the marketplace.refundProvider contract.
|
||||
type PaymentProvider interface {
|
||||
CreateSubscriptionPayment(ctx context.Context, amountCents int, currency, subscriptionID, returnURL string, metadata map[string]string) (paymentID, clientSecret string, err error)
|
||||
CreateSubscriptionPayment(ctx context.Context, idempotencyKey string, amountCents int, currency, subscriptionID, returnURL string, metadata map[string]string) (paymentID, clientSecret string, err error)
|
||||
GetPayment(ctx context.Context, paymentID string) (status string, err error)
|
||||
}
|
||||
|
||||
|
|
@ -257,7 +273,24 @@ func (s *Service) subscribeToFreePlan(ctx context.Context, userID uuid.UUID, pla
|
|||
return &SubscribeResponse{Subscription: sub}, nil
|
||||
}
|
||||
|
||||
// createNewSubscription creates a subscription for a paid plan
|
||||
// createNewSubscription creates a subscription for a paid plan.
|
||||
//
|
||||
// v1.0.9 item G — state machine:
|
||||
// - Trial available + first-time user → status=trialing, no PSP call,
|
||||
// no invoice. Trial expiry will require a follow-up flow that
|
||||
// creates the first paid invoice and transitions the row.
|
||||
// - Trial available + repeat user (already used trial) → falls
|
||||
// through to the paid-plan branch with status=pending_payment.
|
||||
// - Paid plan, no trial / repeat → status=pending_payment, invoice
|
||||
// created with PSP payment_id, client_secret returned for the
|
||||
// frontend to drive the payment UI. Webhook
|
||||
// subscription.payment_succeeded flips to active. Webhook
|
||||
// subscription.payment_failed flips to expired.
|
||||
//
|
||||
// PaymentProvider is now mandatory for any paid-plan subscribe path
|
||||
// that hits the PSP (replaces the v1.0.6.2 silent short-circuit). The
|
||||
// handler maps ErrPaymentProviderRequired to HTTP 503 so misconfig is
|
||||
// surfaced to ops, not silently absorbed.
|
||||
func (s *Service) createNewSubscription(ctx context.Context, userID uuid.UUID, plan *Plan, cycle BillingCycle) (*SubscribeResponse, error) {
|
||||
now := time.Now()
|
||||
var periodEnd time.Time
|
||||
|
|
@ -285,31 +318,46 @@ func (s *Service) createNewSubscription(ctx context.Context, userID uuid.UUID, p
|
|||
// SECURITY(REM-015): Trial check + subscription creation in single transaction to prevent
|
||||
// race condition where two concurrent requests both see previousTrialCount=0.
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Apply trial if available — checked INSIDE transaction for atomicity
|
||||
// Apply trial if available — checked INSIDE transaction for atomicity.
|
||||
// First-time trial users get StatusTrialing (no immediate PSP call).
|
||||
// Repeat users (or no trial offered) fall through to the paid path
|
||||
// which creates the row in StatusPendingPayment.
|
||||
var inTrial bool
|
||||
if plan.TrialDays > 0 {
|
||||
var previousTrialCount int64
|
||||
tx.Model(&UserSubscription{}).
|
||||
Where("user_id = ? AND trial_start IS NOT NULL", userID).
|
||||
Count(&previousTrialCount)
|
||||
if previousTrialCount > 0 {
|
||||
sub.Status = StatusActive
|
||||
} else {
|
||||
if previousTrialCount == 0 {
|
||||
trialEnd := now.AddDate(0, 0, plan.TrialDays)
|
||||
sub.Status = StatusTrialing
|
||||
sub.TrialStart = &now
|
||||
sub.TrialEnd = &trialEnd
|
||||
sub.CurrentPeriodEnd = trialEnd
|
||||
inTrial = true
|
||||
}
|
||||
} else {
|
||||
sub.Status = StatusActive
|
||||
}
|
||||
if !inTrial {
|
||||
// Paid plan, no trial → row enters pending_payment state.
|
||||
// Will transition to active on subscription.payment_succeeded
|
||||
// webhook, or to expired on subscription.payment_failed.
|
||||
sub.Status = StatusPendingPayment
|
||||
}
|
||||
|
||||
if err := tx.Create(sub).Error; err != nil {
|
||||
return fmt.Errorf("failed to create subscription: %w", err)
|
||||
}
|
||||
|
||||
// Create invoice (for paid plans, not during trial)
|
||||
if !sub.IsTrialing() && amountCents > 0 {
|
||||
// Create invoice + open PSP charge (paid plans not in trial).
|
||||
if !inTrial && amountCents > 0 {
|
||||
// v1.0.9 item G: payment provider is mandatory here. The
|
||||
// previous silent `if s.paymentProvider != nil` branch left
|
||||
// the row in `active` without PSP linkage when Hyperswitch
|
||||
// was disabled — effectively giving the plan away.
|
||||
if s.paymentProvider == nil {
|
||||
return ErrPaymentProviderRequired
|
||||
}
|
||||
|
||||
invoice := &Invoice{
|
||||
SubscriptionID: sub.ID,
|
||||
UserID: userID,
|
||||
|
|
@ -323,31 +371,27 @@ func (s *Service) createNewSubscription(ctx context.Context, userID uuid.UUID, p
|
|||
return fmt.Errorf("failed to create invoice: %w", err)
|
||||
}
|
||||
|
||||
// TODO(v1.0.7-item-G): make payment provider mandatory for paid plans.
|
||||
// Today `if s.paymentProvider != nil` short-circuits silently when
|
||||
// Hyperswitch is disabled, leaving the row `active` with no PSP
|
||||
// linkage. Item G replaces this with a mandatory pending_payment
|
||||
// state + webhook-driven activation. Until then, v1.0.6.2
|
||||
// compensates via the `GetUserSubscription` filter.
|
||||
if s.paymentProvider != nil {
|
||||
var err error
|
||||
paymentID, clientSecret, err = s.paymentProvider.CreateSubscriptionPayment(
|
||||
ctx, amountCents, plan.Currency, sub.ID.String(),
|
||||
"", // returnURL to be set by frontend
|
||||
map[string]string{
|
||||
"user_id": userID.String(),
|
||||
"subscription_id": sub.ID.String(),
|
||||
"plan": string(plan.Name),
|
||||
"billing_cycle": string(cycle),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create payment: %w", err)
|
||||
}
|
||||
invoice.HyperswitchPaymentID = paymentID
|
||||
if err := tx.Save(invoice).Error; err != nil {
|
||||
return fmt.Errorf("failed to update invoice with payment ID: %w", err)
|
||||
}
|
||||
var psErr error
|
||||
paymentID, clientSecret, psErr = s.paymentProvider.CreateSubscriptionPayment(
|
||||
ctx,
|
||||
sub.ID.String(), // idempotency key (item G + item D pattern)
|
||||
amountCents,
|
||||
plan.Currency,
|
||||
sub.ID.String(),
|
||||
"", // returnURL — the frontend sets it on the confirm step
|
||||
map[string]string{
|
||||
"user_id": userID.String(),
|
||||
"subscription_id": sub.ID.String(),
|
||||
"plan": string(plan.Name),
|
||||
"billing_cycle": string(cycle),
|
||||
},
|
||||
)
|
||||
if psErr != nil {
|
||||
return fmt.Errorf("failed to create payment: %w", psErr)
|
||||
}
|
||||
invoice.HyperswitchPaymentID = paymentID
|
||||
if err := tx.Save(invoice).Error; err != nil {
|
||||
return fmt.Errorf("failed to update invoice with payment ID: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -111,6 +111,13 @@ func (h *SubscriptionHandler) Subscribe(c *gin.Context) {
|
|||
RespondWithAppError(c, apperrors.NewValidationError("Already subscribed to this plan"))
|
||||
case errors.Is(err, subscription.ErrInvalidBillingCycle):
|
||||
RespondWithAppError(c, apperrors.NewValidationError("Invalid billing cycle: must be 'monthly' or 'yearly'"))
|
||||
case errors.Is(err, subscription.ErrPaymentProviderRequired):
|
||||
// v1.0.9 item G: paid plan attempted but no PaymentProvider
|
||||
// is wired (HYPERSWITCH_ENABLED=false in dev, or missing
|
||||
// credentials in staging). Surface the misconfig as 503 so
|
||||
// ops sees it instead of silently absorbing it as a free
|
||||
// active subscription.
|
||||
RespondWithAppError(c, apperrors.NewServiceUnavailableError("Payment provider not configured — paid plans temporarily unavailable"))
|
||||
default:
|
||||
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to subscribe", err))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,15 @@ 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
|
||||
|
|
@ -67,3 +71,14 @@ func (p *Provider) CreateRefund(ctx context.Context, idempotencyKey, paymentID s
|
|||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
-- v1.0.9 Item G — subscription pending_payment state machine.
|
||||
--
|
||||
-- The user_subscriptions.status column is a free-text VARCHAR(30) with
|
||||
-- no DB-level enum, so the new 'pending_payment' value introduced by
|
||||
-- the Go const StatusPendingPayment requires no DDL. This migration is
|
||||
-- documentation + an index that the future reconciliation worker
|
||||
-- (Phase 2) needs to find rows stuck in pending_payment past the
|
||||
-- webhook-arrival window.
|
||||
--
|
||||
-- Index strategy : a partial index on (created_at) WHERE status =
|
||||
-- 'pending_payment' is the smallest possible footprint that still
|
||||
-- gives the reconciler an O(log N) scan for "rows older than 30m
|
||||
-- that haven't transitioned yet". A non-partial composite index would
|
||||
-- pay storage cost for every row in the table; partial keeps it
|
||||
-- proportional to the in-flight set.
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_subscriptions_pending_payment
|
||||
ON user_subscriptions (created_at)
|
||||
WHERE status = 'pending_payment';
|
||||
|
||||
COMMENT ON INDEX idx_user_subscriptions_pending_payment IS
|
||||
'v1.0.9 Item G — feeds the subscription reconciliation sweep that catches rows stuck in pending_payment past the webhook deadline.';
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- Rollback for 986 — drop the partial index.
|
||||
DROP INDEX IF EXISTS idx_user_subscriptions_pending_payment;
|
||||
Loading…
Reference in a new issue