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>
157 lines
6.7 KiB
Go
157 lines
6.7 KiB
Go
package subscription
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// PlanName represents a subscription plan type
|
|
type PlanName string
|
|
|
|
const (
|
|
PlanFree PlanName = "free"
|
|
PlanCreator PlanName = "creator"
|
|
PlanPremium PlanName = "premium"
|
|
)
|
|
|
|
// SubscriptionStatus represents the status of a user subscription
|
|
type SubscriptionStatus string
|
|
|
|
const (
|
|
StatusActive SubscriptionStatus = "active"
|
|
StatusTrialing SubscriptionStatus = "trialing"
|
|
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
|
|
type BillingCycle string
|
|
|
|
const (
|
|
BillingMonthly BillingCycle = "monthly"
|
|
BillingYearly BillingCycle = "yearly"
|
|
)
|
|
|
|
// InvoiceStatus represents the status of an invoice
|
|
type InvoiceStatus string
|
|
|
|
const (
|
|
InvoicePending InvoiceStatus = "pending"
|
|
InvoicePaid InvoiceStatus = "paid"
|
|
InvoiceFailed InvoiceStatus = "failed"
|
|
InvoiceRefunded InvoiceStatus = "refunded"
|
|
)
|
|
|
|
// Plan represents a subscription plan definition
|
|
type Plan struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
|
Name PlanName `gorm:"not null;uniqueIndex;size:50" json:"name"`
|
|
DisplayName string `gorm:"not null;size:100" json:"display_name"`
|
|
Description string `gorm:"type:text" json:"description"`
|
|
PriceMonthly int `gorm:"column:price_monthly_cents;not null;default:0" json:"price_monthly_cents"`
|
|
PriceYearly int `gorm:"column:price_yearly_cents;not null;default:0" json:"price_yearly_cents"`
|
|
Currency string `gorm:"size:3;default:'USD'" json:"currency"`
|
|
UploadLimitMonthly *int `gorm:"column:upload_limit_monthly" json:"upload_limit_monthly"`
|
|
StorageLimitBytes int64 `gorm:"not null;default:0" json:"storage_limit_bytes"`
|
|
MarketplaceCommission float64 `gorm:"column:marketplace_commission_rate;type:numeric(5,4);default:0" json:"marketplace_commission_rate"`
|
|
CanSellOnMarketplace bool `gorm:"not null;default:false" json:"can_sell_on_marketplace"`
|
|
HasPrioritySupport bool `gorm:"not null;default:false" json:"has_priority_support"`
|
|
HasCollaborationTools bool `gorm:"not null;default:false" json:"has_collaboration_tools"`
|
|
HasDistribution bool `gorm:"not null;default:false" json:"has_distribution"`
|
|
TrialDays int `gorm:"not null;default:0" json:"trial_days"`
|
|
IsActive bool `gorm:"not null;default:true" json:"is_active"`
|
|
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|
}
|
|
|
|
func (Plan) TableName() string {
|
|
return "subscription_plans"
|
|
}
|
|
|
|
func (p *Plan) BeforeCreate(tx *gorm.DB) error {
|
|
if p.ID == uuid.Nil {
|
|
p.ID = uuid.New()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UserSubscription represents a user's active or past subscription
|
|
type UserSubscription struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
|
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
|
|
PlanID uuid.UUID `gorm:"type:uuid;not null" json:"plan_id"`
|
|
Status SubscriptionStatus `gorm:"not null;size:30;default:'active'" json:"status"`
|
|
BillingCycle BillingCycle `gorm:"not null;size:10;default:'monthly'" json:"billing_cycle"`
|
|
CurrentPeriodStart time.Time `gorm:"not null" json:"current_period_start"`
|
|
CurrentPeriodEnd time.Time `gorm:"not null" json:"current_period_end"`
|
|
TrialStart *time.Time `json:"trial_start,omitempty"`
|
|
TrialEnd *time.Time `json:"trial_end,omitempty"`
|
|
CanceledAt *time.Time `json:"canceled_at,omitempty"`
|
|
CancelAtPeriodEnd bool `gorm:"not null;default:false" json:"cancel_at_period_end"`
|
|
HyperswitchSubscriptionID string `gorm:"size:255" json:"hyperswitch_subscription_id,omitempty"`
|
|
HyperswitchCustomerID string `gorm:"size:255" json:"hyperswitch_customer_id,omitempty"`
|
|
PaymentMethodID string `gorm:"size:255" json:"payment_method_id,omitempty"`
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|
|
|
// Relations (loaded via Preload)
|
|
Plan Plan `gorm:"foreignKey:PlanID" json:"plan,omitempty"`
|
|
}
|
|
|
|
func (UserSubscription) TableName() string {
|
|
return "user_subscriptions"
|
|
}
|
|
|
|
func (s *UserSubscription) BeforeCreate(tx *gorm.DB) error {
|
|
if s.ID == uuid.Nil {
|
|
s.ID = uuid.New()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsTrialing returns true if the subscription is in trial period
|
|
func (s *UserSubscription) IsTrialing() bool {
|
|
return s.Status == StatusTrialing && s.TrialEnd != nil && time.Now().Before(*s.TrialEnd)
|
|
}
|
|
|
|
// IsActiveOrTrialing returns true if the subscription grants access
|
|
func (s *UserSubscription) IsActiveOrTrialing() bool {
|
|
return s.Status == StatusActive || s.Status == StatusTrialing
|
|
}
|
|
|
|
// Invoice represents a subscription billing invoice
|
|
type Invoice struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
|
SubscriptionID uuid.UUID `gorm:"type:uuid;not null" json:"subscription_id"`
|
|
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
|
|
AmountCents int `gorm:"not null" json:"amount_cents"`
|
|
Currency string `gorm:"size:3;default:'USD'" json:"currency"`
|
|
Status InvoiceStatus `gorm:"not null;size:30;default:'pending'" json:"status"`
|
|
BillingPeriodStart time.Time `gorm:"not null" json:"billing_period_start"`
|
|
BillingPeriodEnd time.Time `gorm:"not null" json:"billing_period_end"`
|
|
HyperswitchPaymentID string `gorm:"size:255" json:"hyperswitch_payment_id,omitempty"`
|
|
PaidAt *time.Time `json:"paid_at,omitempty"`
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
}
|
|
|
|
func (Invoice) TableName() string {
|
|
return "subscription_invoices"
|
|
}
|
|
|
|
func (i *Invoice) BeforeCreate(tx *gorm.DB) error {
|
|
if i.ID == uuid.Nil {
|
|
i.ID = uuid.New()
|
|
}
|
|
return nil
|
|
}
|