From 900d58efaa81e57758f8b78f4b8a1c030d6694bc Mon Sep 17 00:00:00 2001 From: midagedev Date: Tue, 25 Aug 2026 09:03:10 +0900 Subject: [PATCH] feat(api): invoice void/mark_uncollectible, checkout expire, list paging with starting_after/has_more, remaining create/update params - POST /v1/invoices/{id}/void and /mark_uncollectible: lifecycle transitions with invoice.voided / invoice.marked_uncollectible webhooks; known-route gate skips implemented-without-claim paths - POST /v1/checkout/sessions/{id}/expire: open -> expired + checkout.session.expired webhook - all top-level lists honor starting_after and report has_more (SDK auto-paging no longer silently stops at page 1) - GET /v1/subscriptions supports current_period_end[gte]/[lt] filters - subscription create accepts proration_behavior and payment_settings[...]; update accepts cancel_at (own axis vs cancel_at_period_end) - customer update accepts invoice_settings[default_payment_method]; payment method attach sets default only when unset - invoice payment_settings customer_balance tree echoed in responses --- CHANGELOG.md | 25 ++ internal/api/api.go | 335 +++++++++++++++++++++----- internal/api/api_test.go | 463 ++++++++++++++++++++++++++++++++++++ internal/api/validation.go | 23 +- internal/billing/service.go | 90 ++++++- internal/storage/billing.go | 28 +++ 6 files changed, 904 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c8c3a..5ead45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ ## Unreleased +- `POST /v1/invoices/{id}/void` moves an `open` invoice to `void`, records + `billtap_voided_at`, and emits `invoice.voided`. Other statuses return + `invalid_request_error` with `status must be open`. +- `POST /v1/invoices/{id}/mark_uncollectible` moves an `open` invoice to + `uncollectible`, records `billtap_marked_uncollectible_at`, and emits + `invoice.marked_uncollectible`. +- `POST /v1/checkout/sessions/{id}/expire` moves an `open` session to + `expired` and emits `checkout.session.expired`. Non-open sessions return + `invalid_request_error` with `status must be open`. +- `POST /v1/subscriptions` accepts `proration_behavior` + (`none` / `create_prorations` / `always_invoice`) and nested + `payment_settings[...]` keys, storing the received values in metadata. +- `POST /v1/subscriptions/{id}` accepts `cancel_at` as a unix timestamp, + stores it, and echoes `cancel_at` without changing status immediately. +- `POST /v1/customers/{id}` accepts + `invoice_settings[default_payment_method]` and echoes it on + `invoice_settings.default_payment_method`. Attaching a payment method sets + that default when the customer has none. +- `GET /v1/subscriptions` honors `current_period_end[gte]` and + `current_period_end[lt]` as unix seconds. +- List endpoints honor `starting_after` and set `has_more` when a `limit` + truncates remaining items. +- Invoice `payment_settings[payment_method_options][customer_balance][...]` + is stored and echoed on + `payment_settings.payment_method_options.customer_balance`. - `POST /v1/invoiceitems` can omit `invoice`. The item is stored as a pending customer item (`invoice` is null) and `subscription` is accepted and echoed. - `POST /v1/invoices` `pending_invoice_items_behavior=include` attaches that diff --git a/internal/api/api.go b/internal/api/api.go index 13c43d0..931ccb5 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -207,7 +207,25 @@ func (h *Handler) methodNotAllowed(w http.ResponseWriter, r *http.Request, allow methodNotAllowed(w, allow) } +func implementedWithoutCompatClaim(method string, path string) bool { + if method != http.MethodPost { + return false + } + if strings.HasPrefix(path, "/v1/invoices/") && (strings.HasSuffix(path, "/void") || strings.HasSuffix(path, "/mark_uncollectible")) { + parts := strings.Split(strings.TrimPrefix(path, "/v1/invoices/"), "/") + return len(parts) == 2 && parts[0] != "" + } + if strings.HasPrefix(path, "/v1/checkout/sessions/") && strings.HasSuffix(path, "/expire") { + id := strings.TrimSuffix(strings.TrimPrefix(path, "/v1/checkout/sessions/"), "/expire") + return id != "" && !strings.Contains(id, "/") + } + return false +} + func (h *Handler) writeKnownUnsupportedRoute(w http.ResponseWriter, r *http.Request) bool { + if implementedWithoutCompatClaim(r.Method, r.URL.Path) { + return false + } route, ok := h.knownRoutes.Lookup(r.Method, r.URL.Path) if !ok { return false @@ -278,11 +296,8 @@ func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) { continue } data = append(data, stripeCustomer(customer)) - if limit := queryInt(r, "limit"); limit > 0 && len(data) >= limit { - break - } } - writeResult(w, stripeList(r.URL.Path, data), err) + writeResult(w, stripeListFromRequest(r, data), err) default: h.methodNotAllowed(w, r, "GET, POST") } @@ -345,7 +360,7 @@ func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request) { return } metadata := p.metadata() - if metadata != nil || p.has("test_clock") || hasDiscountParams(p) { + if metadata != nil || p.has("test_clock") || hasDiscountParams(p) || p.has("invoice_settings[default_payment_method]") { current, err := h.billing.GetCustomer(r.Context(), id) if err != nil { writeResult(w, nil, err) @@ -363,6 +378,12 @@ func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request) { } metadata["test_clock"] = p.string("test_clock") } + if defaultPaymentMethod := p.string("invoice_settings[default_payment_method]"); defaultPaymentMethod != "" { + if metadata == nil { + metadata = map[string]string{} + } + metadata[billing.MetadataDefaultPaymentMethod] = defaultPaymentMethod + } if discounts, err := h.discountsFromParams(p); err != nil { writeResult(w, nil, err) return @@ -431,11 +452,8 @@ func (h *Handler) handleCustomerSubscriptions(w http.ResponseWriter, r *http.Req data := make([]map[string]any, 0, len(filtered)) for _, item := range filtered { data = append(data, h.stripeSubscription(r, item)) - if limit := queryInt(r, "limit"); limit > 0 && len(data) >= limit { - break - } } - writeResult(w, stripeList(r.URL.Path, data), nil) + writeResult(w, stripeListFromRequest(r, data), nil) case http.MethodPost: subscription, err := h.createSubscriptionFromParamsWithCustomer(r, customerID) writeResult(w, h.stripeSubscription(r, subscription), err) @@ -496,7 +514,7 @@ func (h *Handler) handleProducts(w http.ResponseWriter, r *http.Request) { writeResult(w, stripeProduct(product), err) case http.MethodGet: products, err := h.billing.ListProducts(r.Context()) - writeResult(w, stripeList(r.URL.Path, stripeProducts(products)), err) + writeResult(w, stripeListFromRequest(r, stripeProducts(products)), err) default: h.methodNotAllowed(w, r, "GET, POST") } @@ -579,7 +597,7 @@ func (h *Handler) handlePrices(w http.ResponseWriter, r *http.Request) { writeResult(w, stripePrice(price), err) case http.MethodGet: prices, err := h.billing.ListPrices(r.Context()) - writeResult(w, stripeList(r.URL.Path, stripePrices(filterPrices(prices, r))), err) + writeResult(w, stripeListFromRequest(r, stripePrices(filterPrices(prices, r))), err) default: h.methodNotAllowed(w, r, "GET, POST") } @@ -676,7 +694,7 @@ func (h *Handler) handleAccounts(w http.ResponseWriter, r *http.Request) { writeResult(w, stripeAccount(account), err) case http.MethodGet: accounts, err := h.billing.ListAccounts(r.Context()) - writeResult(w, stripeList(r.URL.Path, stripeAccounts(filterAccounts(accounts, r))), err) + writeResult(w, stripeListFromRequest(r, stripeAccounts(filterAccounts(accounts, r))), err) default: h.methodNotAllowed(w, r, "GET, POST") } @@ -787,7 +805,12 @@ func (h *Handler) handleAccountCapabilities(w http.ResponseWriter, r *http.Reque for capability, status := range account.Capabilities { data = append(data, stripeCapability(account.ID, capability, status)) } - writeJSON(w, http.StatusOK, stripeList(r.URL.Path, data)) + sort.Slice(data, func(i, j int) bool { + idI, _ := data[i]["id"].(string) + idJ, _ := data[j]["id"].(string) + return idI < idJ + }) + writeJSON(w, http.StatusOK, stripeListFromRequest(r, data)) return } if len(parts) != 1 { @@ -836,7 +859,7 @@ func (h *Handler) handleAccountExternalAccounts(w http.ResponseWriter, r *http.R switch r.Method { case http.MethodGet: resources, err := h.billing.ListConnectResources(r.Context(), billing.ConnectResourceFilter{Object: billing.ObjectBankAccount, AccountID: accountID}) - writeResult(w, stripeList(r.URL.Path, stripeConnectResources(resources)), err) + writeResult(w, stripeListFromRequest(r, stripeConnectResources(resources)), err) case http.MethodPost: p, err := parseParams(r) if err != nil { @@ -940,7 +963,7 @@ func (h *Handler) handleAccountPeople(w http.ResponseWriter, r *http.Request, ac switch r.Method { case http.MethodGet: resources, err := h.billing.ListConnectResources(r.Context(), billing.ConnectResourceFilter{Object: billing.ObjectPerson, AccountID: accountID}) - writeResult(w, stripeList(r.URL.Path, stripeConnectResources(resources)), err) + writeResult(w, stripeListFromRequest(r, stripeConnectResources(resources)), err) case http.MethodPost: p, err := parseParams(r) if err != nil { @@ -1142,7 +1165,7 @@ func (h *Handler) handleTransfers(w http.ResponseWriter, r *http.Request) { Object: billing.ObjectTransfer, Destination: r.URL.Query().Get("destination"), }) - writeResult(w, stripeList(r.URL.Path, stripeConnectResources(resources)), err) + writeResult(w, stripeListFromRequest(r, stripeConnectResources(resources)), err) case http.MethodPost: p, err := parseParams(r) if err != nil { @@ -1226,7 +1249,7 @@ func (h *Handler) handleTransferReversals(w http.ResponseWriter, r *http.Request switch r.Method { case http.MethodGet: resources, err := h.billing.ListConnectResources(r.Context(), billing.ConnectResourceFilter{Object: billing.ObjectTransferReversal, ParentID: transferID}) - writeResult(w, stripeList(r.URL.Path, stripeConnectResources(resources)), err) + writeResult(w, stripeListFromRequest(r, stripeConnectResources(resources)), err) case http.MethodPost: p, err := parseParams(r) if err != nil { @@ -1307,7 +1330,7 @@ func (h *Handler) handlePayouts(w http.ResponseWriter, r *http.Request) { Object: billing.ObjectPayout, Status: r.URL.Query().Get("status"), }) - writeResult(w, stripeList(r.URL.Path, stripeConnectResources(resources)), err) + writeResult(w, stripeListFromRequest(r, stripeConnectResources(resources)), err) case http.MethodPost: p, err := parseParams(r) if err != nil { @@ -1414,7 +1437,7 @@ func (h *Handler) handleApplicationFees(w http.ResponseWriter, r *http.Request) return } resources, err := h.billing.ListConnectResources(r.Context(), billing.ConnectResourceFilter{Object: billing.ObjectApplicationFee}) - writeResult(w, stripeList(r.URL.Path, stripeConnectResources(resources)), err) + writeResult(w, stripeListFromRequest(r, stripeConnectResources(resources)), err) } func (h *Handler) handleApplicationFee(w http.ResponseWriter, r *http.Request) { @@ -1450,7 +1473,7 @@ func (h *Handler) handleApplicationFeeRefunds(w http.ResponseWriter, r *http.Req switch r.Method { case http.MethodGet: resources, err := h.billing.ListConnectResources(r.Context(), billing.ConnectResourceFilter{Object: billing.ObjectFeeRefund, ParentID: feeID}) - writeResult(w, stripeList(r.URL.Path, stripeConnectResources(resources)), err) + writeResult(w, stripeListFromRequest(r, stripeConnectResources(resources)), err) case http.MethodPost: h.handleApplicationFeeRefundCreate(w, r, feeID) default: @@ -1645,7 +1668,7 @@ func (h *Handler) handleCheckoutSessions(w http.ResponseWriter, r *http.Request) sessions[i].URL = h.absoluteURL(r, sessions[i].URL) data = append(data, h.stripeCheckoutSession(r, sessions[i])) } - writeResult(w, stripeList(r.URL.Path, data), err) + writeResult(w, stripeListFromRequest(r, data), err) default: h.methodNotAllowed(w, r, "GET, POST") } @@ -1658,6 +1681,11 @@ func (h *Handler) handleCheckoutSession(w http.ResponseWriter, r *http.Request) h.completeCheckout(w, r, id) return } + if strings.HasSuffix(rest, "/expire") { + id := strings.TrimSuffix(rest, "/expire") + h.expireCheckoutSession(w, r, id) + return + } if rest == "" || strings.Contains(rest, "/") { h.notFound(w, r) return @@ -1926,6 +1954,28 @@ func (h *Handler) completeCheckout(w http.ResponseWriter, r *http.Request, id st writeJSON(w, http.StatusOK, result) } +func (h *Handler) expireCheckoutSession(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateCheckoutSessionExpire(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + session, err := h.billing.ExpireCheckoutSession(r.Context(), id) + if err == nil { + session.URL = h.absoluteURL(r, session.URL) + h.emitGenericWebhook(r, "checkout.session.expired", session.ID, h.stripeCheckoutSession(r, session), webhooks.SourceAPI) + } + writeResult(w, h.stripeCheckoutSession(r, session), err) +} + func (h *Handler) handleSubscriptions(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -1938,11 +1988,8 @@ func (h *Handler) handleSubscriptions(w http.ResponseWriter, r *http.Request) { data := make([]map[string]any, 0, len(filtered)) for _, item := range filtered { data = append(data, h.stripeSubscription(r, item)) - if limit := queryInt(r, "limit"); limit > 0 && len(data) >= limit { - break - } } - writeResult(w, stripeList(r.URL.Path, data), nil) + writeResult(w, stripeListFromRequest(r, data), nil) case http.MethodPost: subscription, err := h.createSubscriptionFromParams(r) writeResult(w, h.stripeSubscription(r, subscription), err) @@ -2069,7 +2116,7 @@ func (h *Handler) createSubscriptionFromParamsWithCustomer(r *http.Request, defa } metadata["test_clock"] = testClockID } - for _, key := range []string{"collection_method", "days_until_due", "cancel_at", "billing_cycle_anchor"} { + for _, key := range []string{"collection_method", "days_until_due", "cancel_at", "billing_cycle_anchor", "proration_behavior"} { if value := p.string(key); value != "" { if metadata == nil { metadata = map[string]string{} @@ -2077,6 +2124,7 @@ func (h *Handler) createSubscriptionFromParamsWithCustomer(r *http.Request, defa metadata[key] = value } } + metadata = copyPaymentSettingsMetadata(metadata, p) if metadata == nil { return subscription, nil } @@ -3258,7 +3306,7 @@ func (h *Handler) handleInvoices(w http.ResponseWriter, r *http.Request) { for _, item := range filtered { data = append(data, h.stripeInvoice(r.Context(), item)) } - writeResult(w, stripeList(r.URL.Path, data), nil) + writeResult(w, stripeListFromRequest(r, data), nil) default: h.methodNotAllowed(w, r, "GET, POST") } @@ -3340,7 +3388,7 @@ func (h *Handler) handleInvoiceItems(w http.ResponseWriter, r *http.Request) { CustomerID: r.URL.Query().Get("customer"), InvoiceID: r.URL.Query().Get("invoice"), }) - writeResult(w, stripeList(r.URL.Path, stripeInvoiceItemMaps(items)), err) + writeResult(w, stripeListFromRequest(r, stripeInvoiceItemMaps(items)), err) default: h.methodNotAllowed(w, r, "GET, POST") } @@ -3460,13 +3508,55 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { writeResult(w, h.stripeInvoiceWithPaymentIntent(r.Context(), result.Invoice, result.PaymentIntent), err) return } + if len(parts) == 2 && parts[1] == "void" { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateInvoiceVoid(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + invoice, err := h.billing.VoidInvoice(r.Context(), id) + if err == nil { + h.emitGenericWebhook(r, "invoice.voided", invoice.ID, h.stripeInvoice(r.Context(), invoice), webhooks.SourceAPI) + } + writeResult(w, h.stripeInvoice(r.Context(), invoice), err) + return + } + if len(parts) == 2 && parts[1] == "mark_uncollectible" { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateInvoiceMarkUncollectible(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + invoice, err := h.billing.MarkInvoiceUncollectible(r.Context(), id) + if err == nil { + h.emitGenericWebhook(r, "invoice.marked_uncollectible", invoice.ID, h.stripeInvoice(r.Context(), invoice), webhooks.SourceAPI) + } + writeResult(w, h.stripeInvoice(r.Context(), invoice), err) + return + } if len(parts) == 2 && parts[1] == "lines" { if r.Method != http.MethodGet { h.methodNotAllowed(w, r, "GET") return } items, err := h.billing.ListInvoiceItems(r.Context(), billing.InvoiceItemFilter{InvoiceID: id}) - writeResult(w, stripeList(r.URL.Path, stripeInvoiceItemMaps(items)), err) + writeResult(w, stripeListFromRequest(r, stripeInvoiceItemMaps(items)), err) return } if len(parts) == 2 && parts[1] == "payments" { @@ -3480,7 +3570,7 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { return } payments := h.stripeInvoicePayments(r.Context(), invoice, nil) - writeResult(w, stripeList(r.URL.Path, payments), nil) + writeResult(w, stripeListFromRequest(r, payments), nil) return } if len(parts) != 1 { @@ -3508,7 +3598,7 @@ func (h *Handler) handleRefunds(w http.ResponseWriter, r *http.Request) { for _, refund := range refunds { data = append(data, stripeRefund(refund)) } - writeResult(w, stripeList(r.URL.Path, data), err) + writeResult(w, stripeListFromRequest(r, data), err) case http.MethodPost: p, err := parseParams(r) if err != nil { @@ -3604,7 +3694,7 @@ func (h *Handler) handleCreditNotes(w http.ResponseWriter, r *http.Request) { for _, note := range notes { data = append(data, stripeCreditNote(note)) } - writeResult(w, stripeList(r.URL.Path, data), err) + writeResult(w, stripeListFromRequest(r, data), err) case http.MethodPost: p, err := parseParams(r) if err != nil { @@ -3722,11 +3812,8 @@ func (h *Handler) handlePaymentIntents(w http.ResponseWriter, r *http.Request) { continue } data = append(data, stripePaymentIntent(item)) - if limit := queryInt(r, "limit"); limit > 0 && len(data) >= limit { - break - } } - writeResult(w, stripeList(r.URL.Path, data), err) + writeResult(w, stripeListFromRequest(r, data), err) default: h.methodNotAllowed(w, r, "GET, POST") } @@ -3871,11 +3958,8 @@ func (h *Handler) handleSetupIntents(w http.ResponseWriter, r *http.Request) { continue } data = append(data, stripeSetupIntent(item)) - if limit := queryInt(r, "limit"); limit > 0 && len(data) >= limit { - break - } } - writeResult(w, stripeList(r.URL.Path, data), err) + writeResult(w, stripeListFromRequest(r, data), err) default: h.methodNotAllowed(w, r, "GET, POST") } @@ -3945,7 +4029,7 @@ func (h *Handler) handleTestClocks(w http.ResponseWriter, r *http.Request) { for _, clock := range clocks { data = append(data, stripeTestClock(clock)) } - writeResult(w, stripeList(r.URL.Path, data), err) + writeResult(w, stripeListFromRequest(r, data), err) case http.MethodPost: p, err := parseParams(r) if err != nil { @@ -4212,7 +4296,7 @@ func (h *Handler) writeCustomerPaymentMethods(w http.ResponseWriter, r *http.Req return } if customerID == "" { - writeResult(w, stripeList(r.URL.Path, []map[string]any{}), nil) + writeResult(w, stripeListFromRequest(r, []map[string]any{}), nil) return } customer, err := h.billing.GetCustomer(r.Context(), customerID) @@ -4221,10 +4305,10 @@ func (h *Handler) writeCustomerPaymentMethods(w http.ResponseWriter, r *http.Req return } if paymentMethodType := strings.TrimSpace(r.URL.Query().Get("type")); paymentMethodType != "" && paymentMethodType != "card" { - writeResult(w, stripeList(r.URL.Path, []map[string]any{}), nil) + writeResult(w, stripeListFromRequest(r, []map[string]any{}), nil) return } - writeResult(w, stripeList(r.URL.Path, stripePaymentMethods(customer)), nil) + writeResult(w, stripeListFromRequest(r, stripePaymentMethods(customer)), nil) } func (h *Handler) attachPaymentMethod(ctx context.Context, customerID string, paymentMethodID string) (billing.Customer, error) { @@ -4236,6 +4320,9 @@ func (h *Handler) attachPaymentMethod(ctx context.Context, customerID string, pa ids := append(splitPaymentMethodIDs(metadata[billing.MetadataPaymentMethodIDs]), paymentMethodID) metadata[billing.MetadataPaymentMethodIDs] = strings.Join(uniquePaymentMethodIDs(ids), ",") metadata[billing.MetadataPaymentMethodsFixture] = billing.PaymentMethodsFixtureExplicit + if strings.TrimSpace(metadata[billing.MetadataDefaultPaymentMethod]) == "" { + metadata[billing.MetadataDefaultPaymentMethod] = paymentMethodID + } return h.billing.UpdateCustomer(ctx, customer.ID, billing.Customer{Metadata: metadata}) } @@ -5999,6 +6086,19 @@ func invoiceMetadataFromParams(p params) map[string]string { metadata[item.key] = value } } + return copyPaymentSettingsMetadata(metadata, p) +} + +func copyPaymentSettingsMetadata(metadata map[string]string, p params) map[string]string { + for key, value := range p.values { + if !invoicePaymentSettingsRE.MatchString(key) || strings.TrimSpace(value) == "" { + continue + } + if metadata == nil { + metadata = map[string]string{} + } + metadata[key] = value + } return metadata } @@ -6407,6 +6507,43 @@ func stripeList(urlPath string, data any) map[string]any { } } +func stripeListFromRequest(r *http.Request, data []map[string]any) map[string]any { + page, hasMore := pageStripeList(data, strings.TrimSpace(r.URL.Query().Get("starting_after")), queryInt(r, "limit")) + return map[string]any{ + "object": "list", + "url": r.URL.Path, + "has_more": hasMore, + "data": page, + } +} + +func pageStripeList(data []map[string]any, startingAfter string, limit int) ([]map[string]any, bool) { + items := data + if startingAfter != "" { + idx := -1 + for i, item := range items { + id, _ := item["id"].(string) + if id == startingAfter { + idx = i + break + } + } + if idx < 0 { + return []map[string]any{}, false + } + items = items[idx+1:] + } + if limit > 0 && len(items) > limit { + out := make([]map[string]any, limit) + copy(out, items[:limit]) + return out, true + } + if items == nil { + return []map[string]any{}, false + } + return items, false +} + func stripeSearchResult(urlPath string, query string, data any) map[string]any { return map[string]any{ "object": "search_result", @@ -7183,13 +7320,13 @@ func subscriptionPauseCollection(sub billing.Subscription) any { } func subscriptionCancelAt(sub billing.Subscription) any { - if !sub.CancelAtPeriodEnd { - return nil - } if value := metadataUnix(sub.Metadata["cancel_at"]); value != nil { return value } - return unix(sub.CurrentPeriodEnd) + if sub.CancelAtPeriodEnd { + return unix(sub.CurrentPeriodEnd) + } + return nil } func subscriptionCancellationDetails(sub billing.Subscription) map[string]any { @@ -7441,7 +7578,7 @@ func stripeInvoiceWithPaymentIntentAndTaxRates(invoice billing.Invoice, intent * "effective_at": nil, "period_start": created, "period_end": created, - "status_transitions": stripeInvoiceStatusTransitions(finalizedAt, paidAt), + "status_transitions": stripeInvoiceStatusTransitions(finalizedAt, paidAt, optionalVoidedAt(invoice), optionalMarkedUncollectibleAt(invoice)), "account_country": nil, "account_name": nil, "account_tax_ids": nil, @@ -7600,12 +7737,20 @@ func stripeInvoiceParent(subscriptionID string) map[string]any { } } -func stripeInvoiceStatusTransitions(finalizedAt any, paidAt any) map[string]any { +func stripeInvoiceStatusTransitions(finalizedAt any, paidAt any, extra ...any) map[string]any { + var voidedAt any + var uncollectibleAt any + if len(extra) > 0 { + voidedAt = extra[0] + } + if len(extra) > 1 { + uncollectibleAt = extra[1] + } return map[string]any{ "finalized_at": finalizedAt, - "marked_uncollectible_at": nil, + "marked_uncollectible_at": uncollectibleAt, "paid_at": paidAt, - "voided_at": nil, + "voided_at": voidedAt, } } @@ -7654,8 +7799,10 @@ func stripeSubscriptionAutomaticTax(enabled bool) map[string]any { func stripeInvoicePaymentSettings(metadata ...map[string]string) map[string]any { paymentMethodTypes := any(nil) + var meta map[string]string if len(metadata) > 0 { - if value := strings.TrimSpace(metadata[0]["payment_method_types"]); value != "" { + meta = metadata[0] + if value := strings.TrimSpace(meta["payment_method_types"]); value != "" { paymentMethodTypes = []string{value} } } @@ -7665,7 +7812,7 @@ func stripeInvoicePaymentSettings(metadata ...map[string]string) map[string]any "acss_debit": nil, "bancontact": nil, "card": nil, - "customer_balance": nil, + "customer_balance": paymentSettingsCustomerBalance(meta), "konbini": nil, "sepa_debit": nil, "us_bank_account": nil, @@ -7674,6 +7821,60 @@ func stripeInvoicePaymentSettings(metadata ...map[string]string) map[string]any } } +func paymentSettingsCustomerBalance(meta map[string]string) any { + if len(meta) == 0 { + return nil + } + const prefix = "payment_settings[payment_method_options][customer_balance]" + root := map[string]any{} + found := false + for key, value := range meta { + if !strings.HasPrefix(key, prefix+"[") { + continue + } + found = true + mergeBracketValue(root, parseBracketPath(strings.TrimPrefix(key, prefix)), value) + } + if !found { + return nil + } + return root +} + +func parseBracketPath(raw string) []string { + var parts []string + for { + start := strings.Index(raw, "[") + if start < 0 { + break + } + end := strings.Index(raw[start:], "]") + if end < 0 { + break + } + end += start + parts = append(parts, raw[start+1:end]) + raw = raw[end+1:] + } + return parts +} + +func mergeBracketValue(dst map[string]any, parts []string, value string) { + if len(parts) == 0 { + return + } + if len(parts) == 1 { + dst[parts[0]] = value + return + } + child, _ := dst[parts[0]].(map[string]any) + if child == nil { + child = map[string]any{} + dst[parts[0]] = child + } + mergeBracketValue(child, parts[1:], value) +} + func stripeInvoiceBillingReason(invoice billing.Invoice) string { if invoice.SubscriptionID == "" { return "manual" @@ -7921,6 +8122,9 @@ func filterSubscriptions(items []billing.Subscription, r *http.Request) []billin if status != "" && status != "all" && item.Status != status { continue } + if !subscriptionPeriodEndMatches(item, query) { + continue + } if !metadataMatches(item.Metadata, metadataFilters) { continue } @@ -7941,6 +8145,9 @@ func filterSubscriptionsForCustomer(items []billing.Subscription, r *http.Reques if status != "" && status != "all" && item.Status != status { continue } + if !subscriptionPeriodEndMatches(item, query) { + continue + } if !metadataMatches(item.Metadata, metadataFilters) { continue } @@ -7949,6 +8156,17 @@ func filterSubscriptionsForCustomer(items []billing.Subscription, r *http.Reques return out } +func subscriptionPeriodEndMatches(item billing.Subscription, query url.Values) bool { + end := unix(item.CurrentPeriodEnd) + if gte := queryInt64Values(query, "current_period_end[gte]"); gte != 0 && end < gte { + return false + } + if lt := queryInt64Values(query, "current_period_end[lt]"); lt != 0 && end >= lt { + return false + } + return true +} + func queryMetadataFilters(query url.Values) map[string]string { out := map[string]string{} for key, values := range query { @@ -8365,6 +8583,7 @@ func subscriptionUpdateMetadata(p params) map[string]string { {param: "proration_date", key: "proration_date"}, {param: "payment_behavior", key: "payment_behavior"}, {param: "billing_cycle_anchor", key: "billing_cycle_anchor"}, + {param: "cancel_at", key: "cancel_at"}, } { if value := p.string(item.param); value != "" { if metadata == nil { @@ -8468,6 +8687,14 @@ func optionalFinalizedAt(invoice billing.Invoice) any { return unix(invoice.CreatedAt) } +func optionalVoidedAt(invoice billing.Invoice) any { + return metadataUnix(invoice.Metadata["billtap_voided_at"]) +} + +func optionalMarkedUncollectibleAt(invoice billing.Invoice) any { + return metadataUnix(invoice.Metadata["billtap_marked_uncollectible_at"]) +} + func paymentIntentError(intent billing.PaymentIntent) any { if intent.FailureCode == "" && intent.FailureMessage == "" { return nil diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 0f52090..a9b97ef 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -7694,3 +7694,466 @@ func TestFilterPricesLookupKeys(t *testing.T) { }) } } + +func TestInvoiceVoidAndMarkUncollectible(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"invoice-lifecycle@example.test"}, + }) + openInvoice := func(t *testing.T) string { + t.Helper() + invoice := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "currency": {"usd"}, + }) + postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "invoice": {invoice.ID}, + "amount": {"1200"}, + "currency": {"usd"}, + }) + finalized := postForm[struct { + ID string `json:"id"` + Status string `json:"status"` + }](t, handler, "/v1/invoices/"+invoice.ID+"/finalize", nil) + if finalized.Status != "open" { + t.Fatalf("finalized = %#v, want open", finalized) + } + return invoice.ID + } + + voidID := openInvoice(t) + voided := postForm[struct { + ID string `json:"id"` + Status string `json:"status"` + StatusTransitions struct { + VoidedAt *int64 `json:"voided_at"` + } `json:"status_transitions"` + }](t, handler, "/v1/invoices/"+voidID+"/void", nil) + if voided.Status != "void" || voided.StatusTransitions.VoidedAt == nil { + t.Fatalf("voided = %#v, want status void with voided_at", voided) + } + events := getJSON[struct { + Data []webhooks.Event `json:"data"` + }](t, handler, "/v1/events?type=invoice.voided") + if !eventObjectIDFound(events.Data, voidID) { + t.Fatalf("invoice.voided events = %#v, want object %s", events.Data, voidID) + } + voidDraft := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "currency": {"usd"}, + }) + status, body := postFormStatus(t, handler, "/v1/invoices/"+voidDraft.ID+"/void", nil) + errBody := decodeErrorBody(t, body) + if status != http.StatusBadRequest || errBody.Error.Type != "invalid_request_error" || !strings.Contains(errBody.Error.Message, "status must be open") { + t.Fatalf("void draft status=%d body=%s, want 400 status must be open", status, body) + } + + uncollectibleID := openInvoice(t) + uncollectible := postForm[struct { + ID string `json:"id"` + Status string `json:"status"` + StatusTransitions struct { + MarkedUncollectibleAt *int64 `json:"marked_uncollectible_at"` + } `json:"status_transitions"` + }](t, handler, "/v1/invoices/"+uncollectibleID+"/mark_uncollectible", nil) + if uncollectible.Status != "uncollectible" || uncollectible.StatusTransitions.MarkedUncollectibleAt == nil { + t.Fatalf("uncollectible = %#v, want uncollectible with timestamp", uncollectible) + } + markedEvents := getJSON[struct { + Data []webhooks.Event `json:"data"` + }](t, handler, "/v1/events?type=invoice.marked_uncollectible") + if !eventObjectIDFound(markedEvents.Data, uncollectibleID) { + t.Fatalf("invoice.marked_uncollectible events = %#v, want object %s", markedEvents.Data, uncollectibleID) + } +} + +func TestCheckoutSessionExpire(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"expire@example.test"}, + }) + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "mode": {"payment"}, + "line_items[0][price_data][currency]": {"usd"}, + "line_items[0][price_data][unit_amount]": {"500"}, + "line_items[0][price_data][product_data][name]": {"Expire"}, + "line_items[0][quantity]": {"1"}, + }) + if session.Status != "open" { + t.Fatalf("session = %#v, want open", session) + } + expired := postForm[struct { + ID string `json:"id"` + Status string `json:"status"` + }](t, handler, "/v1/checkout/sessions/"+session.ID+"/expire", nil) + if expired.Status != "expired" { + t.Fatalf("expired = %#v, want expired", expired) + } + got := getJSON[struct { + Status string `json:"status"` + }](t, handler, "/v1/checkout/sessions/"+session.ID) + if got.Status != "expired" { + t.Fatalf("GET after expire = %#v, want expired", got) + } + events := getJSON[struct { + Data []webhooks.Event `json:"data"` + }](t, handler, "/v1/events?type=checkout.session.expired") + if !eventObjectIDFound(events.Data, session.ID) { + t.Fatalf("checkout.session.expired events = %#v, want object %s", events.Data, session.ID) + } + status, body := postFormStatus(t, handler, "/v1/checkout/sessions/"+session.ID+"/expire", nil) + errBody := decodeErrorBody(t, body) + if status != http.StatusBadRequest || errBody.Error.Type != "invalid_request_error" || !strings.Contains(errBody.Error.Message, "status must be open") { + t.Fatalf("expire again status=%d body=%s, want 400 status must be open", status, body) + } + + completed := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "mode": {"payment"}, + "line_items[0][price_data][currency]": {"usd"}, + "line_items[0][price_data][unit_amount]": {"700"}, + "line_items[0][price_data][product_data][name]": {"Complete then expire"}, + "line_items[0][quantity]": {"1"}, + }) + postJSON[map[string]json.RawMessage](t, handler, "/api/checkout/sessions/"+completed.ID+"/complete", map[string]string{ + "outcome": "payment_succeeded", + }) + status, body = postFormStatus(t, handler, "/v1/checkout/sessions/"+completed.ID+"/expire", nil) + errBody = decodeErrorBody(t, body) + if status != http.StatusBadRequest || !strings.Contains(errBody.Error.Message, "status must be open") { + t.Fatalf("expire completed status=%d body=%s, want 400", status, body) + } +} + +func TestSubscriptionCreateProrationBehaviorAndPaymentSettings(t *testing.T) { + handler := newTestHandler(t) + customer, price := seedCustomerAndPrice(t, handler, "create-params@example.test", "Create Params") + created := postForm[struct { + ID string `json:"id"` + Status string `json:"status"` + Metadata map[string]string `json:"metadata"` + }](t, handler, "/v1/subscriptions", url.Values{ + "customer": {customer.ID}, + "items[0][price]": {price.ID}, + "proration_behavior": {"create_prorations"}, + "payment_settings[payment_method_types][0]": {"card"}, + "payment_settings[payment_method_options][customer_balance][funding_type]": {"bank_transfer"}, + }) + if created.Status != "active" { + t.Fatalf("created = %#v, want active", created) + } + if created.Metadata["proration_behavior"] != "create_prorations" { + t.Fatalf("proration_behavior metadata = %#v, want create_prorations", created.Metadata) + } + if created.Metadata["payment_settings[payment_method_types][0]"] != "card" { + t.Fatalf("payment_settings metadata = %#v, want types card", created.Metadata) + } + if created.Metadata["payment_settings[payment_method_options][customer_balance][funding_type]"] != "bank_transfer" { + t.Fatalf("customer_balance metadata = %#v, want funding_type", created.Metadata) + } + status, body := postFormStatus(t, handler, "/v1/subscriptions", url.Values{ + "customer": {customer.ID}, + "items[0][price]": {price.ID}, + "proration_behavior": {"bogus"}, + }) + errBody := decodeErrorBody(t, body) + if status != http.StatusBadRequest || errBody.Error.Code != "parameter_invalid" || errBody.Error.Param != "proration_behavior" { + t.Fatalf("unknown proration_behavior status=%d body=%s", status, body) + } +} + +func TestSubscriptionCancelAtEcho(t *testing.T) { + handler := newTestHandler(t) + customer, price := seedCustomerAndPrice(t, handler, "cancel-at@example.test", "Cancel At") + subscription := postForm[struct { + ID string `json:"id"` + Status string `json:"status"` + }](t, handler, "/v1/subscriptions", url.Values{ + "customer": {customer.ID}, + "items[0][price]": {price.ID}, + }) + cancelAt := time.Now().UTC().Add(48 * time.Hour).Unix() + updated := postForm[struct { + ID string `json:"id"` + Status string `json:"status"` + CancelAt *int64 `json:"cancel_at"` + CancelAtPeriodEnd bool `json:"cancel_at_period_end"` + }](t, handler, "/v1/subscriptions/"+subscription.ID, url.Values{ + "cancel_at": {strconv.FormatInt(cancelAt, 10)}, + }) + if updated.Status != "active" || updated.CancelAtPeriodEnd || updated.CancelAt == nil || *updated.CancelAt != cancelAt { + t.Fatalf("updated = %#v, want active cancel_at=%d", updated, cancelAt) + } +} + +func TestCustomerDefaultPaymentMethodUpdateAndAttach(t *testing.T) { + handler := newTestHandler(t) + updated := postForm[struct { + ID string `json:"id"` + InvoiceSettings struct { + DefaultPaymentMethod *string `json:"default_payment_method"` + } `json:"invoice_settings"` + }](t, handler, "/v1/customers", url.Values{ + "email": {"default-pm@example.test"}, + }) + patched := postForm[struct { + InvoiceSettings struct { + DefaultPaymentMethod *string `json:"default_payment_method"` + } `json:"invoice_settings"` + }](t, handler, "/v1/customers/"+updated.ID, url.Values{ + "invoice_settings[default_payment_method]": {"pm_card_visa"}, + }) + if patched.InvoiceSettings.DefaultPaymentMethod == nil || *patched.InvoiceSettings.DefaultPaymentMethod != "pm_card_visa" { + t.Fatalf("patched invoice_settings = %#v, want pm_card_visa", patched.InvoiceSettings) + } + + empty := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"attach-default@example.test"}, + "metadata[" + billing.MetadataPaymentMethodsFixture + "]": {billing.PaymentMethodsFixtureEmpty}, + }) + attached := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/payment_methods/pm_card_mastercard/attach", url.Values{ + "customer": {empty.ID}, + }) + if attached.ID != "pm_card_mastercard" { + t.Fatalf("attached = %#v", attached) + } + afterAttach := getJSON[struct { + InvoiceSettings struct { + DefaultPaymentMethod *string `json:"default_payment_method"` + } `json:"invoice_settings"` + }](t, handler, "/v1/customers/"+empty.ID) + if afterAttach.InvoiceSettings.DefaultPaymentMethod == nil || *afterAttach.InvoiceSettings.DefaultPaymentMethod != "pm_card_mastercard" { + t.Fatalf("after attach default = %#v, want pm_card_mastercard", afterAttach.InvoiceSettings) + } + _ = postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/payment_methods/pm_card_visa/attach", url.Values{ + "customer": {empty.ID}, + }) + afterSecond := getJSON[struct { + InvoiceSettings struct { + DefaultPaymentMethod *string `json:"default_payment_method"` + } `json:"invoice_settings"` + }](t, handler, "/v1/customers/"+empty.ID) + if afterSecond.InvoiceSettings.DefaultPaymentMethod == nil || *afterSecond.InvoiceSettings.DefaultPaymentMethod != "pm_card_mastercard" { + t.Fatalf("second attach overwrote default = %#v", afterSecond.InvoiceSettings) + } +} + +func TestSubscriptionCurrentPeriodEndFilter(t *testing.T) { + handler := newTestHandler(t) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Period Filter"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"1000"}, + "recurring[interval]": {"month"}, + }) + earlyClock := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/test_helpers/test_clocks", url.Values{ + "frozen_time": {"1000000000"}, + }) + lateClock := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/test_helpers/test_clocks", url.Values{ + "frozen_time": {"2000000000"}, + }) + earlyCustomer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"early-period@example.test"}, + "test_clock": {earlyClock.ID}, + }) + lateCustomer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"late-period@example.test"}, + "test_clock": {lateClock.ID}, + }) + earlySub := postForm[struct { + ID string `json:"id"` + CurrentPeriodEnd int64 `json:"current_period_end"` + }](t, handler, "/v1/subscriptions", url.Values{ + "customer": {earlyCustomer.ID}, + "items[0][price]": {price.ID}, + "test_clock": {earlyClock.ID}, + }) + lateSub := postForm[struct { + ID string `json:"id"` + CurrentPeriodEnd int64 `json:"current_period_end"` + }](t, handler, "/v1/subscriptions", url.Values{ + "customer": {lateCustomer.ID}, + "items[0][price]": {price.ID}, + "test_clock": {lateClock.ID}, + }) + if lateSub.CurrentPeriodEnd <= earlySub.CurrentPeriodEnd { + t.Fatalf("period ends early=%d late=%d, want late > early", earlySub.CurrentPeriodEnd, lateSub.CurrentPeriodEnd) + } + mid := (earlySub.CurrentPeriodEnd + lateSub.CurrentPeriodEnd) / 2 + gte := getJSON[struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + }](t, handler, "/v1/subscriptions?current_period_end[gte]="+strconv.FormatInt(mid, 10)) + if !listIDsEqual(gte.Data, []string{lateSub.ID}) { + t.Fatalf("gte list = %#v, want %s", gte.Data, lateSub.ID) + } + lt := getJSON[struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + }](t, handler, "/v1/subscriptions?current_period_end[lt]="+strconv.FormatInt(mid, 10)) + if !listIDsEqual(lt.Data, []string{earlySub.ID}) { + t.Fatalf("lt list = %#v, want %s", lt.Data, earlySub.ID) + } +} + +func TestStripeListStartingAfterHasMore(t *testing.T) { + handler := newTestHandler(t) + for _, id := range []string{"cus_page_a", "cus_page_b", "cus_page_c"} { + postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "id": {id}, + "email": {id + "@example.test"}, + }) + } + first := getJSON[struct { + HasMore bool `json:"has_more"` + Data []struct { + ID string `json:"id"` + } `json:"data"` + }](t, handler, "/v1/customers?limit=1") + if !first.HasMore || len(first.Data) != 1 || first.Data[0].ID != "cus_page_c" { + t.Fatalf("page 1 = %#v, want cus_page_c has_more=true", first) + } + second := getJSON[struct { + HasMore bool `json:"has_more"` + Data []struct { + ID string `json:"id"` + } `json:"data"` + }](t, handler, "/v1/customers?limit=1&starting_after="+first.Data[0].ID) + if !second.HasMore || len(second.Data) != 1 || second.Data[0].ID != "cus_page_b" { + t.Fatalf("page 2 = %#v, want cus_page_b has_more=true", second) + } + third := getJSON[struct { + HasMore bool `json:"has_more"` + Data []struct { + ID string `json:"id"` + } `json:"data"` + }](t, handler, "/v1/customers?limit=1&starting_after="+second.Data[0].ID) + if third.HasMore || len(third.Data) != 1 || third.Data[0].ID != "cus_page_a" { + t.Fatalf("page 3 = %#v, want cus_page_a has_more=false", third) + } + + for _, id := range []string{"prod_page_a", "prod_page_b", "prod_page_c"} { + postForm[billing.Product](t, handler, "/v1/products", url.Values{ + "id": {id}, + "name": {id}, + }) + } + products := getJSON[struct { + HasMore bool `json:"has_more"` + Data []struct { + ID string `json:"id"` + } `json:"data"` + }](t, handler, "/v1/products?limit=1") + if !products.HasMore || len(products.Data) != 1 || products.Data[0].ID != "prod_page_c" { + t.Fatalf("product page 1 = %#v, want prod_page_c has_more=true", products) + } + productPage2 := getJSON[struct { + HasMore bool `json:"has_more"` + Data []struct { + ID string `json:"id"` + } `json:"data"` + }](t, handler, "/v1/products?limit=1&starting_after="+products.Data[0].ID) + if !productPage2.HasMore || len(productPage2.Data) != 1 || productPage2.Data[0].ID != "prod_page_b" { + t.Fatalf("product page 2 = %#v, want prod_page_b", productPage2) + } +} + +func TestInvoicePaymentSettingsCustomerBalanceEcho(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"customer-balance@example.test"}, + }) + invoice := postForm[struct { + ID string `json:"id"` + PaymentSettings struct { + PaymentMethodOptions struct { + CustomerBalance map[string]any `json:"customer_balance"` + } `json:"payment_method_options"` + } `json:"payment_settings"` + }](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "currency": {"usd"}, + "payment_settings[payment_method_options][customer_balance][funding_type]": {"bank_transfer"}, + "payment_settings[payment_method_options][customer_balance][bank_transfer][type]": {"us_bank_transfer"}, + "payment_settings[payment_method_options][customer_balance][bank_transfer][requested_address_types][0]": {"aba"}, + }) + balance := invoice.PaymentSettings.PaymentMethodOptions.CustomerBalance + if fmt.Sprint(balance["funding_type"]) != "bank_transfer" { + t.Fatalf("create customer_balance = %#v, want funding_type=bank_transfer", balance) + } + bankTransfer, _ := balance["bank_transfer"].(map[string]any) + if bankTransfer == nil || fmt.Sprint(bankTransfer["type"]) != "us_bank_transfer" { + t.Fatalf("create bank_transfer = %#v, want type=us_bank_transfer", balance) + } + got := getJSON[struct { + PaymentSettings struct { + PaymentMethodOptions struct { + CustomerBalance map[string]any `json:"customer_balance"` + } `json:"payment_method_options"` + } `json:"payment_settings"` + }](t, handler, "/v1/invoices/"+invoice.ID) + if fmt.Sprint(got.PaymentSettings.PaymentMethodOptions.CustomerBalance["funding_type"]) != "bank_transfer" { + t.Fatalf("GET customer_balance = %#v, want stored tree", got.PaymentSettings.PaymentMethodOptions.CustomerBalance) + } +} + +func seedCustomerAndPrice(t *testing.T, handler http.Handler, email string, productName string) (billing.Customer, billing.Price) { + t.Helper() + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {email}, + }) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {productName}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"1500"}, + "recurring[interval]": {"month"}, + }) + return customer, price +} + +func eventObjectIDFound(events []webhooks.Event, objectID string) bool { + for _, event := range events { + var payload map[string]any + if err := json.Unmarshal(event.Data.Object, &payload); err != nil { + continue + } + if id, _ := payload["id"].(string); id == objectID { + return true + } + } + return false +} + +func listIDsEqual(got []struct { + ID string `json:"id"` +}, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range want { + if got[i].ID != want[i] { + return false + } + } + return true +} diff --git a/internal/api/validation.go b/internal/api/validation.go index 3b70dee..f4e667a 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -413,7 +413,7 @@ func validateCustomerCreate(p params) error { func validateCustomerUpdate(p params) error { return p.validate(paramSpec{ - Allowed: []string{"email", "name", "test_clock", "coupon", "promotion_code"}, + Allowed: []string{"email", "name", "test_clock", "coupon", "promotion_code", "invoice_settings[default_payment_method]"}, AllowedRegex: []*regexp.Regexp{discountParamRE}, AllowMetadata: true, }) @@ -947,14 +947,16 @@ func validateSubscriptionCreate(p params) error { "coupon", "promotion_code", "automatic_tax[enabled]", + "proration_behavior", }, - AllowedRegex: []*regexp.Regexp{subscriptionItemRE, discountParamRE, defaultTaxRatesParamRE}, + AllowedRegex: []*regexp.Regexp{subscriptionItemRE, discountParamRE, defaultTaxRatesParamRE, invoicePaymentSettingsRE}, RequiredAny: [][]string{{"customer", "customer_id"}}, Int64Params: []string{"days_until_due", "cancel_at", "billing_cycle_anchor"}, BoolParams: []string{"automatic_tax[enabled]"}, Positive: []string{"days_until_due"}, EnumParams: map[string][]string{ - "collection_method": {"charge_automatically", "send_invoice"}, + "collection_method": {"charge_automatically", "send_invoice"}, + "proration_behavior": {"none", "create_prorations", "always_invoice"}, }, AllowMetadata: true, }); err != nil { @@ -983,6 +985,7 @@ func validateSubscriptionUpdate(p params) error { if err := p.validate(paramSpec{ Allowed: []string{ "cancel_at_period_end", + "cancel_at", "pause_collection", "pause_collection[behavior]", "pause_collection[resumes_at]", @@ -998,7 +1001,7 @@ func validateSubscriptionUpdate(p params) error { }, AllowedRegex: []*regexp.Regexp{subscriptionItemRE, cancellationDetailsRE, discountParamRE, defaultTaxRatesParamRE}, BoolParams: []string{"cancel_at_period_end"}, - Int64Params: []string{"pause_collection[resumes_at]", "proration_date"}, + Int64Params: []string{"pause_collection[resumes_at]", "proration_date", "cancel_at"}, EnumParams: map[string][]string{ "pause_collection[behavior]": {"void", "keep_as_draft", "mark_uncollectible"}, "proration_behavior": {"none", "create_prorations", "always_invoice"}, @@ -1231,6 +1234,18 @@ func validateInvoiceSend(p params) error { return p.validate(paramSpec{}) } +func validateInvoiceVoid(p params) error { + return p.validate(paramSpec{}) +} + +func validateInvoiceMarkUncollectible(p params) error { + return p.validate(paramSpec{}) +} + +func validateCheckoutSessionExpire(p params) error { + return p.validate(paramSpec{}) +} + func validateInvoicePreview(p params) error { if err := p.validate(paramSpec{ Allowed: []string{ diff --git a/internal/billing/service.go b/internal/billing/service.go index 2fcf43d..c7d824c 100644 --- a/internal/billing/service.go +++ b/internal/billing/service.go @@ -82,6 +82,7 @@ type Repository interface { GetCheckoutSession(context.Context, string) (CheckoutSession, error) ListCheckoutSessions(context.Context) ([]CheckoutSession, error) UpdateCheckoutSessionDiscounts(context.Context, string, []Discount) (CheckoutSession, error) + UpdateCheckoutSession(context.Context, CheckoutSession, []TimelineEntry) (CheckoutSession, error) RecordCheckoutCompletion(context.Context, CheckoutCompletion) (CheckoutSession, error) GetSubscription(context.Context, string) (Subscription, error) @@ -414,6 +415,41 @@ func (s *Service) UpdateCheckoutSessionDiscounts(ctx context.Context, id string, return s.repo.UpdateCheckoutSessionDiscounts(ctx, id, discounts) } +// ExpireCheckoutSession moves an open Checkout Session to expired. +func (s *Service) ExpireCheckoutSession(ctx context.Context, sessionID string) (CheckoutSession, error) { + if strings.TrimSpace(sessionID) == "" { + return CheckoutSession{}, fmt.Errorf("%w: session is required", ErrInvalidInput) + } + session, err := s.repo.GetCheckoutSession(ctx, sessionID) + if err != nil { + return CheckoutSession{}, err + } + if strings.ToLower(strings.TrimSpace(session.Status)) != "open" { + return CheckoutSession{}, fmt.Errorf("%w: status must be open", ErrInvalidInput) + } + at := s.now() + session.Status = "expired" + session.Metadata = copyMap(session.Metadata) + if session.Metadata == nil { + session.Metadata = map[string]string{} + } + session.Metadata["billtap_expired_at"] = at.Format(time.RFC3339Nano) + return s.repo.UpdateCheckoutSession(ctx, session, []TimelineEntry{billingTimelineEntry( + "checkout_session_expired_"+session.ID+"_"+at.Format(time.RFC3339Nano), + "checkout.session.expired", + "Checkout session expired", + ObjectCheckoutSession, + session.ID, + session.CustomerID, + session.ID, + session.SubscriptionID, + session.InvoiceID, + session.PaymentIntentID, + map[string]string{"source": "checkout.session.expire", "status": session.Status}, + at, + )}) +} + func (s *Service) CompleteCheckout(ctx context.Context, sessionID string, outcome string) (CheckoutSession, error) { return s.completeCheckout(ctx, sessionID, outcome, CheckoutCompletionOptions{}) } @@ -748,15 +784,23 @@ func (s *Service) PatchSubscription(ctx context.Context, subscriptionID string, if patch.CancelAtPeriodEnd != nil { sub.Metadata = copyMap(sub.Metadata) sub.CancelAtPeriodEnd = *patch.CancelAtPeriodEnd + _, patchCancelAt := patch.Metadata["cancel_at"] + if patch.Metadata == nil { + patchCancelAt = false + } if *patch.CancelAtPeriodEnd { if sub.CanceledAt == nil { canceledAt := s.now() sub.CanceledAt = &canceledAt } - sub.Metadata["cancel_at"] = sub.CurrentPeriodEnd.Format(time.RFC3339Nano) + if !patchCancelAt { + sub.Metadata["cancel_at"] = sub.CurrentPeriodEnd.Format(time.RFC3339Nano) + } } else { sub.CanceledAt = nil - delete(sub.Metadata, "cancel_at") + if !patchCancelAt { + delete(sub.Metadata, "cancel_at") + } delete(sub.Metadata, "cancellation_details_comment") delete(sub.Metadata, "cancellation_details_feedback") if sub.Status == "canceled" { @@ -1074,6 +1118,48 @@ func (s *Service) SendInvoice(ctx context.Context, invoiceID string) (Invoice, e )}) } +func (s *Service) VoidInvoice(ctx context.Context, invoiceID string) (Invoice, error) { + return s.transitionOpenInvoice(ctx, invoiceID, "void", "invoice.voided", "Invoice voided", "invoice.void", "billtap_voided_at") +} + +func (s *Service) MarkInvoiceUncollectible(ctx context.Context, invoiceID string) (Invoice, error) { + return s.transitionOpenInvoice(ctx, invoiceID, "uncollectible", "invoice.marked_uncollectible", "Invoice marked uncollectible", "invoice.mark_uncollectible", "billtap_marked_uncollectible_at") +} + +func (s *Service) transitionOpenInvoice(ctx context.Context, invoiceID string, status string, action string, message string, source string, timestampKey string) (Invoice, error) { + if strings.TrimSpace(invoiceID) == "" { + return Invoice{}, fmt.Errorf("%w: invoice is required", ErrInvalidInput) + } + invoice, err := s.repo.GetInvoice(ctx, invoiceID) + if err != nil { + return Invoice{}, err + } + if strings.ToLower(strings.TrimSpace(invoice.Status)) != "open" { + return Invoice{}, fmt.Errorf("%w: status must be open", ErrInvalidInput) + } + at := s.now() + invoice.Status = status + invoice.Metadata = copyMap(invoice.Metadata) + if invoice.Metadata == nil { + invoice.Metadata = map[string]string{} + } + invoice.Metadata[timestampKey] = at.Format(time.RFC3339Nano) + return s.repo.UpdateInvoice(ctx, invoice, []TimelineEntry{billingTimelineEntry( + source+"_"+invoice.ID+"_"+at.Format(time.RFC3339Nano), + action, + message, + ObjectInvoice, + invoice.ID, + invoice.CustomerID, + "", + invoice.SubscriptionID, + invoice.ID, + invoice.PaymentIntentID, + map[string]string{"source": source, "status": invoice.Status}, + at, + )}) +} + func (s *Service) PayInvoice(ctx context.Context, invoiceID string, opts InvoicePaymentOptions) (InvoicePaymentResult, error) { if strings.TrimSpace(invoiceID) == "" { return InvoicePaymentResult{}, fmt.Errorf("%w: invoice is required", ErrInvalidInput) diff --git a/internal/storage/billing.go b/internal/storage/billing.go index da13088..128db0c 100644 --- a/internal/storage/billing.go +++ b/internal/storage/billing.go @@ -473,6 +473,34 @@ func (s *SQLiteStore) UpdateCheckoutSessionDiscounts(ctx context.Context, id str return s.GetCheckoutSession(ctx, id) } +func (s *SQLiteStore) UpdateCheckoutSession(ctx context.Context, cs billing.CheckoutSession, timeline []billing.TimelineEntry) (billing.CheckoutSession, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return billing.CheckoutSession{}, err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE checkout_sessions SET status = ?, metadata = ? WHERE id = ?`, cs.Status, encodeMap(cs.Metadata), cs.ID) + if err != nil { + return billing.CheckoutSession{}, err + } + changed, err := result.RowsAffected() + if err != nil { + return billing.CheckoutSession{}, err + } + if changed == 0 { + return billing.CheckoutSession{}, billing.ErrNotFound + } + for _, entry := range timeline { + if err := s.insertTimeline(ctx, tx, entry); err != nil { + return billing.CheckoutSession{}, err + } + } + if err := tx.Commit(); err != nil { + return billing.CheckoutSession{}, err + } + return s.GetCheckoutSession(ctx, cs.ID) +} + func (s *SQLiteStore) RecordCheckoutCompletion(ctx context.Context, c billing.CheckoutCompletion) (billing.CheckoutSession, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil {