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, 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.ConstructEvent(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 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.UserRepository—FindByStripeCustomerID,Updateconfig.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/stripe_client.gobackend/internal/handlers/payment_handler.gobackend/internal/webhooks/service.go— user-defined outbound webhooks (unrelated to Stripe; HMAC-signed, SSRF-protected)backend/scripts/create_stripe_prices.go— one-shot price seederbackend/scripts/setup_stripe.sh— convenience wrapperbackend/internal/services/payment_service_test.go—StripeClientmock examples