Skip to content

Payments

Purpose

PaymentService is the Stripe integration. It mints checkout sessions (when a user upgrades to Pro), opens the Stripe customer portal (so they can manage / cancel), and consumes Stripe webhooks to keep users.subscription_status, users.subscription_plan, and users.subscription_end in sync with Stripe's source of truth. The Stripe client lives behind a small StripeClient interface so the service can be unit-tested with a mock (see payment_service_test.go).

Responsibilities

  • CreateCheckoutSession(user, planType) — lazily provision a Stripe customer (stripe.Customer) on first paid action, persist users.stripe_customer_id, then create a hosted checkout session for the monthly or yearly price ID
  • CreatePortalSession(user) — open the Stripe-hosted billing portal so the user can manage their subscription / payment methods
  • HandleWebhook(payload, signature) — verify the signature via webhook.ConstructEvent (with IgnoreAPIVersionMismatch to tolerate Stripe CLI dev fixtures), dispatch by event type
  • handleSubscriptionUpdated(sub) — look up the user by stripe_customer_id, update subscription_status (active, past_due, canceled, etc.), subscription_plan (pro), and subscription_end from CurrentPeriodEnd

Subscribed events: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted.

HTTP endpoints

Method Path Auth Description
POST /api/v1/auth/payment/create-checkout-session JWT Body { "plan": "monthly" \| "yearly" }. Returns { "url": "https://checkout.stripe.com/..." }
POST /api/v1/auth/payment/create-portal-session JWT Returns { "url": "https://billing.stripe.com/..." }. Errors if the user has no Stripe customer yet
POST /api/v1/payment/webhook none (signed) Stripe → us. Body limited to 64 KB. Validates Stripe-Signature header

Key types

type PaymentService struct {
    config   *config.StripeConfig
    userRepo repository.UserRepository
    client   StripeClient
}

type StripeClient interface {
    CreateCustomer(*stripe.CustomerParams) (*stripe.Customer, error)
    CreateCheckoutSession(*stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error)
    CreatePortalSession(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error)
    ConstructEvent(payload []byte, header, secret string) (stripe.Event, error)
}

// config.StripeConfig (paraphrased from usage)
type StripeConfig struct {
    SecretKey     string
    WebhookSecret string
    SuccessURL    string
    CancelURL     string
    PriceIDs struct {
        Monthly string
        Yearly  string
    }
}

Webhook flow

sequenceDiagram
    participant S as Stripe
    participant H as PaymentHandler
    participant P as PaymentService
    participant DB as UserRepository
    S->>H: POST /api/v1/payment/webhook (body + Stripe-Signature)
    H->>H: io.ReadAll (max 64 KB)
    H->>P: HandleWebhook(payload, signature)
    P->>P: webhook.ConstructEvent (signature check)
    P->>P: switch event.Type → subscription handler
    P->>DB: FindByStripeCustomerID(customer.id)
    P->>DB: Update user with status / plan / period_end
    P-->>H: nil
    H-->>S: 200 OK

Data model

The users table carries every billing field. There is no separate subscriptions table — Stripe is the system of record and we mirror the minimum we need.

Column Used for
stripe_customer_id Set on first checkout, never cleared. Linked to a stripe.Customer.
subscription_status Mirrors stripe.Subscription.Status (active / past_due / canceled / etc.)
subscription_plan Currently always "pro" once activated — expand if you add plans
subscription_end time.Unix(sub.CurrentPeriodEnd, 0) — used by the app to gate Pro features

Dependencies

  • repository.UserRepositoryFindByStripeCustomerID, Update
  • config.StripeConfig — secrets, price IDs, success/cancel URLs
  • External: Stripe (github.com/stripe/stripe-go/v76)
  • The Stripe price IDs themselves are seeded by backend/scripts/create_stripe_prices.go

Notable behavior

Webhook signature verification is mandatory

HandleWebhook returns an error before touching DB if webhook.ConstructEvent fails — Stripe must send a valid Stripe-Signature header. In dev, the API-version mismatch tolerance is enabled (IgnoreAPIVersionMismatch: true) so fixtures from stripe trigger work even when the local SDK lags the CLI.

Lazy customer creation

A user only gets a stripe.Customer row created the first time they hit CreateCheckoutSession. Until then users.stripe_customer_id is empty, and calling CreatePortalSession returns an error — clients should hide the portal CTA for non-subscribers.

Zero-timestamp safeguard

Stripe test fixtures sometimes have CurrentPeriodEnd = 0. The handler papers over that by setting SubscriptionEnd = now + 1 month so dev/staging environments don't end up with a 1970 expiry.

Price ID seeding

backend/scripts/create_stripe_prices.go is a one-shot CLI that:

  1. Creates a Pro Plan product on Stripe
  2. Creates monthly ($29.00) and yearly ($290.00) recurring prices
  3. Prints YAML snippets to paste into backend/config.yaml under stripe.price_ids

Run it once per environment when standing Stripe up. The produced price IDs feed config.StripeConfig.PriceIDs which PaymentService.CreateCheckoutSession reads at request time.

Where to look