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, persistusers.stripe_customer_id, then create a hosted checkout session for themonthlyoryearlyprice IDCreatePortalSession(user)— open the Stripe-hosted billing portal so the user can manage their subscription / payment methodsHandleWebhook(payload, signature)— verify the signature viawebhook.ConstructEventWithOptions(withIgnoreAPIVersionMismatchto tolerate Stripe CLI dev fixtures), dispatch by event typehandleSubscriptionUpdated(sub)— look up the user bystripe_customer_id, updatesubscription_status(active,past_due,canceled, etc.),subscription_plan(pro), andsubscription_endfromCurrentPeriodEndDeleteCustomer(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¶
POST /api/v1/payment/webhook
io.ReadAll, max 64 KB
webhook.ConstructEvent — signature check
FindByStripeCustomerID → UPDATE status / plan / period_end
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.UserStore—FindByStripeCustomerID,Updateauth.AuthService—GetUserByID(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¶
backend/internal/services/payment/service.gobackend/internal/services/payment/stripe_client.gobackend/internal/services/payment/handler.gobackend/internal/services/payment/routes.go—RegisterRoutes(authenticated) +RegisterWebhookRoute(public, signature-verified)backend/internal/services/payment/service_test.go—StripeClientmock examples