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>
640 lines
22 KiB
Go
640 lines
22 KiB
Go
package subscription
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Service errors
|
|
var (
|
|
ErrPlanNotFound = errors.New("subscription plan not found")
|
|
ErrSubscriptionNotFound = errors.New("subscription not found")
|
|
ErrAlreadySubscribed = errors.New("user already has an active subscription to this plan")
|
|
ErrCannotDowngradeDuring = errors.New("downgrade takes effect at end of current period")
|
|
ErrNoActiveSubscription = errors.New("no active subscription found")
|
|
ErrInvalidBillingCycle = errors.New("invalid billing cycle: must be 'monthly' or 'yearly'")
|
|
ErrFreePlanNoBilling = errors.New("free plan does not require billing")
|
|
// ErrSubscriptionNoPayment: a subscription row exists in active/trialing but no
|
|
// effective payment signal is attached (no PSP payment intent on any invoice, and
|
|
// neither free plan nor active trial). Introduced in v1.0.6.2 to close a bypass
|
|
// where rows could be created as 'active' without the payment provider ever
|
|
// 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. 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.
|
|
//
|
|
// 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, 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)
|
|
}
|
|
|
|
// ServiceOption is a functional option for configuring the Service
|
|
type ServiceOption func(*Service)
|
|
|
|
// WithPaymentProvider sets the payment provider for the subscription service
|
|
func WithPaymentProvider(p PaymentProvider) ServiceOption {
|
|
return func(s *Service) {
|
|
s.paymentProvider = p
|
|
}
|
|
}
|
|
|
|
// Service handles subscription business logic
|
|
type Service struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
paymentProvider PaymentProvider
|
|
}
|
|
|
|
// NewService creates a new subscription service
|
|
func NewService(db *gorm.DB, logger *zap.Logger, opts ...ServiceOption) *Service {
|
|
s := &Service{
|
|
db: db,
|
|
logger: logger,
|
|
}
|
|
for _, opt := range opts {
|
|
opt(s)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ListPlans returns all active subscription plans ordered by sort_order
|
|
func (s *Service) ListPlans(ctx context.Context) ([]Plan, error) {
|
|
var plans []Plan
|
|
if err := s.db.WithContext(ctx).
|
|
Where("is_active = ?", true).
|
|
Order("sort_order ASC").
|
|
Find(&plans).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to list plans: %w", err)
|
|
}
|
|
return plans, nil
|
|
}
|
|
|
|
// GetPlan returns a plan by ID
|
|
func (s *Service) GetPlan(ctx context.Context, planID uuid.UUID) (*Plan, error) {
|
|
var plan Plan
|
|
if err := s.db.WithContext(ctx).First(&plan, "id = ?", planID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrPlanNotFound
|
|
}
|
|
return nil, fmt.Errorf("failed to get plan: %w", err)
|
|
}
|
|
return &plan, nil
|
|
}
|
|
|
|
// GetPlanByName returns a plan by its name
|
|
func (s *Service) GetPlanByName(ctx context.Context, name PlanName) (*Plan, error) {
|
|
var plan Plan
|
|
if err := s.db.WithContext(ctx).First(&plan, "name = ?", string(name)).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrPlanNotFound
|
|
}
|
|
return nil, fmt.Errorf("failed to get plan by name: %w", err)
|
|
}
|
|
return &plan, nil
|
|
}
|
|
|
|
// GetUserSubscription returns the user's current active/trialing subscription
|
|
// IFF it has an effective payment linkage. A row that sits in active/trialing
|
|
// but was never linked to a PSP payment intent (and is not on a free plan or
|
|
// in an unexpired trial) returns ErrSubscriptionNoPayment. This is the sole
|
|
// gate for feature eligibility — any caller routing to a paid-feature check
|
|
// goes through here, by design, so there is no code path that can grant
|
|
// access to a subscription that never paid. See v1.0.6.2 hotfix.
|
|
func (s *Service) GetUserSubscription(ctx context.Context, userID uuid.UUID) (*UserSubscription, error) {
|
|
var sub UserSubscription
|
|
err := s.db.WithContext(ctx).
|
|
Preload("Plan").
|
|
Where("user_id = ? AND status IN ?", userID, []string{string(StatusActive), string(StatusTrialing)}).
|
|
First(&sub).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrNoActiveSubscription
|
|
}
|
|
return nil, fmt.Errorf("failed to get user subscription: %w", err)
|
|
}
|
|
if !s.hasEffectivePayment(ctx, &sub) {
|
|
return nil, ErrSubscriptionNoPayment
|
|
}
|
|
return &sub, nil
|
|
}
|
|
|
|
// hasEffectivePayment returns true if the subscription represents a legitimate
|
|
// claim on paid features. True when: the plan is free, OR the subscription is
|
|
// in an unexpired trial, OR at least one invoice carries a PSP payment intent
|
|
// (hyperswitch_payment_id populated). The last branch raises the bar from
|
|
// "anyone can POST /subscribe" to "someone must have actually reached the
|
|
// PSP". It does not prove the charge succeeded — item G in v1.0.7 tightens
|
|
// this further by requiring invoice.status='paid' via webhook.
|
|
func (s *Service) hasEffectivePayment(ctx context.Context, sub *UserSubscription) bool {
|
|
if sub.Plan.PriceMonthly == 0 {
|
|
return true
|
|
}
|
|
if sub.Status == StatusTrialing && sub.TrialEnd != nil && time.Now().Before(*sub.TrialEnd) {
|
|
return true
|
|
}
|
|
var count int64
|
|
s.db.WithContext(ctx).Model(&Invoice{}).
|
|
Where("subscription_id = ? AND hyperswitch_payment_id IS NOT NULL AND hyperswitch_payment_id <> ''", sub.ID).
|
|
Count(&count)
|
|
return count > 0
|
|
}
|
|
|
|
// GetUserSubscriptionHistory returns all subscriptions for a user (including canceled/expired)
|
|
func (s *Service) GetUserSubscriptionHistory(ctx context.Context, userID uuid.UUID, limit, offset int) ([]UserSubscription, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 20
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
var subs []UserSubscription
|
|
err := s.db.WithContext(ctx).
|
|
Preload("Plan").
|
|
Where("user_id = ?", userID).
|
|
Order("created_at DESC").
|
|
Limit(limit).
|
|
Offset(offset).
|
|
Find(&subs).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get subscription history: %w", err)
|
|
}
|
|
return subs, nil
|
|
}
|
|
|
|
// SubscribeRequest holds the parameters for subscribing to a plan
|
|
type SubscribeRequest struct {
|
|
PlanID uuid.UUID `json:"plan_id" binding:"required"`
|
|
BillingCycle BillingCycle `json:"billing_cycle" binding:"required"`
|
|
}
|
|
|
|
// SubscribeResponse holds the result of a subscription creation
|
|
type SubscribeResponse struct {
|
|
Subscription *UserSubscription `json:"subscription"`
|
|
ClientSecret string `json:"client_secret,omitempty"` // For Hyperswitch payment
|
|
PaymentID string `json:"payment_id,omitempty"`
|
|
}
|
|
|
|
// Subscribe creates a new subscription for a user
|
|
func (s *Service) Subscribe(ctx context.Context, userID uuid.UUID, req SubscribeRequest) (*SubscribeResponse, error) {
|
|
if req.BillingCycle != BillingMonthly && req.BillingCycle != BillingYearly {
|
|
return nil, ErrInvalidBillingCycle
|
|
}
|
|
|
|
plan, err := s.GetPlan(ctx, req.PlanID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if plan.Name == PlanFree {
|
|
return s.subscribeToFreePlan(ctx, userID, plan)
|
|
}
|
|
|
|
// Check for existing active subscription. A row in active/trialing without
|
|
// effective payment (ErrSubscriptionNoPayment, v1.0.6.2) is treated as
|
|
// "no active subscription" for the purpose of re-subscribing — the
|
|
// cleanup migration 980 voids those rows at deploy time, so in practice
|
|
// this branch is only hit during the brief deploy window.
|
|
existing, err := s.GetUserSubscription(ctx, userID)
|
|
if err != nil && !errors.Is(err, ErrNoActiveSubscription) && !errors.Is(err, ErrSubscriptionNoPayment) {
|
|
return nil, err
|
|
}
|
|
if errors.Is(err, ErrSubscriptionNoPayment) {
|
|
existing = nil
|
|
}
|
|
|
|
if existing != nil && existing.PlanID == req.PlanID {
|
|
return nil, ErrAlreadySubscribed
|
|
}
|
|
|
|
// If upgrading from a lower plan, handle the transition
|
|
if existing != nil {
|
|
return s.changePlan(ctx, userID, existing, plan, req.BillingCycle)
|
|
}
|
|
|
|
return s.createNewSubscription(ctx, userID, plan, req.BillingCycle)
|
|
}
|
|
|
|
// subscribeToFreePlan assigns the free plan without payment
|
|
func (s *Service) subscribeToFreePlan(ctx context.Context, userID uuid.UUID, plan *Plan) (*SubscribeResponse, error) {
|
|
// Cancel any existing subscription first. A no-payment fantôme row
|
|
// (v1.0.6.2 filter) is treated as "nothing to cancel" — the cleanup
|
|
// migration handles it at deploy time.
|
|
existing, err := s.GetUserSubscription(ctx, userID)
|
|
if err != nil && !errors.Is(err, ErrNoActiveSubscription) && !errors.Is(err, ErrSubscriptionNoPayment) {
|
|
return nil, err
|
|
}
|
|
if existing != nil {
|
|
if err := s.cancelImmediately(ctx, existing); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
now := time.Now()
|
|
sub := &UserSubscription{
|
|
UserID: userID,
|
|
PlanID: plan.ID,
|
|
Status: StatusActive,
|
|
BillingCycle: BillingMonthly,
|
|
CurrentPeriodStart: now,
|
|
CurrentPeriodEnd: now.AddDate(100, 0, 0), // effectively never expires
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Create(sub).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to create free subscription: %w", err)
|
|
}
|
|
|
|
sub.Plan = *plan
|
|
return &SubscribeResponse{Subscription: sub}, nil
|
|
}
|
|
|
|
// 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
|
|
var amountCents int
|
|
|
|
switch cycle {
|
|
case BillingYearly:
|
|
periodEnd = now.AddDate(1, 0, 0)
|
|
amountCents = plan.PriceYearly
|
|
default:
|
|
periodEnd = now.AddDate(0, 1, 0)
|
|
amountCents = plan.PriceMonthly
|
|
}
|
|
|
|
sub := &UserSubscription{
|
|
UserID: userID,
|
|
PlanID: plan.ID,
|
|
BillingCycle: cycle,
|
|
CurrentPeriodStart: now,
|
|
CurrentPeriodEnd: periodEnd,
|
|
}
|
|
|
|
var clientSecret, paymentID string
|
|
|
|
// 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.
|
|
// 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 {
|
|
trialEnd := now.AddDate(0, 0, plan.TrialDays)
|
|
sub.Status = StatusTrialing
|
|
sub.TrialStart = &now
|
|
sub.TrialEnd = &trialEnd
|
|
sub.CurrentPeriodEnd = trialEnd
|
|
inTrial = true
|
|
}
|
|
}
|
|
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 + 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,
|
|
AmountCents: amountCents,
|
|
Currency: plan.Currency,
|
|
Status: InvoicePending,
|
|
BillingPeriodStart: now,
|
|
BillingPeriodEnd: periodEnd,
|
|
}
|
|
if err := tx.Create(invoice).Error; err != nil {
|
|
return fmt.Errorf("failed to create invoice: %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)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sub.Plan = *plan
|
|
return &SubscribeResponse{
|
|
Subscription: sub,
|
|
ClientSecret: clientSecret,
|
|
PaymentID: paymentID,
|
|
}, nil
|
|
}
|
|
|
|
// changePlan handles upgrade or downgrade between plans
|
|
func (s *Service) changePlan(ctx context.Context, userID uuid.UUID, current *UserSubscription, newPlan *Plan, cycle BillingCycle) (*SubscribeResponse, error) {
|
|
currentPlan := ¤t.Plan
|
|
if currentPlan.ID == uuid.Nil {
|
|
var err error
|
|
currentPlan, err = s.GetPlan(ctx, current.PlanID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
isUpgrade := newPlan.SortOrder > currentPlan.SortOrder
|
|
|
|
if isUpgrade {
|
|
// Upgrade: takes effect immediately
|
|
return s.upgradeSubscription(ctx, userID, current, newPlan, cycle)
|
|
}
|
|
|
|
// Downgrade: takes effect at end of current period
|
|
return s.scheduleDowngrade(ctx, userID, current, newPlan, cycle)
|
|
}
|
|
|
|
// upgradeSubscription applies an immediate upgrade
|
|
func (s *Service) upgradeSubscription(ctx context.Context, userID uuid.UUID, current *UserSubscription, newPlan *Plan, cycle BillingCycle) (*SubscribeResponse, error) {
|
|
now := time.Now()
|
|
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
// Expire the current subscription
|
|
current.Status = StatusExpired
|
|
if err := tx.Save(current).Error; err != nil {
|
|
return fmt.Errorf("failed to expire current subscription: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.logger.Info("User upgraded subscription",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("from_plan", string(current.Plan.Name)),
|
|
zap.String("to_plan", string(newPlan.Name)),
|
|
zap.Time("upgraded_at", now),
|
|
)
|
|
|
|
return s.createNewSubscription(ctx, userID, newPlan, cycle)
|
|
}
|
|
|
|
// scheduleDowngrade schedules a downgrade at end of current period
|
|
func (s *Service) scheduleDowngrade(ctx context.Context, userID uuid.UUID, current *UserSubscription, newPlan *Plan, cycle BillingCycle) (*SubscribeResponse, error) {
|
|
s.logger.Info("User scheduled downgrade",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("from_plan", string(current.Plan.Name)),
|
|
zap.String("to_plan", string(newPlan.Name)),
|
|
zap.Time("effective_at", current.CurrentPeriodEnd),
|
|
)
|
|
|
|
// For now, we mark the current as cancel_at_period_end and return info
|
|
// The actual downgrade will happen when the period ends (via ProcessExpiredSubscriptions)
|
|
current.CancelAtPeriodEnd = true
|
|
now := time.Now()
|
|
current.CanceledAt = &now
|
|
|
|
if err := s.db.WithContext(ctx).Save(current).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to schedule downgrade: %w", err)
|
|
}
|
|
|
|
current.Plan = *newPlan // indicate the target plan in response
|
|
return &SubscribeResponse{
|
|
Subscription: current,
|
|
}, nil
|
|
}
|
|
|
|
// CancelSubscription cancels a user's subscription at the end of the current period
|
|
func (s *Service) CancelSubscription(ctx context.Context, userID uuid.UUID) (*UserSubscription, error) {
|
|
sub, err := s.GetUserSubscription(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Free plan: cancel immediately
|
|
if sub.Plan.Name == PlanFree {
|
|
return nil, ErrFreePlanNoBilling
|
|
}
|
|
|
|
now := time.Now()
|
|
sub.CancelAtPeriodEnd = true
|
|
sub.CanceledAt = &now
|
|
|
|
if err := s.db.WithContext(ctx).Save(sub).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to cancel subscription: %w", err)
|
|
}
|
|
|
|
s.logger.Info("User canceled subscription",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("plan", string(sub.Plan.Name)),
|
|
zap.Time("access_until", sub.CurrentPeriodEnd),
|
|
)
|
|
|
|
return sub, nil
|
|
}
|
|
|
|
// ReactivateSubscription removes the cancellation flag if still within the period
|
|
func (s *Service) ReactivateSubscription(ctx context.Context, userID uuid.UUID) (*UserSubscription, error) {
|
|
sub, err := s.GetUserSubscription(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !sub.CancelAtPeriodEnd {
|
|
return sub, nil // not canceled, nothing to do
|
|
}
|
|
|
|
sub.CancelAtPeriodEnd = false
|
|
sub.CanceledAt = nil
|
|
|
|
if err := s.db.WithContext(ctx).Save(sub).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to reactivate subscription: %w", err)
|
|
}
|
|
|
|
s.logger.Info("User reactivated subscription",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("plan", string(sub.Plan.Name)),
|
|
)
|
|
|
|
return sub, nil
|
|
}
|
|
|
|
// cancelImmediately expires a subscription right away (used internally for plan switches)
|
|
func (s *Service) cancelImmediately(ctx context.Context, sub *UserSubscription) error {
|
|
now := time.Now()
|
|
sub.Status = StatusExpired
|
|
sub.CanceledAt = &now
|
|
return s.db.WithContext(ctx).Save(sub).Error
|
|
}
|
|
|
|
// GetUserInvoices returns invoices for a user
|
|
func (s *Service) GetUserInvoices(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Invoice, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 20
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
var invoices []Invoice
|
|
err := s.db.WithContext(ctx).
|
|
Where("user_id = ?", userID).
|
|
Order("created_at DESC").
|
|
Limit(limit).
|
|
Offset(offset).
|
|
Find(&invoices).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get invoices: %w", err)
|
|
}
|
|
return invoices, nil
|
|
}
|
|
|
|
// ProcessExpiredSubscriptions checks for subscriptions past their period end and expires them
|
|
// This should be called periodically (e.g., daily cron job)
|
|
func (s *Service) ProcessExpiredSubscriptions(ctx context.Context) (int, error) {
|
|
now := time.Now()
|
|
var count int64
|
|
|
|
// Expire subscriptions that have cancel_at_period_end and period has ended
|
|
result := s.db.WithContext(ctx).
|
|
Model(&UserSubscription{}).
|
|
Where("cancel_at_period_end = ? AND current_period_end < ? AND status IN ?",
|
|
true, now, []string{string(StatusActive), string(StatusTrialing)}).
|
|
Updates(map[string]interface{}{
|
|
"status": StatusExpired,
|
|
"updated_at": now,
|
|
})
|
|
|
|
if result.Error != nil {
|
|
return 0, fmt.Errorf("failed to expire subscriptions: %w", result.Error)
|
|
}
|
|
count = result.RowsAffected
|
|
|
|
// Expire trials that have ended without payment
|
|
trialResult := s.db.WithContext(ctx).
|
|
Model(&UserSubscription{}).
|
|
Where("status = ? AND trial_end < ?", StatusTrialing, now).
|
|
Updates(map[string]interface{}{
|
|
"status": StatusExpired,
|
|
"updated_at": now,
|
|
})
|
|
|
|
if trialResult.Error != nil {
|
|
return int(count), fmt.Errorf("failed to expire trials: %w", trialResult.Error)
|
|
}
|
|
count += trialResult.RowsAffected
|
|
|
|
if count > 0 {
|
|
s.logger.Info("Processed expired subscriptions", zap.Int64("expired_count", count))
|
|
}
|
|
|
|
return int(count), nil
|
|
}
|
|
|
|
// ChangeBillingCycle switches between monthly and yearly billing
|
|
func (s *Service) ChangeBillingCycle(ctx context.Context, userID uuid.UUID, newCycle BillingCycle) (*UserSubscription, error) {
|
|
if newCycle != BillingMonthly && newCycle != BillingYearly {
|
|
return nil, ErrInvalidBillingCycle
|
|
}
|
|
|
|
sub, err := s.GetUserSubscription(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if sub.BillingCycle == newCycle {
|
|
return sub, nil // already on this cycle
|
|
}
|
|
|
|
sub.BillingCycle = newCycle
|
|
// The new cycle takes effect at the next renewal
|
|
if err := s.db.WithContext(ctx).Save(sub).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to change billing cycle: %w", err)
|
|
}
|
|
|
|
s.logger.Info("User changed billing cycle",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("new_cycle", string(newCycle)),
|
|
)
|
|
|
|
return sub, nil
|
|
}
|