From c29faa41049a1f60c4da7190c88ac2651d122eff Mon Sep 17 00:00:00 2001 From: midagedev Date: Tue, 25 Aug 2026 08:19:21 +0900 Subject: [PATCH] feat(api): trial_end preview/confirm parity, full-remainder refunds, credit note memo/oob/refund_amount Newer Stripe SDK surfaces that clients actually send, wired end to end: - invoices/create_preview accepts subscription_details[trial_end] (unix or "now") and prices the first paid cycle instead of rejecting with parameter_unknown. - subscriptions/{id} trial_end=now (or a non-future unix) actually ends the trial: trialing -> active through the existing proration/invoice path, so preview and confirm produce the same amount (test pins subtotal/tax/total). A future unix keeps updating trial_end only. Previously the param was echoed into metadata and silently ignored. - refunds: omitted amount defaults to the remaining refundable balance (charged minus non-canceled refunds); explicit over-refunds are rejected. - credit_notes: memo / out_of_band_amount / refund_amount are accepted, persisted (migration 021) and echoed; amount defaults to oob+refund. out_of_band settlement does not credit customer cash balance. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HapqvjSLa6A6zRqe6dYyvu --- CHANGELOG.md | 13 + internal/api/api.go | 136 ++++++--- internal/api/api_test.go | 264 ++++++++++++++++++ internal/api/validation.go | 44 ++- internal/billing/models.go | 33 ++- internal/billing/service.go | 85 +++++- internal/storage/billing.go | 16 +- .../migrations/021_credit_note_allocation.sql | 5 + internal/storage/storage_test.go | 4 +- 9 files changed, 533 insertions(+), 67 deletions(-) create mode 100644 internal/storage/migrations/021_credit_note_allocation.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index c01e2a5..cc61f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- Invoice preview accepts `subscription_details[trial_end]` (`now` or a unix + timestamp). For a `trialing` subscription, `trial_end=now` (or a timestamp + that is not in the future) previews the first paid cycle instead of a + zero-amount remaining-trial window. `POST /v1/subscriptions/{id}` with the + same `trial_end` moves the subscription to `active` and invoices that same + first-cycle amount so preview and confirm agree. +- `POST /v1/refunds` treats a missing `amount` as a full refund of the + remaining refundable balance on the invoice or payment intent. Requested + amounts still cannot exceed that remaining balance. +- `POST /v1/credit_notes` accepts `memo`, `out_of_band_amount`, and + `refund_amount`, persists them, and echoes them (plus derived + `credit_amount`) on create and retrieve. `out_of_band_amount` is external + settlement and does not change customer cash balance. - `POST /v1/invoiceitems` now accepts `pricing[price]` and `quantity` as an alternative to `amount` (`amount` cannot be sent with `pricing` or `quantity`). The line amount is the price's `unit_amount` times `quantity` diff --git a/internal/api/api.go b/internal/api/api.go index 9656ae5..63d1ee0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -2124,13 +2124,15 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { replaceItems := hasSubscriptionItemPatch(p) var items []billing.LineItem var current billing.Subscription - if replaceItems { + if replaceItems || p.has("trial_end") { current, err = h.billing.GetSubscription(r.Context(), id) if err != nil { writeResult(w, nil, err) return } - items = subscriptionItemsFromParams(p, current) + if replaceItems { + items = subscriptionItemsFromParams(p, current) + } } metadata := subscriptionUpdateMetadata(p) discounts, err := h.discountsFromParams(p) @@ -2182,28 +2184,49 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { } } } + now := time.Now().UTC() + trialEndAt, hasTrialEnd := unixTimestampOrNow(p.string("trial_end"), now) + endTrialNow := hasTrialEnd && strings.EqualFold(current.Status, "trialing") && !trialEndAt.After(now) + if hasTrialEnd { + if metadata == nil { + metadata = map[string]string{} + } + metadata["trial_end"] = trialEndAt.Format(time.RFC3339Nano) + } prorationBehavior := p.string("proration_behavior") if prorationBehavior == "" { prorationBehavior = "none" } + if endTrialNow && len(items) == 0 { + items = append([]billing.LineItem{}, current.Items...) + } // Item change with create_prorations / always_invoice bills (or defers) proration. - if replaceItems && (prorationBehavior == "always_invoice" || prorationBehavior == "create_prorations") { - prorationDate := time.Now().UTC() - if raw := p.string("proration_date"); raw != "" { + // Ending a trial immediately always invoices the first paid cycle. + billNow := endTrialNow || (replaceItems && (prorationBehavior == "always_invoice" || prorationBehavior == "create_prorations")) + if billNow { + prorationDate := now + if endTrialNow { + prorationDate = trialEndAt + } else if raw := p.string("proration_date"); raw != "" { if seconds, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil { prorationDate = time.Unix(seconds, 0).UTC() } } + anchor := p.string("billing_cycle_anchor") + if endTrialNow && anchor == "" { + anchor = "now" + } result, err := h.billing.UpdateSubscriptionItemsWithProration(r.Context(), billing.SubscriptionProrationRequest{ SubscriptionID: id, NewItems: items, ProrationBehavior: prorationBehavior, ProrationDate: prorationDate, - BillingCycleAnchor: p.string("billing_cycle_anchor"), + BillingCycleAnchor: anchor, PaymentBehavior: p.string("payment_behavior"), DefaultTaxRates: defaultTaxRates, Metadata: metadata, CancelAtPeriodEnd: p.boolPtr("cancel_at_period_end"), + EndTrial: endTrialNow, }) if err != nil { writeResult(w, nil, err) @@ -2220,12 +2243,16 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { writeResult(w, h.stripeSubscription(r, result.Subscription), nil) return } - subscription, err := h.billing.PatchSubscription(r.Context(), id, billing.SubscriptionPatch{ + patch := billing.SubscriptionPatch{ Items: items, ReplaceItems: replaceItems, Metadata: metadata, CancelAtPeriodEnd: p.boolPtr("cancel_at_period_end"), - }) + } + if hasTrialEnd && strings.EqualFold(current.Status, "trialing") && trialEndAt.After(now) { + patch.CurrentPeriodEnd = &trialEndAt + } + subscription, err := h.billing.PatchSubscription(r.Context(), id, patch) if err == nil { h.emitSubscriptionWebhook(r, "customer.subscription.updated", subscription, webhooks.SourceAPI) if len(discounts) > 0 { @@ -2491,8 +2518,15 @@ func (h *Handler) invoicePreview(ctx context.Context, path string, p params) (ma } } // No item override + subscription → next-period upcoming invoice (Stripe-compatible). - // Override detection shares invoicePreviewHasItemOverrides with the items loader path. - if subscription.ID != "" && !invoicePreviewHasItemOverrides(p) { + // Ending a trial immediately also uses that path (full first paid cycle, including + // item overrides) so preview amounts match confirm. + endingTrialNow := false + if subscription.ID != "" && strings.EqualFold(subscription.Status, "trialing") { + if trialEnd, ok := invoicePreviewTrialEnd(p, now); ok && !trialEnd.After(now) { + endingTrialNow = true + } + } + if subscription.ID != "" && (!invoicePreviewHasItemOverrides(p) || endingTrialNow) { return h.invoicePreviewNextPeriod(ctx, path, p, subscription, customerID, now) } items := invoicePreviewLineItems(p) @@ -2756,6 +2790,9 @@ func invoicePreviewHasItemOverrides(p params) bool { // invoice when no subscription_details items are overridden. func (h *Handler) invoicePreviewNextPeriod(ctx context.Context, path string, p params, subscription billing.Subscription, customerID string, now time.Time) (map[string]any, error) { items := append([]billing.LineItem{}, subscription.Items...) + if overrides := invoicePreviewLineItems(p); len(overrides) > 0 { + items = overrides + } behavior := invoicePreviewProrationBehavior(p) createdAt := invoicePreviewProrationDate(p, now) billingCycleAnchor := invoicePreviewBillingCycleAnchor(p, createdAt) @@ -2764,7 +2801,11 @@ func (h *Handler) invoicePreviewNextPeriod(ctx context.Context, path string, p p // Next period starts at current_period_end. For trialing, first charge is at trial_end. periodStart := subscription.CurrentPeriodEnd if strings.EqualFold(subscription.Status, "trialing") { - if trialEnd := subscriptionTrialEndTime(subscription); !trialEnd.IsZero() { + trialEnd := subscriptionTrialEndTime(subscription) + if override, ok := invoicePreviewTrialEnd(p, now); ok { + trialEnd = override + } + if !trialEnd.IsZero() { periodStart = trialEnd } } @@ -3130,6 +3171,25 @@ func invoicePreviewBillingCycleAnchor(p params, fallback time.Time) time.Time { return time.Unix(seconds, 0).UTC() } +func invoicePreviewTrialEnd(p params, now time.Time) (time.Time, bool) { + return unixTimestampOrNow(p.first("subscription_details[trial_end]", "subscriptionDetails[trialEnd]"), now) +} + +func unixTimestampOrNow(raw string, now time.Time) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + if raw == "now" { + return now, true + } + seconds, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return time.Time{}, false + } + return time.Unix(seconds, 0).UTC(), true +} + func invoicePreviewLineItems(p params) []billing.LineItem { var out []billing.LineItem for i := 0; i < 100; i++ { @@ -3465,13 +3525,17 @@ func (h *Handler) handleRefunds(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } + amount := int64(0) + if p.has("amount") { + amount = p.int64("amount") + } refund, err := h.billing.CreateRefund(r.Context(), billing.Refund{ ID: p.string("id"), ChargeID: p.string("charge"), PaymentIntentID: p.string("payment_intent"), InvoiceID: p.string("invoice"), CustomerID: p.string("customer"), - Amount: p.int64("amount"), + Amount: amount, Currency: p.string("currency"), Reason: p.string("reason"), Status: p.string("status"), @@ -3558,14 +3622,17 @@ func (h *Handler) handleCreditNotes(w http.ResponseWriter, r *http.Request) { return } note, err := h.billing.CreateCreditNote(r.Context(), billing.CreditNote{ - ID: p.string("id"), - InvoiceID: p.string("invoice"), - CustomerID: p.string("customer"), - Amount: p.int64("amount"), - Currency: p.string("currency"), - Reason: p.string("reason"), - Status: p.string("status"), - Metadata: p.metadata(), + ID: p.string("id"), + InvoiceID: p.string("invoice"), + CustomerID: p.string("customer"), + Amount: p.int64("amount"), + Currency: p.string("currency"), + Reason: p.string("reason"), + Status: p.string("status"), + Memo: p.string("memo"), + OutOfBandAmount: p.int64("out_of_band_amount"), + RefundAmount: p.int64("refund_amount"), + Metadata: p.metadata(), }) if err == nil { h.emitGenericWebhook(r, "credit_note.created", note.ID, stripeCreditNote(note), webhooks.SourceAPI) @@ -7695,18 +7762,22 @@ func stripeChargeFromRefund(refund billing.Refund) map[string]any { func stripeCreditNote(note billing.CreditNote) map[string]any { return map[string]any{ - "id": note.ID, - "object": billing.ObjectCreditNote, - "invoice": note.InvoiceID, - "customer": emptyToNil(note.CustomerID), - "amount": note.Amount, - "currency": note.Currency, - "reason": emptyToNil(note.Reason), - "status": note.Status, - "metadata": nonNilMap(note.Metadata), - "created": unix(note.CreatedAt), - "livemode": false, - "lines": stripeList("/v1/credit_notes/"+note.ID+"/lines", []map[string]any{}), + "id": note.ID, + "object": billing.ObjectCreditNote, + "invoice": note.InvoiceID, + "customer": emptyToNil(note.CustomerID), + "amount": note.Amount, + "currency": note.Currency, + "reason": emptyToNil(note.Reason), + "status": note.Status, + "memo": emptyToNil(note.Memo), + "out_of_band_amount": note.OutOfBandAmount, + "refund_amount": note.RefundAmount, + "credit_amount": note.CreditAmount(), + "metadata": nonNilMap(note.Metadata), + "created": unix(note.CreatedAt), + "livemode": false, + "lines": stripeList("/v1/credit_notes/"+note.ID+"/lines", []map[string]any{}), } } @@ -8240,7 +8311,6 @@ 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: "trial_end", key: "trial_end"}, } { if value := p.string(item.param); value != "" { if metadata == nil { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index a53d7d0..bde8794 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -6650,6 +6650,270 @@ func TestInvoicePreviewProrationAndUpcoming(t *testing.T) { } } +func TestInvoicePreviewTrialEndNowMatchesConfirm(t *testing.T) { + handler := newTestHandler(t) + customer, lite, _, taxRateID := setupProrationPlans(t, handler) + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {lite.ID}, + "subscription_data[trial_period_days]": {"14"}, + "subscription_data[default_tax_rates][0]": {taxRateID}, + }) + completion := postJSON[struct { + Subscription billing.Subscription `json:"subscription"` + }](t, handler, "/api/checkout/sessions/"+session.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + if completion.Subscription.ID == "" || completion.Subscription.Status != "trialing" { + t.Fatalf("subscription = %#v, want trialing", completion.Subscription) + } + subID := completion.Subscription.ID + createInvoice := completion.Subscription.LatestInvoiceID + + endAt := time.Now().UTC().Unix() + endAtRaw := strconv.FormatInt(endAt, 10) + preview := postForm[upcomingPreviewFields](t, handler, "/v1/invoices/create_preview", url.Values{ + "customer": {customer.ID}, + "subscription": {subID}, + "subscription_details[trial_end]": {endAtRaw}, + }) + if preview.Object != "invoice" || preview.Subtotal != 4900 || taxVal(preview) != 490 || preview.Total != 5390 || preview.AmountDue != 5390 { + t.Fatalf("trial_end preview = %#v, want first-cycle 4900/490/5390", preview) + } + if preview.Total <= 0 { + t.Fatalf("trial_end preview total = %d, want non-zero first charge", preview.Total) + } + assertInvoiceInvariant(t, preview) + + updated := postForm[prorationSubResponse](t, handler, "/v1/subscriptions/"+subID, url.Values{ + "trial_end": {endAtRaw}, + }) + if updated.LatestInvoice == "" || updated.LatestInvoice == createInvoice { + t.Fatalf("latest_invoice = %q, want new first-cycle invoice (create was %q)", updated.LatestInvoice, createInvoice) + } + gotSub := getJSON[struct { + Status string `json:"status"` + TrialEnd int64 `json:"trial_end"` + }](t, handler, "/v1/subscriptions/"+subID) + if gotSub.Status != "active" { + t.Fatalf("status = %q, want active", gotSub.Status) + } + if gotSub.TrialEnd != endAt { + t.Fatalf("trial_end = %d, want %d", gotSub.TrialEnd, endAt) + } + + invoice := getJSON[prorationInvoiceResponse](t, handler, "/v1/invoices/"+updated.LatestInvoice) + if invoice.Subtotal != preview.Subtotal || invoice.Total != preview.Total || invoice.Tax != taxVal(preview) || invoice.AmountPaid != preview.AmountDue { + t.Fatalf("confirm invoice=%#v preview subtotal/tax/total/due=%d/%d/%d/%d, want match", + invoice, preview.Subtotal, taxVal(preview), preview.Total, preview.AmountDue) + } + t.Logf("preview↔confirm cents: subtotal=%d tax=%d total=%d preview_amount_due=%d confirm_amount_paid=%d", + preview.Subtotal, taxVal(preview), preview.Total, preview.AmountDue, invoice.AmountPaid) +} + +func TestInvoicePreviewTrialEndNowAccepted(t *testing.T) { + handler := newTestHandler(t) + customer, lite, _, _ := setupProrationPlans(t, handler) + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {lite.ID}, + "subscription_data[trial_period_days]": {"14"}, + }) + completion := postJSON[struct { + Subscription billing.Subscription `json:"subscription"` + }](t, handler, "/api/checkout/sessions/"+session.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + + status, body := postFormStatus(t, handler, "/v1/invoices/create_preview", url.Values{ + "subscription": {completion.Subscription.ID}, + "subscription_details[trial_end]": {"now"}, + }) + if status != http.StatusOK { + t.Fatalf("status=%d body=%s, want 200 (trial_end=now allowed)", status, body) + } + preview := postForm[upcomingPreviewFields](t, handler, "/v1/invoices/create_preview", url.Values{ + "subscription": {completion.Subscription.ID}, + "subscription_details[trial_end]": {"now"}, + }) + if preview.Total != 4900 || preview.AmountDue != 4900 { + t.Fatalf("trial_end=now preview = %#v, want 4900 first-cycle total", preview) + } +} + +func TestSubscriptionUpdateFutureTrialEndUpdatesTimestamp(t *testing.T) { + handler := newTestHandler(t) + customer, lite, _, _ := setupProrationPlans(t, handler) + trialEnd := time.Date(2030, 2, 15, 0, 0, 0, 0, time.UTC) + future := time.Date(2030, 3, 1, 0, 0, 0, 0, time.UTC) + applied := postJSON[fixtures.ApplyResult](t, handler, "/api/fixtures/apply", map[string]any{ + "name": "future-trial-end", + "subscriptions": []map[string]any{{ + "id": "sub_future_trial", + "customer": customer.ID, + "price": lite.ID, + "status": "trialing", + "current_period_start": "2030-01-15T00:00:00Z", + "current_period_end": trialEnd.Format(time.RFC3339), + "trial_start": "2030-01-15T00:00:00Z", + "trial_end": trialEnd.Format(time.RFC3339), + }}, + }) + if len(applied.Subscriptions) != 1 { + t.Fatalf("fixture = %#v", applied) + } + updated := postForm[struct { + Status string `json:"status"` + TrialEnd int64 `json:"trial_end"` + CurrentPeriodEnd int64 `json:"current_period_end"` + LatestInvoice string `json:"latest_invoice"` + }](t, handler, "/v1/subscriptions/sub_future_trial", url.Values{ + "trial_end": {strconv.FormatInt(future.Unix(), 10)}, + }) + if updated.Status != "trialing" { + t.Fatalf("status = %q, want still trialing", updated.Status) + } + if updated.TrialEnd != future.Unix() || updated.CurrentPeriodEnd != future.Unix() { + t.Fatalf("trial/period end = %d/%d, want %d", updated.TrialEnd, updated.CurrentPeriodEnd, future.Unix()) + } +} + +func TestRefundOmitsAmountRefundsRemaining(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"refund-full@example.test"}}) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Refund Full"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"30000"}, + }) + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {price.ID}, + }) + completion := postJSON[struct { + Invoice billing.Invoice `json:"invoice"` + }](t, handler, "/api/checkout/sessions/"+session.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + + partial := postForm[struct { + Amount int64 `json:"amount"` + }](t, handler, "/v1/refunds", url.Values{ + "invoice": {completion.Invoice.ID}, + "amount": {"10000"}, + }) + if partial.Amount != 10000 { + t.Fatalf("partial refund = %#v, want 10000", partial) + } + full := postForm[struct { + Amount int64 `json:"amount"` + }](t, handler, "/v1/refunds", url.Values{ + "invoice": {completion.Invoice.ID}, + }) + if full.Amount != 20000 { + t.Fatalf("omitted-amount refund = %#v, want remaining 20000", full) + } + + status, body := postFormStatus(t, handler, "/v1/refunds", url.Values{ + "invoice": {completion.Invoice.ID}, + "amount": {"1"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400 over-refund", status, body) + } + errBody := decodeErrorBody(t, body) + if errBody.Error.Param != "amount" || errBody.Error.Code != "parameter_invalid" { + t.Fatalf("error=%#v, want amount parameter_invalid", errBody.Error) + } +} + +func TestCreditNoteOutOfBandMemoRefundAmount(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"credit-oob@example.test"}}) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Credit OOB"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"30000"}, + }) + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {price.ID}, + }) + completion := postJSON[struct { + Invoice billing.Invoice `json:"invoice"` + }](t, handler, "/api/checkout/sessions/"+session.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + + _ = postForm[map[string]any](t, handler, "/v1/test_helpers/customers/"+customer.ID+"/fund_cash_balance", url.Values{ + "amount": {"1000"}, + "currency": {"usd"}, + }) + before := getJSON[struct { + Available map[string]int64 `json:"available"` + }](t, handler, "/v1/customers/"+customer.ID+"/cash_balance") + if before.Available["usd"] != 1000 { + t.Fatalf("cash balance before = %#v, want 1000", before.Available) + } + + created := postForm[struct { + ID string `json:"id"` + Amount int64 `json:"amount"` + Memo string `json:"memo"` + OutOfBandAmount int64 `json:"out_of_band_amount"` + RefundAmount int64 `json:"refund_amount"` + CreditAmount int64 `json:"credit_amount"` + Status string `json:"status"` + }](t, handler, "/v1/credit_notes", url.Values{ + "invoice": {completion.Invoice.ID}, + "amount": {"8000"}, + "memo": {"settled outside the processor"}, + "out_of_band_amount": {"5000"}, + "refund_amount": {"3000"}, + }) + if created.ID == "" || created.Status != "issued" { + t.Fatalf("credit note = %#v, want issued", created) + } + if created.Amount != 8000 || created.OutOfBandAmount != 5000 || created.RefundAmount != 3000 || created.CreditAmount != 0 || created.Memo != "settled outside the processor" { + t.Fatalf("credit note allocation = %#v, want amount=8000 oob=5000 refund=3000 credit=0", created) + } + + got := getJSON[struct { + Amount int64 `json:"amount"` + Memo string `json:"memo"` + OutOfBandAmount int64 `json:"out_of_band_amount"` + RefundAmount int64 `json:"refund_amount"` + CreditAmount int64 `json:"credit_amount"` + }](t, handler, "/v1/credit_notes/"+created.ID) + if got.Amount != 8000 || got.OutOfBandAmount != 5000 || got.RefundAmount != 3000 || got.CreditAmount != 0 || got.Memo != "settled outside the processor" { + t.Fatalf("retrieved credit note = %#v, want persisted allocation", got) + } + + listed := getJSON[struct { + Data []struct { + Amount int64 `json:"amount"` + OutOfBandAmount int64 `json:"out_of_band_amount"` + } `json:"data"` + }](t, handler, "/v1/credit_notes?invoice="+completion.Invoice.ID) + if len(listed.Data) != 1 || listed.Data[0].Amount != 8000 || listed.Data[0].OutOfBandAmount != 5000 { + t.Fatalf("invoice credit notes = %#v, want oob included in listed amount", listed.Data) + } + + after := getJSON[struct { + Available map[string]int64 `json:"available"` + }](t, handler, "/v1/customers/"+customer.ID+"/cash_balance") + if after.Available["usd"] != 1000 { + t.Fatalf("cash balance after out_of_band credit note = %#v, want unchanged 1000", after.Available) + } + + oobOnly := postForm[struct { + Amount int64 `json:"amount"` + OutOfBandAmount int64 `json:"out_of_band_amount"` + CreditAmount int64 `json:"credit_amount"` + }](t, handler, "/v1/credit_notes", url.Values{ + "invoice": {completion.Invoice.ID}, + "out_of_band_amount": {"2000"}, + "memo": {"amount omitted; oob is the total"}, + }) + if oobOnly.Amount != 2000 || oobOnly.OutOfBandAmount != 2000 || oobOnly.CreditAmount != 0 { + t.Fatalf("oob-only credit note = %#v, want amount=2000 from out_of_band_amount", oobOnly) + } +} + func TestCustomerDefaultInvoiceOutcomeFailsRenewal(t *testing.T) { handler := newTestHandler(t) product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Renewal Team"}}) diff --git a/internal/api/validation.go b/internal/api/validation.go index b1fc680..c803430 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -1239,9 +1239,11 @@ func validateInvoicePreview(p params) error { "subscription_details[proration_behavior]", "subscription_details[proration_date]", "subscription_details[billing_cycle_anchor]", + "subscription_details[trial_end]", "subscriptionDetails[prorationBehavior]", "subscriptionDetails[prorationDate]", "subscriptionDetails[billingCycleAnchor]", + "subscriptionDetails[trialEnd]", "preview_mode", "coupon", "promotion_code", @@ -1257,7 +1259,12 @@ func validateInvoicePreview(p params) error { }); err != nil { return err } - for _, key := range []string{"subscription_details[billing_cycle_anchor]", "subscriptionDetails[billingCycleAnchor]"} { + for _, key := range []string{ + "subscription_details[billing_cycle_anchor]", + "subscriptionDetails[billingCycleAnchor]", + "subscription_details[trial_end]", + "subscriptionDetails[trialEnd]", + } { if err := p.validateUnixTimestampOrNow(key); err != nil { return err } @@ -1288,9 +1295,6 @@ func validateRefundCreate(p params) error { }); err != nil { return err } - if !p.has("amount") { - return missingParam("amount") - } return nil } @@ -1308,16 +1312,34 @@ func validateRefundUpdate(p params) error { } func validateCreditNoteCreate(p params) error { - return p.validate(paramSpec{ - Allowed: []string{"id", "invoice", "customer", "amount", "currency", "reason", "status"}, - Required: []string{"invoice", "amount"}, - Int64Params: []string{"amount"}, - Positive: []string{"amount"}, + if err := p.validate(paramSpec{ + Allowed: []string{ + "id", + "invoice", + "customer", + "amount", + "currency", + "reason", + "status", + "memo", + "out_of_band_amount", + "refund_amount", + }, + Required: []string{"invoice"}, + Int64Params: []string{"amount", "out_of_band_amount", "refund_amount"}, + Positive: []string{"amount"}, + NonNegative: []string{"out_of_band_amount", "refund_amount"}, + AllowMetadata: true, EnumParams: map[string][]string{ "status": {"issued", "void"}, }, - AllowMetadata: true, - }) + }); err != nil { + return err + } + if !p.has("amount") && !p.has("out_of_band_amount") && !p.has("refund_amount") { + return missingParam("amount") + } + return nil } func validatePaymentIntentConfirm(p params) error { diff --git a/internal/billing/models.go b/internal/billing/models.go index 4690dc8..22c8f3e 100644 --- a/internal/billing/models.go +++ b/internal/billing/models.go @@ -288,16 +288,29 @@ type Refund struct { } type CreditNote struct { - ID string `json:"id"` - Object string `json:"object"` - InvoiceID string `json:"invoice"` - CustomerID string `json:"customer,omitempty"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - Reason string `json:"reason,omitempty"` - Status string `json:"status"` - Metadata map[string]string `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Object string `json:"object"` + InvoiceID string `json:"invoice"` + CustomerID string `json:"customer,omitempty"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + Reason string `json:"reason,omitempty"` + Status string `json:"status"` + Memo string `json:"memo,omitempty"` + OutOfBandAmount int64 `json:"out_of_band_amount,omitempty"` + RefundAmount int64 `json:"refund_amount,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// CreditAmount is the portion of Amount not allocated to out-of-band settlement +// or a linked refund. It is derived, not stored. +func (n CreditNote) CreditAmount() int64 { + credit := n.Amount - n.OutOfBandAmount - n.RefundAmount + if credit < 0 { + return 0 + } + return credit } type Account struct { diff --git a/internal/billing/service.go b/internal/billing/service.go index e5a8d4b..a06717b 100644 --- a/internal/billing/service.go +++ b/internal/billing/service.go @@ -1289,9 +1289,6 @@ func (s *Service) AdvanceTestClock(ctx context.Context, clockID string, frozenTi } func (s *Service) CreateRefund(ctx context.Context, in Refund) (Refund, error) { - if in.Amount <= 0 { - return Refund{}, fmt.Errorf("%w: amount must be at least 1", ErrInvalidInput) - } now := s.now() if strings.TrimSpace(in.ID) == "" { in.ID = id("re") @@ -1302,6 +1299,7 @@ func (s *Service) CreateRefund(ctx context.Context, in Refund) (Refund, error) { in.ChargeID = strings.TrimSpace(in.ChargeID) in.PaymentIntentID = strings.TrimSpace(in.PaymentIntentID) in.InvoiceID = strings.TrimSpace(in.InvoiceID) + charged := int64(0) if in.PaymentIntentID != "" { intent, err := s.repo.GetPaymentIntent(ctx, in.PaymentIntentID) if err != nil { @@ -1310,6 +1308,9 @@ func (s *Service) CreateRefund(ctx context.Context, in Refund) (Refund, error) { in.CustomerID = firstNonEmpty(in.CustomerID, intent.CustomerID) in.InvoiceID = firstNonEmpty(in.InvoiceID, intent.InvoiceID) in.Currency = firstNonEmpty(in.Currency, intent.Currency) + if intent.Status == "succeeded" { + charged = intent.Amount + } } if in.InvoiceID != "" { invoice, err := s.repo.GetInvoice(ctx, in.InvoiceID) @@ -1321,6 +1322,9 @@ func (s *Service) CreateRefund(ctx context.Context, in Refund) (Refund, error) { if in.PaymentIntentID == "" { in.PaymentIntentID = invoice.PaymentIntentID } + if invoice.AmountPaid > 0 { + charged = invoice.AmountPaid + } } if in.ChargeID == "" && in.PaymentIntentID != "" { in.ChargeID = "ch_" + sanitizeID(in.PaymentIntentID) @@ -1328,6 +1332,26 @@ func (s *Service) CreateRefund(ctx context.Context, in Refund) (Refund, error) { if in.ChargeID == "" { return Refund{}, fmt.Errorf("%w: charge or payment_intent is required", ErrInvalidInput) } + if charged > 0 { + already, err := s.refundedAmount(ctx, in) + if err != nil { + return Refund{}, err + } + remaining := charged - already + if remaining < 0 { + remaining = 0 + } + if in.Amount <= 0 { + if remaining <= 0 { + return Refund{}, fmt.Errorf("%w: amount must be at least 1", ErrInvalidInput) + } + in.Amount = remaining + } else if in.Amount > remaining { + return Refund{}, fmt.Errorf("%w: amount must be less than or equal to the unrefunded amount", ErrInvalidInput) + } + } else if in.Amount <= 0 { + return Refund{}, fmt.Errorf("%w: amount must be at least 1", ErrInvalidInput) + } if in.CreatedAt.IsZero() { in.CreatedAt = now } @@ -1347,6 +1371,33 @@ func (s *Service) CreateRefund(ctx context.Context, in Refund) (Refund, error) { )}) } +func (s *Service) refundedAmount(ctx context.Context, in Refund) (int64, error) { + filter := RefundFilter{} + switch { + case in.InvoiceID != "": + filter.InvoiceID = in.InvoiceID + case in.PaymentIntentID != "": + filter.PaymentIntentID = in.PaymentIntentID + case in.ChargeID != "": + filter.ChargeID = in.ChargeID + default: + return 0, nil + } + existing, err := s.repo.ListRefundsFiltered(ctx, filter) + if err != nil { + return 0, err + } + var total int64 + for _, refund := range existing { + switch strings.ToLower(strings.TrimSpace(refund.Status)) { + case "canceled", "failed": + continue + } + total += refund.Amount + } + return total, nil +} + func (s *Service) GetRefund(ctx context.Context, refundID string) (Refund, error) { return s.repo.GetRefund(ctx, refundID) } @@ -1392,6 +1443,15 @@ func (s *Service) CreateCreditNote(ctx context.Context, in CreditNote) (CreditNo if strings.TrimSpace(in.InvoiceID) == "" { return CreditNote{}, fmt.Errorf("%w: invoice is required", ErrInvalidInput) } + if in.OutOfBandAmount < 0 { + return CreditNote{}, fmt.Errorf("%w: out_of_band_amount must be at least 0", ErrInvalidInput) + } + if in.RefundAmount < 0 { + return CreditNote{}, fmt.Errorf("%w: refund_amount must be at least 0", ErrInvalidInput) + } + if in.Amount <= 0 { + in.Amount = in.OutOfBandAmount + in.RefundAmount + } if in.Amount <= 0 { return CreditNote{}, fmt.Errorf("%w: amount must be at least 1", ErrInvalidInput) } @@ -1407,6 +1467,9 @@ func (s *Service) CreateCreditNote(ctx context.Context, in CreditNote) (CreditNo in.Status = firstNonEmpty(strings.TrimSpace(in.Status), "issued") in.CustomerID = firstNonEmpty(in.CustomerID, invoice.CustomerID) in.Currency = strings.ToLower(firstNonEmpty(strings.TrimSpace(in.Currency), invoice.Currency, "usd")) + in.Memo = strings.TrimSpace(in.Memo) + // out_of_band_amount is external settlement: it is stored and echoed but + // must not credit customer cash balance (that balance lives outside this service). if in.CreatedAt.IsZero() { in.CreatedAt = now } @@ -2211,6 +2274,9 @@ type SubscriptionProrationRequest struct { DefaultTaxRates []AppliedTaxRate // optional override; empty uses subscription metadata Metadata map[string]string CancelAtPeriodEnd *bool + // EndTrial bills the first paid cycle for a trialing subscription + // (trial_end=now or a unix timestamp that is not in the future). + EndTrial bool } // SubscriptionProrationResult is the outcome of a proration-aware subscription update. @@ -2303,6 +2369,19 @@ func (s *Service) UpdateSubscriptionItemsWithProration(ctx context.Context, req } updated.Metadata["stripe_compat_updated_at"] = at.Format(time.RFC3339Nano) + if req.EndTrial && strings.EqualFold(sub.Status, "trialing") { + // Trial invoices were $0, so unused trial time is not a paid credit. + oldTotal = 0 + oldDiscounted = 0 + updated.Status = "active" + updated.Metadata["trial_end"] = at.Format(time.RFC3339Nano) + if strings.TrimSpace(updated.Metadata["trial_start"]) == "" && !sub.CurrentPeriodStart.IsZero() { + updated.Metadata["trial_start"] = sub.CurrentPeriodStart.Format(time.RFC3339Nano) + } + behavior = "always_invoice" + anchor = "now" + } + rates := req.DefaultTaxRates if len(rates) == 0 { rates = DefaultTaxRatesFromMetadata(updated.Metadata) diff --git a/internal/storage/billing.go b/internal/storage/billing.go index ae1d3fe..c106e11 100644 --- a/internal/storage/billing.go +++ b/internal/storage/billing.go @@ -1325,9 +1325,9 @@ func (s *SQLiteStore) CreateCreditNote(ctx context.Context, note billing.CreditN return billing.CreditNote{}, err } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `INSERT INTO credit_notes (id, invoice_id, customer_id, amount, currency, reason, status, metadata, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - note.ID, note.InvoiceID, note.CustomerID, note.Amount, note.Currency, note.Reason, note.Status, encodeMap(note.Metadata), encodeTime(note.CreatedAt)); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO credit_notes (id, invoice_id, customer_id, amount, currency, reason, status, metadata, created_at, memo, out_of_band_amount, refund_amount) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + note.ID, note.InvoiceID, note.CustomerID, note.Amount, note.Currency, note.Reason, note.Status, encodeMap(note.Metadata), encodeTime(note.CreatedAt), note.Memo, note.OutOfBandAmount, note.RefundAmount); err != nil { return billing.CreditNote{}, err } for _, entry := range timeline { @@ -1342,7 +1342,7 @@ func (s *SQLiteStore) CreateCreditNote(ctx context.Context, note billing.CreditN } func (s *SQLiteStore) GetCreditNote(ctx context.Context, id string) (billing.CreditNote, error) { - row := s.db.QueryRowContext(ctx, `SELECT id, invoice_id, customer_id, amount, currency, reason, status, metadata, created_at FROM credit_notes WHERE id = ?`, id) + row := s.db.QueryRowContext(ctx, `SELECT id, invoice_id, customer_id, amount, currency, reason, status, metadata, created_at, memo, out_of_band_amount, refund_amount FROM credit_notes WHERE id = ?`, id) note, err := scanCreditNote(row) if errors.Is(err, sql.ErrNoRows) { return billing.CreditNote{}, billing.ErrNotFound @@ -1361,7 +1361,7 @@ func (s *SQLiteStore) ListCreditNotesFiltered(ctx context.Context, filter billin clauses = append(clauses, "customer_id = ?") args = append(args, filter.CustomerID) } - rows, err := s.db.QueryContext(ctx, `SELECT id, invoice_id, customer_id, amount, currency, reason, status, metadata, created_at + rows, err := s.db.QueryContext(ctx, `SELECT id, invoice_id, customer_id, amount, currency, reason, status, metadata, created_at, memo, out_of_band_amount, refund_amount FROM credit_notes WHERE `+strings.Join(clauses, " AND ")+` ORDER BY created_at DESC, id DESC`, args...) if err != nil { return nil, err @@ -1385,9 +1385,9 @@ func (s *SQLiteStore) UpdateCreditNote(ctx context.Context, note billing.CreditN } defer tx.Rollback() result, err := tx.ExecContext(ctx, `UPDATE credit_notes - SET invoice_id = ?, customer_id = ?, amount = ?, currency = ?, reason = ?, status = ?, metadata = ? + SET invoice_id = ?, customer_id = ?, amount = ?, currency = ?, reason = ?, status = ?, metadata = ?, memo = ?, out_of_band_amount = ?, refund_amount = ? WHERE id = ?`, - note.InvoiceID, note.CustomerID, note.Amount, note.Currency, note.Reason, note.Status, encodeMap(note.Metadata), note.ID) + note.InvoiceID, note.CustomerID, note.Amount, note.Currency, note.Reason, note.Status, encodeMap(note.Metadata), note.Memo, note.OutOfBandAmount, note.RefundAmount, note.ID) if err != nil { return billing.CreditNote{}, err } @@ -1719,7 +1719,7 @@ func scanRefund(row scanner) (billing.Refund, error) { func scanCreditNote(row scanner) (billing.CreditNote, error) { var note billing.CreditNote var metadata, createdAt string - if err := row.Scan(¬e.ID, ¬e.InvoiceID, ¬e.CustomerID, ¬e.Amount, ¬e.Currency, ¬e.Reason, ¬e.Status, &metadata, &createdAt); err != nil { + if err := row.Scan(¬e.ID, ¬e.InvoiceID, ¬e.CustomerID, ¬e.Amount, ¬e.Currency, ¬e.Reason, ¬e.Status, &metadata, &createdAt, ¬e.Memo, ¬e.OutOfBandAmount, ¬e.RefundAmount); err != nil { return note, err } note.Object = billing.ObjectCreditNote diff --git a/internal/storage/migrations/021_credit_note_allocation.sql b/internal/storage/migrations/021_credit_note_allocation.sql new file mode 100644 index 0000000..1757563 --- /dev/null +++ b/internal/storage/migrations/021_credit_note_allocation.sql @@ -0,0 +1,5 @@ +-- Credit notes persist Stripe's memo / out_of_band_amount / refund_amount so +-- create and retrieve echo the same allocation. Amount still stores the total. +ALTER TABLE credit_notes ADD COLUMN memo TEXT NOT NULL DEFAULT ''; +ALTER TABLE credit_notes ADD COLUMN out_of_band_amount INTEGER NOT NULL DEFAULT 0; +ALTER TABLE credit_notes ADD COLUMN refund_amount INTEGER NOT NULL DEFAULT 0; diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 523d346..bfa2546 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -22,8 +22,8 @@ func TestSQLiteMigrationsRun(t *testing.T) { if err != nil { t.Fatalf("MigrationVersions returned error: %v", err) } - if len(versions) != 20 || versions[0] != 1 || versions[1] != 2 || versions[2] != 3 || versions[3] != 4 || versions[4] != 5 || versions[5] != 6 || versions[6] != 7 || versions[7] != 8 || versions[8] != 9 || versions[9] != 10 || versions[10] != 11 || versions[11] != 12 || versions[12] != 13 || versions[13] != 14 || versions[14] != 15 || versions[15] != 16 || versions[16] != 17 || versions[17] != 18 || versions[18] != 19 || versions[19] != 20 { - t.Fatalf("versions = %#v, want [1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20]", versions) + if len(versions) != 21 || versions[0] != 1 || versions[1] != 2 || versions[2] != 3 || versions[3] != 4 || versions[4] != 5 || versions[5] != 6 || versions[6] != 7 || versions[7] != 8 || versions[8] != 9 || versions[9] != 10 || versions[10] != 11 || versions[11] != 12 || versions[12] != 13 || versions[13] != 14 || versions[14] != 15 || versions[15] != 16 || versions[16] != 17 || versions[17] != 18 || versions[18] != 19 || versions[19] != 20 || versions[20] != 21 { + t.Fatalf("versions = %#v, want [1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21]", versions) } }