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_endfromCurrentPeriodEnd
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)
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, 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¶
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 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:
- Creates a
Pro Planproduct on Stripe - Creates monthly ($29.00) and yearly ($290.00) recurring prices
- Prints YAML snippets to paste into
backend/config.yamlunderstripe.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¶
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/scripts/create_stripe_prices.go— one-shot price seederbackend/scripts/setup_stripe.sh— convenience wrapperbackend/internal/services/payment/service_test.go—StripeClientmock examples