| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | // Package stripebilling contains the Stripe-specific edge of the billing |
| 4 | // system. Local subscription state stays in internal/billing; this package |
| 5 | // owns hosted Checkout, Billing Portal, seat quantity updates, and webhook |
| 6 | // signature verification. |
| 7 | package stripebilling |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | |
| 16 | stripeapi "github.com/stripe/stripe-go/v85" |
| 17 | "github.com/stripe/stripe-go/v85/webhook" |
| 18 | ) |
| 19 | |
| 20 | const ( |
| 21 | // MetadataOrgID and MetadataOrgSlug are the legacy SP03 keys. |
| 22 | // PRO04 introduces MetadataSubjectKind and MetadataSubjectID; |
| 23 | // new subscriptions stamp both for forward compatibility, the |
| 24 | // webhook resolver falls back to the legacy keys for org rows |
| 25 | // created before PRO04 deployed. |
| 26 | MetadataOrgID = "shithub_org_id" |
| 27 | MetadataOrgSlug = "shithub_org_slug" |
| 28 | MetadataSubjectKind = "shithub_subject_kind" |
| 29 | MetadataSubjectID = "shithub_subject_id" |
| 30 | MetadataSubjectLabel = "shithub_subject_label" // human-readable, e.g. org slug or username |
| 31 | ) |
| 32 | |
| 33 | // SubjectKind mirrors billing.SubjectKind without taking a hard |
| 34 | // dependency on the billing package (avoid import cycle). |
| 35 | // Conversions live in the webhook handler. |
| 36 | type SubjectKind string |
| 37 | |
| 38 | const ( |
| 39 | SubjectKindUser SubjectKind = "user" |
| 40 | SubjectKindOrg SubjectKind = "org" |
| 41 | ) |
| 42 | |
| 43 | var ( |
| 44 | ErrSecretKeyRequired = errors.New("stripe billing: secret key is required") |
| 45 | ErrWebhookSecretRequired = errors.New("stripe billing: webhook secret is required") |
| 46 | ErrTeamPriceRequired = errors.New("stripe billing: team price id is required") |
| 47 | ErrProPriceRequired = errors.New("stripe billing: pro price id is required") |
| 48 | ErrCustomerIDRequired = errors.New("stripe billing: customer id is required") |
| 49 | ErrSubscriptionItemID = errors.New("stripe billing: subscription item id is required") |
| 50 | ErrURLRequired = errors.New("stripe billing: redirect url is required") |
| 51 | ErrInvalidSubjectKind = errors.New("stripe billing: invalid subject kind") |
| 52 | ) |
| 53 | |
| 54 | type Config struct { |
| 55 | SecretKey string |
| 56 | WebhookSecret string |
| 57 | TeamPriceID string |
| 58 | ProPriceID string // PRO04: required once user-tier path is enabled |
| 59 | AutomaticTax bool |
| 60 | } |
| 61 | |
| 62 | type Remote interface { |
| 63 | CreateCustomer(context.Context, CustomerInput) (Customer, error) |
| 64 | CreateCheckoutSession(context.Context, CheckoutInput) (CheckoutSession, error) |
| 65 | CreatePortalSession(context.Context, PortalInput) (PortalSession, error) |
| 66 | UpdateSubscriptionItemQuantity(context.Context, SeatQuantityInput) error |
| 67 | VerifyWebhook(payload []byte, signatureHeader string) (stripeapi.Event, error) |
| 68 | } |
| 69 | |
| 70 | type Client struct { |
| 71 | stripe *stripeapi.Client |
| 72 | webhookSecret string |
| 73 | teamPriceID string |
| 74 | proPriceID string |
| 75 | automaticTax bool |
| 76 | } |
| 77 | |
| 78 | // CustomerInput carries enough subject context to populate Stripe |
| 79 | // metadata for new customer records. The legacy `OrgID`/`OrgSlug` |
| 80 | // pair stays for backward compatibility with SP03 callers; PRO04 |
| 81 | // callers populate `Kind` + `SubjectID` + `Label`, and the |
| 82 | // metadata stamps both old and new keys. |
| 83 | type CustomerInput struct { |
| 84 | OrgID int64 |
| 85 | OrgSlug string |
| 86 | OrgName string |
| 87 | Email string |
| 88 | |
| 89 | Kind SubjectKind // PRO04: "user" | "org" |
| 90 | SubjectID int64 // PRO04: user id or org id |
| 91 | Label string // PRO04: human-readable (org slug or username) |
| 92 | } |
| 93 | |
| 94 | type Customer struct { |
| 95 | ID string |
| 96 | } |
| 97 | |
| 98 | type CheckoutInput struct { |
| 99 | OrgID int64 |
| 100 | OrgSlug string |
| 101 | CustomerID string |
| 102 | SeatCount int64 |
| 103 | SuccessURL string |
| 104 | CancelURL string |
| 105 | |
| 106 | Kind SubjectKind // PRO04: routes to TeamPriceID (org) or ProPriceID (user) |
| 107 | SubjectID int64 // PRO04: user id or org id |
| 108 | Label string // PRO04: human-readable for metadata.shithub_subject_label |
| 109 | } |
| 110 | |
| 111 | type CheckoutSession struct { |
| 112 | ID string |
| 113 | URL string |
| 114 | } |
| 115 | |
| 116 | type PortalInput struct { |
| 117 | CustomerID string |
| 118 | ReturnURL string |
| 119 | } |
| 120 | |
| 121 | type PortalSession struct { |
| 122 | ID string |
| 123 | URL string |
| 124 | } |
| 125 | |
| 126 | type SeatQuantityInput struct { |
| 127 | OrgID int64 |
| 128 | SubscriptionItemID string |
| 129 | Quantity int64 |
| 130 | } |
| 131 | |
| 132 | func New(cfg Config) (*Client, error) { |
| 133 | cfg.SecretKey = strings.TrimSpace(cfg.SecretKey) |
| 134 | cfg.WebhookSecret = strings.TrimSpace(cfg.WebhookSecret) |
| 135 | cfg.TeamPriceID = strings.TrimSpace(cfg.TeamPriceID) |
| 136 | cfg.ProPriceID = strings.TrimSpace(cfg.ProPriceID) |
| 137 | if cfg.SecretKey == "" { |
| 138 | return nil, ErrSecretKeyRequired |
| 139 | } |
| 140 | if cfg.WebhookSecret == "" { |
| 141 | return nil, ErrWebhookSecretRequired |
| 142 | } |
| 143 | if cfg.TeamPriceID == "" { |
| 144 | return nil, ErrTeamPriceRequired |
| 145 | } |
| 146 | // ProPriceID is optional at construction (operators on the |
| 147 | // SP-only path don't have it). Pro-path callers will get |
| 148 | // ErrProPriceRequired at CheckoutSession time if they try to |
| 149 | // route a user-kind checkout against a client that lacks the |
| 150 | // price id. |
| 151 | return &Client{ |
| 152 | stripe: stripeapi.NewClient(cfg.SecretKey), |
| 153 | webhookSecret: cfg.WebhookSecret, |
| 154 | teamPriceID: cfg.TeamPriceID, |
| 155 | proPriceID: cfg.ProPriceID, |
| 156 | automaticTax: cfg.AutomaticTax, |
| 157 | }, nil |
| 158 | } |
| 159 | |
| 160 | // SupportsPro reports whether the client was configured with a Pro |
| 161 | // price. Wiring code uses this to decide whether to register the |
| 162 | // user-tier checkout/portal routes; refusing the routes when Pro |
| 163 | // isn't configured keeps the operator-disabled path consistent. |
| 164 | func (c *Client) SupportsPro() bool { return c.proPriceID != "" } |
| 165 | |
| 166 | func (c *Client) CreateCustomer(ctx context.Context, in CustomerInput) (Customer, error) { |
| 167 | // PRO04: subject-aware customer creation. When `in.Kind` is set |
| 168 | // the new metadata keys + idempotency-key prefix include the |
| 169 | // kind; legacy SP03 callers (no Kind set) keep the org-only |
| 170 | // behavior so existing org customers don't get duplicated. |
| 171 | kind, subjectID, label, err := normalizeSubject(in.Kind, in.SubjectID, in.Label, in.OrgID, in.OrgSlug) |
| 172 | if err != nil { |
| 173 | return Customer{}, err |
| 174 | } |
| 175 | name := strings.TrimSpace(in.OrgName) |
| 176 | if name == "" { |
| 177 | name = label |
| 178 | } |
| 179 | descriptor := fmt.Sprintf("shithub %s %s", kind, label) |
| 180 | params := &stripeapi.CustomerCreateParams{ |
| 181 | Name: stripeapi.String(name), |
| 182 | Description: stripeapi.String(descriptor), |
| 183 | Metadata: subjectMetadata(kind, subjectID, label, in.OrgID, in.OrgSlug), |
| 184 | } |
| 185 | if email := strings.TrimSpace(in.Email); email != "" { |
| 186 | params.Email = stripeapi.String(email) |
| 187 | } |
| 188 | params.SetIdempotencyKey(idempotencyKey("customer", string(kind), subjectID, "v1")) |
| 189 | customer, err := c.stripe.V1Customers.Create(ctx, params) |
| 190 | if err != nil { |
| 191 | return Customer{}, err |
| 192 | } |
| 193 | return Customer{ID: customer.ID}, nil |
| 194 | } |
| 195 | |
| 196 | func (c *Client) CreateCheckoutSession(ctx context.Context, in CheckoutInput) (CheckoutSession, error) { |
| 197 | in.CustomerID = strings.TrimSpace(in.CustomerID) |
| 198 | if in.CustomerID == "" { |
| 199 | return CheckoutSession{}, ErrCustomerIDRequired |
| 200 | } |
| 201 | in.SuccessURL = strings.TrimSpace(in.SuccessURL) |
| 202 | if in.SuccessURL == "" { |
| 203 | return CheckoutSession{}, fmt.Errorf("%w: success_url", ErrURLRequired) |
| 204 | } |
| 205 | in.CancelURL = strings.TrimSpace(in.CancelURL) |
| 206 | if in.CancelURL == "" { |
| 207 | return CheckoutSession{}, fmt.Errorf("%w: cancel_url", ErrURLRequired) |
| 208 | } |
| 209 | kind, subjectID, label, err := normalizeSubject(in.Kind, in.SubjectID, in.Label, in.OrgID, in.OrgSlug) |
| 210 | if err != nil { |
| 211 | return CheckoutSession{}, err |
| 212 | } |
| 213 | // Route price + quantity by kind. Pro is single-seat; Team |
| 214 | // quantity equals the caller-provided seat count. |
| 215 | var priceID string |
| 216 | var quantity int64 |
| 217 | switch kind { |
| 218 | case SubjectKindOrg: |
| 219 | priceID = c.teamPriceID |
| 220 | quantity = in.SeatCount |
| 221 | if quantity < 1 { |
| 222 | quantity = 1 |
| 223 | } |
| 224 | case SubjectKindUser: |
| 225 | if c.proPriceID == "" { |
| 226 | return CheckoutSession{}, ErrProPriceRequired |
| 227 | } |
| 228 | priceID = c.proPriceID |
| 229 | quantity = 1 |
| 230 | default: |
| 231 | return CheckoutSession{}, fmt.Errorf("%w: %q", ErrInvalidSubjectKind, kind) |
| 232 | } |
| 233 | metadata := subjectMetadata(kind, subjectID, label, in.OrgID, in.OrgSlug) |
| 234 | mode := string(stripeapi.CheckoutSessionModeSubscription) |
| 235 | paymentMethodCollection := string(stripeapi.CheckoutSessionPaymentMethodCollectionAlways) |
| 236 | billingAddressCollection := string(stripeapi.CheckoutSessionBillingAddressCollectionAuto) |
| 237 | params := &stripeapi.CheckoutSessionCreateParams{ |
| 238 | Mode: stripeapi.String(mode), |
| 239 | Customer: stripeapi.String(in.CustomerID), |
| 240 | ClientReferenceID: stripeapi.String(strconv.FormatInt(subjectID, 10)), |
| 241 | SuccessURL: stripeapi.String(in.SuccessURL), |
| 242 | CancelURL: stripeapi.String(in.CancelURL), |
| 243 | PaymentMethodCollection: stripeapi.String(paymentMethodCollection), |
| 244 | BillingAddressCollection: stripeapi.String(billingAddressCollection), |
| 245 | LineItems: []*stripeapi.CheckoutSessionCreateLineItemParams{{ |
| 246 | Price: stripeapi.String(priceID), |
| 247 | Quantity: stripeapi.Int64(quantity), |
| 248 | }}, |
| 249 | Metadata: metadata, |
| 250 | SubscriptionData: &stripeapi.CheckoutSessionCreateSubscriptionDataParams{ |
| 251 | Metadata: metadata, |
| 252 | }, |
| 253 | } |
| 254 | if c.automaticTax { |
| 255 | params.AutomaticTax = &stripeapi.CheckoutSessionCreateAutomaticTaxParams{ |
| 256 | Enabled: stripeapi.Bool(true), |
| 257 | } |
| 258 | } |
| 259 | params.SetIdempotencyKey(idempotencyKey("checkout", string(kind), subjectID, planLabelForKind(kind), strconv.FormatInt(quantity, 10))) |
| 260 | session, err := c.stripe.V1CheckoutSessions.Create(ctx, params) |
| 261 | if err != nil { |
| 262 | return CheckoutSession{}, err |
| 263 | } |
| 264 | return CheckoutSession{ID: session.ID, URL: session.URL}, nil |
| 265 | } |
| 266 | |
| 267 | func (c *Client) CreatePortalSession(ctx context.Context, in PortalInput) (PortalSession, error) { |
| 268 | in.CustomerID = strings.TrimSpace(in.CustomerID) |
| 269 | if in.CustomerID == "" { |
| 270 | return PortalSession{}, ErrCustomerIDRequired |
| 271 | } |
| 272 | in.ReturnURL = strings.TrimSpace(in.ReturnURL) |
| 273 | if in.ReturnURL == "" { |
| 274 | return PortalSession{}, fmt.Errorf("%w: portal_return_url", ErrURLRequired) |
| 275 | } |
| 276 | params := &stripeapi.BillingPortalSessionCreateParams{ |
| 277 | Customer: stripeapi.String(in.CustomerID), |
| 278 | ReturnURL: stripeapi.String(in.ReturnURL), |
| 279 | } |
| 280 | session, err := c.stripe.V1BillingPortalSessions.Create(ctx, params) |
| 281 | if err != nil { |
| 282 | return PortalSession{}, err |
| 283 | } |
| 284 | return PortalSession{ID: session.ID, URL: session.URL}, nil |
| 285 | } |
| 286 | |
| 287 | func (c *Client) UpdateSubscriptionItemQuantity(ctx context.Context, in SeatQuantityInput) error { |
| 288 | in.SubscriptionItemID = strings.TrimSpace(in.SubscriptionItemID) |
| 289 | if in.SubscriptionItemID == "" { |
| 290 | return ErrSubscriptionItemID |
| 291 | } |
| 292 | if in.Quantity < 1 { |
| 293 | in.Quantity = 1 |
| 294 | } |
| 295 | params := &stripeapi.SubscriptionItemUpdateParams{ |
| 296 | Quantity: stripeapi.Int64(in.Quantity), |
| 297 | } |
| 298 | params.SetIdempotencyKey(idempotencyKey("seat-sync", in.OrgID, in.SubscriptionItemID, strconv.FormatInt(in.Quantity, 10))) |
| 299 | _, err := c.stripe.V1SubscriptionItems.Update(ctx, in.SubscriptionItemID, params) |
| 300 | return err |
| 301 | } |
| 302 | |
| 303 | func (c *Client) VerifyWebhook(payload []byte, signatureHeader string) (stripeapi.Event, error) { |
| 304 | return webhook.ConstructEvent(payload, signatureHeader, c.webhookSecret) |
| 305 | } |
| 306 | |
| 307 | // normalizeSubject resolves the (kind, id, label) tuple from a |
| 308 | // CheckoutInput / CustomerInput. Legacy SP03 callers populate only |
| 309 | // OrgID/OrgSlug and zero Kind — those default to SubjectKindOrg |
| 310 | // for backward compatibility. PRO04 callers populate Kind + |
| 311 | // SubjectID + Label and may leave OrgID/OrgSlug zero (user kind). |
| 312 | // |
| 313 | // Cross-checks: if both legacy OrgID and explicit user-kind |
| 314 | // SubjectID are set, the explicit Kind wins; the OrgID stays in |
| 315 | // the metadata for audit but the routing key is Kind+SubjectID. |
| 316 | func normalizeSubject(kind SubjectKind, subjectID int64, label string, orgID int64, orgSlug string) (SubjectKind, int64, string, error) { |
| 317 | if kind == "" { |
| 318 | // Legacy path: org-only. |
| 319 | if orgID <= 0 { |
| 320 | return "", 0, "", fmt.Errorf("%w: %q", ErrInvalidSubjectKind, kind) |
| 321 | } |
| 322 | return SubjectKindOrg, orgID, strings.TrimSpace(orgSlug), nil |
| 323 | } |
| 324 | if kind != SubjectKindOrg && kind != SubjectKindUser { |
| 325 | return "", 0, "", fmt.Errorf("%w: %q", ErrInvalidSubjectKind, kind) |
| 326 | } |
| 327 | if subjectID <= 0 { |
| 328 | // Fall back to OrgID for org kind when caller forgot to set |
| 329 | // SubjectID explicitly; tightens the API without breaking |
| 330 | // the SP03→PRO04 transition. |
| 331 | if kind == SubjectKindOrg && orgID > 0 { |
| 332 | subjectID = orgID |
| 333 | } else { |
| 334 | return "", 0, "", fmt.Errorf("%w: subject id required for kind %q", ErrInvalidSubjectKind, kind) |
| 335 | } |
| 336 | } |
| 337 | label = strings.TrimSpace(label) |
| 338 | if label == "" && kind == SubjectKindOrg { |
| 339 | label = strings.TrimSpace(orgSlug) |
| 340 | } |
| 341 | return kind, subjectID, label, nil |
| 342 | } |
| 343 | |
| 344 | // subjectMetadata returns the Stripe metadata map stamped on |
| 345 | // customers, checkout sessions, and subscription objects. Both the |
| 346 | // new MetadataSubject* keys and the legacy MetadataOrg* keys are |
| 347 | // emitted when the kind is org — this keeps SP03-deployed webhook |
| 348 | // resolvers working through the transition. User-kind metadata |
| 349 | // omits the legacy keys (no legacy resolver expected them). |
| 350 | func subjectMetadata(kind SubjectKind, subjectID int64, label string, orgID int64, orgSlug string) map[string]string { |
| 351 | m := map[string]string{ |
| 352 | MetadataSubjectKind: string(kind), |
| 353 | MetadataSubjectID: strconv.FormatInt(subjectID, 10), |
| 354 | MetadataSubjectLabel: label, |
| 355 | } |
| 356 | if kind == SubjectKindOrg { |
| 357 | if orgID == 0 { |
| 358 | orgID = subjectID |
| 359 | } |
| 360 | if orgSlug == "" { |
| 361 | orgSlug = label |
| 362 | } |
| 363 | m[MetadataOrgID] = strconv.FormatInt(orgID, 10) |
| 364 | m[MetadataOrgSlug] = strings.TrimSpace(orgSlug) |
| 365 | } |
| 366 | return m |
| 367 | } |
| 368 | |
| 369 | // planLabelForKind returns the marketing label baked into the |
| 370 | // idempotency key. Keeps Pro and Team checkouts from colliding on |
| 371 | // the same idempotency string even if a single subject ID exists |
| 372 | // on both subject_kind tables (which the schema prohibits, but |
| 373 | // defense-in-depth). |
| 374 | func planLabelForKind(kind SubjectKind) string { |
| 375 | switch kind { |
| 376 | case SubjectKindUser: |
| 377 | return "pro" |
| 378 | case SubjectKindOrg: |
| 379 | return "team" |
| 380 | } |
| 381 | return string(kind) |
| 382 | } |
| 383 | |
| 384 | func idempotencyKey(parts ...any) string { |
| 385 | var b strings.Builder |
| 386 | b.WriteString("shithub") |
| 387 | for _, part := range parts { |
| 388 | b.WriteByte(':') |
| 389 | b.WriteString(strings.NewReplacer(":", "_", " ", "_", "/", "_").Replace(fmt.Sprint(part))) |
| 390 | } |
| 391 | return b.String() |
| 392 | } |
| 393 |