Go · 13779 bytes Raw Blame History
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