Skip to content

Payments

Purpose

The payment domain (backend/internal/services/payment/) 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 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.ConstructEventWithOptions (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
  • DeleteCustomer(customerID) — erase the customer at Stripe when an account is purged, taking its email, name, billing history and any active subscription with it

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 Service struct {
    config   *config.StripeConfig
    userRepo user.UserStore
    client   StripeClient
}

type StripeClient interface {
    CreateCustomer(*stripe.CustomerParams) (*stripe.Customer, error)
    CreateCheckoutSession(*stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error)
    CreatePortalSession(*stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error)
    DeleteCustomer(customerID string) (*stripe.Customer, 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

Stripe
POST /api/v1/payment/webhook
↓ body + Stripe-Signature
tomoda-backend
PaymentHandler
io.ReadAll, max 64 KB
PaymentService.HandleWebhook
webhook.ConstructEvent — signature check
↓ switch event.Type
handleSubscriptionUpdated
users
FindByStripeCustomerID → UPDATE status / plan / period_end
↓ 200 OK
Stripe
Signature verification is the first gate — DB is only touched after ConstructEvent succeeds.

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. Linked to a stripe.Customer, and the only handle the account purge has for erasing that 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

  • user.UserStoreFindByStripeCustomerID, Update
  • auth.AuthServiceGetUserByID (the handler resolves the caller before calling the service)
  • config.StripeConfig — secrets, price IDs, success/cancel URLs
  • External: Stripe (github.com/stripe/stripe-go/v76)
  • The Stripe price IDs themselves are configured per environment (see Price ID seeding)

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.

Account purge

Erasure obligations reach the processor, not just our database. When the purge worker hard-deletes an account it calls Service.DeleteCustomer with the row's stripe_customer_id, which issues DELETE /v1/customers/{id}. Stripe erases the customer's email, name and billing history, and immediately cancels any active subscription on it.

The user domain consumes this through its own BillingTeardown port (backend/internal/services/user/deps.go), bound to *payment.Service in backend/internal/wiring/providers.go.

DeleteCustomer is idempotent by design, since the purge is retried daily until it succeeds:

  • An empty customer id (the user never reached checkout) is a no-op success.
  • A customer Stripe no longer knows about is a no-op success, matched on the typed *stripe.Error (resource_missing, or a bare 404) rather than the message text.
  • Any other Stripe error propagates, which aborts the purge and leaves the account for the next run.

Cancellation forfeits the remaining paid period

Deleting the customer cancels an active subscription immediately with no proration and no refund. A user who deletes their account mid-cycle loses the rest of the period they paid for. Issuing a refund is a deliberate product decision and is not implemented; if that changes, it belongs in the purge path before the customer is deleted, since the customer is unrecoverable afterwards.

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

Each environment needs a Pro Plan product with monthly ($29.00) and yearly ($290.00) recurring prices, created once in the Stripe dashboard (or via the Stripe CLI) when standing Stripe up. Put the resulting price IDs in backend/config.yaml under stripe.price_ids; they feed config.StripeConfig.PriceIDs, which PaymentService.CreateCheckoutSession reads at request time.

Where to look