@@ -18,23 +18,44 @@ import ( |
| 18 | 18 | ) |
| 19 | 19 | |
| 20 | 20 | const ( |
| 21 | | - MetadataOrgID = "shithub_org_id" |
| 22 | | - MetadataOrgSlug = "shithub_org_slug" |
| 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" |
| 23 | 41 | ) |
| 24 | 42 | |
| 25 | 43 | var ( |
| 26 | 44 | ErrSecretKeyRequired = errors.New("stripe billing: secret key is required") |
| 27 | 45 | ErrWebhookSecretRequired = errors.New("stripe billing: webhook secret is required") |
| 28 | 46 | ErrTeamPriceRequired = errors.New("stripe billing: team price id is required") |
| 47 | + ErrProPriceRequired = errors.New("stripe billing: pro price id is required") |
| 29 | 48 | ErrCustomerIDRequired = errors.New("stripe billing: customer id is required") |
| 30 | 49 | ErrSubscriptionItemID = errors.New("stripe billing: subscription item id is required") |
| 31 | 50 | ErrURLRequired = errors.New("stripe billing: redirect url is required") |
| 51 | + ErrInvalidSubjectKind = errors.New("stripe billing: invalid subject kind") |
| 32 | 52 | ) |
| 33 | 53 | |
| 34 | 54 | type Config struct { |
| 35 | 55 | SecretKey string |
| 36 | 56 | WebhookSecret string |
| 37 | 57 | TeamPriceID string |
| 58 | + ProPriceID string // PRO04: required once user-tier path is enabled |
| 38 | 59 | AutomaticTax bool |
| 39 | 60 | } |
| 40 | 61 | |
@@ -50,14 +71,24 @@ type Client struct { |
| 50 | 71 | stripe *stripeapi.Client |
| 51 | 72 | webhookSecret string |
| 52 | 73 | teamPriceID string |
| 74 | + proPriceID string |
| 53 | 75 | automaticTax bool |
| 54 | 76 | } |
| 55 | 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. |
| 56 | 83 | type CustomerInput struct { |
| 57 | 84 | OrgID int64 |
| 58 | 85 | OrgSlug string |
| 59 | 86 | OrgName string |
| 60 | 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) |
| 61 | 92 | } |
| 62 | 93 | |
| 63 | 94 | type Customer struct { |
@@ -71,6 +102,10 @@ type CheckoutInput struct { |
| 71 | 102 | SeatCount int64 |
| 72 | 103 | SuccessURL string |
| 73 | 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 |
| 74 | 109 | } |
| 75 | 110 | |
| 76 | 111 | type CheckoutSession struct { |
@@ -98,6 +133,7 @@ func New(cfg Config) (*Client, error) { |
| 98 | 133 | cfg.SecretKey = strings.TrimSpace(cfg.SecretKey) |
| 99 | 134 | cfg.WebhookSecret = strings.TrimSpace(cfg.WebhookSecret) |
| 100 | 135 | cfg.TeamPriceID = strings.TrimSpace(cfg.TeamPriceID) |
| 136 | + cfg.ProPriceID = strings.TrimSpace(cfg.ProPriceID) |
| 101 | 137 | if cfg.SecretKey == "" { |
| 102 | 138 | return nil, ErrSecretKeyRequired |
| 103 | 139 | } |
@@ -107,28 +143,49 @@ func New(cfg Config) (*Client, error) { |
| 107 | 143 | if cfg.TeamPriceID == "" { |
| 108 | 144 | return nil, ErrTeamPriceRequired |
| 109 | 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. |
| 110 | 151 | return &Client{ |
| 111 | 152 | stripe: stripeapi.NewClient(cfg.SecretKey), |
| 112 | 153 | webhookSecret: cfg.WebhookSecret, |
| 113 | 154 | teamPriceID: cfg.TeamPriceID, |
| 155 | + proPriceID: cfg.ProPriceID, |
| 114 | 156 | automaticTax: cfg.AutomaticTax, |
| 115 | 157 | }, nil |
| 116 | 158 | } |
| 117 | 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 | + |
| 118 | 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 | + } |
| 119 | 175 | name := strings.TrimSpace(in.OrgName) |
| 120 | 176 | if name == "" { |
| 121 | | - name = strings.TrimSpace(in.OrgSlug) |
| 177 | + name = label |
| 122 | 178 | } |
| 179 | + descriptor := fmt.Sprintf("shithub %s %s", kind, label) |
| 123 | 180 | params := &stripeapi.CustomerCreateParams{ |
| 124 | 181 | Name: stripeapi.String(name), |
| 125 | | - Description: stripeapi.String(fmt.Sprintf("shithub organization %s", strings.TrimSpace(in.OrgSlug))), |
| 126 | | - Metadata: orgMetadata(in.OrgID, in.OrgSlug), |
| 182 | + Description: stripeapi.String(descriptor), |
| 183 | + Metadata: subjectMetadata(kind, subjectID, label, in.OrgID, in.OrgSlug), |
| 127 | 184 | } |
| 128 | 185 | if email := strings.TrimSpace(in.Email); email != "" { |
| 129 | 186 | params.Email = stripeapi.String(email) |
| 130 | 187 | } |
| 131 | | - params.SetIdempotencyKey(idempotencyKey("customer", in.OrgID, "v1")) |
| 188 | + params.SetIdempotencyKey(idempotencyKey("customer", string(kind), subjectID, "v1")) |
| 132 | 189 | customer, err := c.stripe.V1Customers.Create(ctx, params) |
| 133 | 190 | if err != nil { |
| 134 | 191 | return Customer{}, err |
@@ -149,24 +206,45 @@ func (c *Client) CreateCheckoutSession(ctx context.Context, in CheckoutInput) (C |
| 149 | 206 | if in.CancelURL == "" { |
| 150 | 207 | return CheckoutSession{}, fmt.Errorf("%w: cancel_url", ErrURLRequired) |
| 151 | 208 | } |
| 152 | | - if in.SeatCount < 1 { |
| 153 | | - in.SeatCount = 1 |
| 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) |
| 154 | 232 | } |
| 155 | | - metadata := orgMetadata(in.OrgID, in.OrgSlug) |
| 233 | + metadata := subjectMetadata(kind, subjectID, label, in.OrgID, in.OrgSlug) |
| 156 | 234 | mode := string(stripeapi.CheckoutSessionModeSubscription) |
| 157 | 235 | paymentMethodCollection := string(stripeapi.CheckoutSessionPaymentMethodCollectionAlways) |
| 158 | 236 | billingAddressCollection := string(stripeapi.CheckoutSessionBillingAddressCollectionAuto) |
| 159 | 237 | params := &stripeapi.CheckoutSessionCreateParams{ |
| 160 | 238 | Mode: stripeapi.String(mode), |
| 161 | 239 | Customer: stripeapi.String(in.CustomerID), |
| 162 | | - ClientReferenceID: stripeapi.String(strconv.FormatInt(in.OrgID, 10)), |
| 240 | + ClientReferenceID: stripeapi.String(strconv.FormatInt(subjectID, 10)), |
| 163 | 241 | SuccessURL: stripeapi.String(in.SuccessURL), |
| 164 | 242 | CancelURL: stripeapi.String(in.CancelURL), |
| 165 | 243 | PaymentMethodCollection: stripeapi.String(paymentMethodCollection), |
| 166 | 244 | BillingAddressCollection: stripeapi.String(billingAddressCollection), |
| 167 | 245 | LineItems: []*stripeapi.CheckoutSessionCreateLineItemParams{{ |
| 168 | | - Price: stripeapi.String(c.teamPriceID), |
| 169 | | - Quantity: stripeapi.Int64(in.SeatCount), |
| 246 | + Price: stripeapi.String(priceID), |
| 247 | + Quantity: stripeapi.Int64(quantity), |
| 170 | 248 | }}, |
| 171 | 249 | Metadata: metadata, |
| 172 | 250 | SubscriptionData: &stripeapi.CheckoutSessionCreateSubscriptionDataParams{ |
@@ -178,7 +256,7 @@ func (c *Client) CreateCheckoutSession(ctx context.Context, in CheckoutInput) (C |
| 178 | 256 | Enabled: stripeapi.Bool(true), |
| 179 | 257 | } |
| 180 | 258 | } |
| 181 | | - params.SetIdempotencyKey(idempotencyKey("checkout", in.OrgID, "team", strconv.FormatInt(in.SeatCount, 10))) |
| 259 | + params.SetIdempotencyKey(idempotencyKey("checkout", string(kind), subjectID, planLabelForKind(kind), strconv.FormatInt(quantity, 10))) |
| 182 | 260 | session, err := c.stripe.V1CheckoutSessions.Create(ctx, params) |
| 183 | 261 | if err != nil { |
| 184 | 262 | return CheckoutSession{}, err |
@@ -226,11 +304,81 @@ func (c *Client) VerifyWebhook(payload []byte, signatureHeader string) (stripeap |
| 226 | 304 | return webhook.ConstructEvent(payload, signatureHeader, c.webhookSecret) |
| 227 | 305 | } |
| 228 | 306 | |
| 229 | | -func orgMetadata(orgID int64, orgSlug string) map[string]string { |
| 230 | | - return map[string]string{ |
| 231 | | - MetadataOrgID: strconv.FormatInt(orgID, 10), |
| 232 | | - MetadataOrgSlug: strings.TrimSpace(orgSlug), |
| 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" |
| 233 | 380 | } |
| 381 | + return string(kind) |
| 234 | 382 | } |
| 235 | 383 | |
| 236 | 384 | func idempotencyKey(parts ...any) string { |