From 5dd35fb4e2b40622abb3625b4e196c1edc68235d Mon Sep 17 00:00:00 2001 From: midagedev Date: Tue, 25 Aug 2026 08:37:25 +0900 Subject: [PATCH 1/2] feat(api): pending invoice items, pending_invoice_items_behavior include/exclude, invoice subscription/days_until_due, lines.data and post_payment_credit_notes_amount - POST /v1/invoiceitems without invoice stores a pending item (nullable invoice_id via migration 022), optional subscription echo - POST /v1/invoices pending_invoice_items_behavior=include attaches the customer's same-currency pending items once at create and sums subtotal/total/amount_due; exclude/omit leaves them pending - POST /v1/invoices accepts subscription (customer match enforced) and days_until_due (due_date = created + days) - invoice responses populate lines.data reusing the /lines serialization (pricing/quantity included) - paid invoices report post_payment_credit_notes_amount from issued credit notes' credit amounts --- CHANGELOG.md | 13 + internal/api/api.go | 136 +++++--- internal/api/api_test.go | 306 ++++++++++++++++++ internal/api/validation.go | 7 +- internal/billing/models.go | 27 +- internal/billing/service.go | 109 +++++-- internal/storage/billing.go | 60 +++- .../migrations/022_pending_invoice_items.sql | 54 ++++ internal/storage/storage_test.go | 48 ++- 9 files changed, 677 insertions(+), 83 deletions(-) create mode 100644 internal/storage/migrations/022_pending_invoice_items.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index cc61f82..a3c8c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- `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 + customer's pending items of the same currency and adds them to + `subtotal`/`total` once at create. `exclude` (and omit) leaves them + unattached. +- `POST /v1/invoices` accepts `subscription` (stored and echoed) and + `days_until_due` (response `due_date` is created plus that many days). +- Invoice responses populate `lines.data` from the invoice's items using the + same serialization as `GET /v1/invoices/{id}/lines`, including Wave 0 + `pricing` and `quantity`. +- Paid invoice responses set `post_payment_credit_notes_amount` to the sum of + issued (non-void) credit notes' `credit_amount` for that invoice. - 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 diff --git a/internal/api/api.go b/internal/api/api.go index 63d1ee0..13c43d0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -3236,11 +3236,12 @@ func (h *Handler) handleInvoices(w http.ResponseWriter, r *http.Request) { } metadata := invoiceMetadataFromParams(p) invoice, err := h.billing.CreateInvoice(r.Context(), billing.Invoice{ - ID: p.string("id"), - CustomerID: p.string("customer"), - Currency: p.stringDefault("currency", "usd"), - Status: "draft", - Metadata: metadata, + ID: p.string("id"), + CustomerID: p.string("customer"), + Currency: p.stringDefault("currency", "usd"), + Status: "draft", + SubscriptionID: p.string("subscription"), + Metadata: metadata, }) if err == nil { h.emitGenericWebhook(r, "invoice.created", invoice.ID, h.stripeInvoice(r.Context(), invoice), webhooks.SourceAPI) @@ -3307,13 +3308,14 @@ func (h *Handler) handleInvoiceItems(w http.ResponseWriter, r *http.Request) { return } item := billing.InvoiceItem{ - ID: p.string("id"), - CustomerID: p.string("customer"), - InvoiceID: p.string("invoice"), - Amount: p.int64("amount"), - Currency: p.string("currency"), - Description: p.string("description"), - Metadata: p.metadata(), + ID: p.string("id"), + CustomerID: p.string("customer"), + InvoiceID: p.string("invoice"), + SubscriptionID: p.string("subscription"), + Amount: p.int64("amount"), + Currency: p.string("currency"), + Description: p.string("description"), + Metadata: p.metadata(), } if p.has("pricing[price]") { price, err := h.billing.GetPrice(r.Context(), p.string("pricing[price]")) @@ -3338,11 +3340,7 @@ func (h *Handler) handleInvoiceItems(w http.ResponseWriter, r *http.Request) { CustomerID: r.URL.Query().Get("customer"), InvoiceID: r.URL.Query().Get("invoice"), }) - data := make([]map[string]any, 0, len(items)) - for _, item := range items { - data = append(data, stripeInvoiceItem(item)) - } - writeResult(w, stripeList(r.URL.Path, data), err) + writeResult(w, stripeList(r.URL.Path, stripeInvoiceItemMaps(items)), err) default: h.methodNotAllowed(w, r, "GET, POST") } @@ -3396,7 +3394,7 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { } result, err := h.billing.FinalizeInvoice(r.Context(), id) if err == nil { - h.emitGenericWebhook(r, "invoice.finalized", result.Invoice.ID, h.stripeInvoiceWithPaymentIntent(result.Invoice, result.PaymentIntent), webhooks.SourceAPI) + h.emitGenericWebhook(r, "invoice.finalized", result.Invoice.ID, h.stripeInvoiceWithPaymentIntent(r.Context(), result.Invoice, result.PaymentIntent), webhooks.SourceAPI) if result.PaymentIntent.ID != "" { h.emitPaymentIntentWebhook(r, "payment_intent.created", result.PaymentIntent) } @@ -3408,7 +3406,7 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { } } } - writeResult(w, h.stripeInvoiceWithPaymentIntent(result.Invoice, result.PaymentIntent), err) + writeResult(w, h.stripeInvoiceWithPaymentIntent(r.Context(), result.Invoice, result.PaymentIntent), err) return } if len(parts) == 2 && parts[1] == "send" { @@ -3459,7 +3457,7 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { if err == nil { h.emitInvoicePaymentWebhooks(r, result, webhooks.SourceAPI) } - writeResult(w, h.stripeInvoiceWithPaymentIntent(result.Invoice, result.PaymentIntent), err) + writeResult(w, h.stripeInvoiceWithPaymentIntent(r.Context(), result.Invoice, result.PaymentIntent), err) return } if len(parts) == 2 && parts[1] == "lines" { @@ -3468,11 +3466,7 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { return } items, err := h.billing.ListInvoiceItems(r.Context(), billing.InvoiceItemFilter{InvoiceID: id}) - data := make([]map[string]any, 0, len(items)) - for _, item := range items { - data = append(data, stripeInvoiceItem(item)) - } - writeResult(w, stripeList(r.URL.Path, data), err) + writeResult(w, stripeList(r.URL.Path, stripeInvoiceItemMaps(items)), err) return } if len(parts) == 2 && parts[1] == "payments" { @@ -5994,6 +5988,7 @@ func invoiceMetadataFromParams(p params) map[string]string { {param: "collection_method", key: "collection_method"}, {param: "auto_advance", key: "auto_advance"}, {param: "pending_invoice_items_behavior", key: "pending_invoice_items_behavior"}, + {param: "days_until_due", key: "days_until_due"}, {param: "payment_settings[payment_method_types][]", key: "payment_method_types"}, {param: "payment_settings[payment_method_types][0]", key: "payment_method_types"}, } { @@ -7254,15 +7249,49 @@ func (h *Handler) stripeInvoice(ctx context.Context, invoice billing.Invoice) ma intent = &pi } } - return stripeInvoiceWithPaymentIntentAndTaxRates(invoice, intent, h.stripeTaxRateObjects(invoice.DefaultTaxRates)) + return h.enrichStripeInvoice(ctx, invoice, stripeInvoiceWithPaymentIntentAndTaxRates(invoice, intent, h.stripeTaxRateObjects(invoice.DefaultTaxRates))) } -func (h *Handler) stripeInvoiceWithPaymentIntent(invoice billing.Invoice, intent billing.PaymentIntent) map[string]any { +func (h *Handler) stripeInvoiceWithPaymentIntent(ctx context.Context, invoice billing.Invoice, intent billing.PaymentIntent) map[string]any { taxRates := h.stripeTaxRateObjects(invoice.DefaultTaxRates) + var payload map[string]any if intent.ID == "" { - return stripeInvoiceWithPaymentIntentAndTaxRates(invoice, nil, taxRates) + payload = stripeInvoiceWithPaymentIntentAndTaxRates(invoice, nil, taxRates) + } else { + payload = stripeInvoiceWithPaymentIntentAndTaxRates(invoice, &intent, taxRates) + } + return h.enrichStripeInvoice(ctx, invoice, payload) +} + +func (h *Handler) enrichStripeInvoice(ctx context.Context, invoice billing.Invoice, payload map[string]any) map[string]any { + if invoice.ID == "" { + return payload } - return stripeInvoiceWithPaymentIntentAndTaxRates(invoice, &intent, taxRates) + items, err := h.billing.ListInvoiceItems(ctx, billing.InvoiceItemFilter{InvoiceID: invoice.ID}) + if err != nil { + items = nil + } + payload["lines"] = stripeInvoiceLines(invoice.ID, items) + payload["post_payment_credit_notes_amount"] = h.invoicePostPaymentCreditNotesAmount(ctx, invoice) + return payload +} + +func (h *Handler) invoicePostPaymentCreditNotesAmount(ctx context.Context, invoice billing.Invoice) int64 { + if invoice.Status != "paid" && invoice.AmountPaid <= 0 { + return 0 + } + notes, err := h.billing.ListCreditNotes(ctx, billing.CreditNoteFilter{InvoiceID: invoice.ID}) + if err != nil { + return 0 + } + var sum int64 + for _, note := range notes { + if note.Status == "void" { + continue + } + sum += note.CreditAmount() + } + return sum } func (h *Handler) stripeInvoicePayments(ctx context.Context, invoice billing.Invoice, intent *billing.PaymentIntent) []map[string]any { @@ -7423,7 +7452,7 @@ func stripeInvoiceWithPaymentIntentAndTaxRates(invoice billing.Invoice, intent * "default_payment_method": emptyToNil(invoice.Metadata[billing.MetadataDefaultPaymentMethod]), "default_source": nil, "default_tax_rates": defaultTaxRates, - "due_date": nil, + "due_date": invoiceDueDate(invoice), "ending_balance": 0, "footer": nil, "from_invoice": nil, @@ -7461,17 +7490,18 @@ func stripeInvoiceItem(item billing.InvoiceItem) map[string]any { quantity = 1 } out := map[string]any{ - "id": item.ID, - "object": billing.ObjectInvoiceItem, - "customer": item.CustomerID, - "invoice": item.InvoiceID, - "amount": item.Amount, - "currency": item.Currency, - "description": emptyToNil(item.Description), - "metadata": nonNilMap(item.Metadata), - "quantity": quantity, - "created": unix(item.CreatedAt), - "livemode": false, + "id": item.ID, + "object": billing.ObjectInvoiceItem, + "customer": item.CustomerID, + "invoice": emptyToNil(item.InvoiceID), + "subscription": emptyToNil(item.SubscriptionID), + "amount": item.Amount, + "currency": item.Currency, + "description": emptyToNil(item.Description), + "metadata": nonNilMap(item.Metadata), + "quantity": quantity, + "created": unix(item.CreatedAt), + "livemode": false, } if item.PriceID != "" { unitAmount := item.Amount @@ -7490,6 +7520,30 @@ func stripeInvoiceItem(item billing.InvoiceItem) map[string]any { return out } +func stripeInvoiceItemMaps(items []billing.InvoiceItem) []map[string]any { + data := make([]map[string]any, 0, len(items)) + for _, item := range items { + data = append(data, stripeInvoiceItem(item)) + } + return data +} + +func stripeInvoiceLines(invoiceID string, items []billing.InvoiceItem) map[string]any { + return stripeList("/v1/invoices/"+invoiceID+"/lines", stripeInvoiceItemMaps(items)) +} + +func invoiceDueDate(invoice billing.Invoice) any { + raw := strings.TrimSpace(invoice.Metadata["days_until_due"]) + if raw == "" { + return nil + } + days, err := strconv.ParseInt(raw, 10, 64) + if err != nil || days <= 0 { + return nil + } + return unix(invoice.CreatedAt.Add(time.Duration(days) * 24 * time.Hour)) +} + func stripeInvoicePaymentRecords(invoice billing.Invoice, intent *billing.PaymentIntent) []map[string]any { if invoice.PaymentIntentID == "" { return []map[string]any{} diff --git a/internal/api/api_test.go b/internal/api/api_test.go index bde8794..7505154 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -5322,6 +5322,312 @@ func TestInvoiceItemPricingCreate(t *testing.T) { }) } +func TestPendingInvoiceItemsAndInvoiceReadShape(t *testing.T) { + type invoiceLine struct { + ID string `json:"id"` + Amount int64 `json:"amount"` + Quantity int64 `json:"quantity"` + Invoice *string `json:"invoice"` + Subscription *string `json:"subscription"` + Pricing *struct { + Type string `json:"type"` + PriceDetails struct { + Price string `json:"price"` + Product string `json:"product"` + } `json:"price_details"` + UnitAmountDecimal string `json:"unit_amount_decimal"` + } `json:"pricing"` + } + type invoiceResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Customer string `json:"customer"` + Subscription *string `json:"subscription"` + Created int64 `json:"created"` + DueDate *int64 `json:"due_date"` + Subtotal int64 `json:"subtotal"` + Total int64 `json:"total"` + AmountDue int64 `json:"amount_due"` + PostPaymentCreditNotesAmount int64 `json:"post_payment_credit_notes_amount"` + Lines struct { + Data []invoiceLine `json:"data"` + } `json:"lines"` + } + type invoiceItemResponse struct { + ID string `json:"id"` + Amount int64 `json:"amount"` + Invoice *string `json:"invoice"` + Subscription *string `json:"subscription"` + Quantity int64 `json:"quantity"` + Pricing *struct { + Type string `json:"type"` + PriceDetails struct { + Price string `json:"price"` + Product string `json:"product"` + } `json:"price_details"` + } `json:"pricing"` + } + + t.Run("include attaches pending items and sums totals", func(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"pending-include@example.test"}, + }) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Pending Include"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"100"}, + "recurring[interval]": {"month"}, + }) + subscription := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/subscriptions", url.Values{ + "customer": {customer.ID}, + "items[0][price]": {price.ID}, + }) + first := postForm[invoiceItemResponse](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "amount": {"100"}, + "currency": {"usd"}, + "description": {"pending a"}, + }) + if first.ID == "" || first.Invoice != nil { + t.Fatalf("pending item = %#v, want invoice null", first) + } + second := postForm[invoiceItemResponse](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "subscription": {subscription.ID}, + "amount": {"250"}, + "currency": {"usd"}, + "description": {"pending b"}, + }) + if second.Invoice != nil || second.Subscription == nil || *second.Subscription != subscription.ID { + t.Fatalf("pending subscription item = %#v, want subscription echoed and invoice null", second) + } + + invoice := postForm[invoiceResponse](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "currency": {"usd"}, + "pending_invoice_items_behavior": {"include"}, + }) + if invoice.Subtotal != 350 || invoice.Total != 350 || invoice.AmountDue != 350 { + t.Fatalf("include invoice totals = %#v, want 350", invoice) + } + if len(invoice.Lines.Data) != 2 { + t.Fatalf("include lines = %#v, want 2 attached items", invoice.Lines.Data) + } + gotIDs := map[string]bool{} + for _, line := range invoice.Lines.Data { + gotIDs[line.ID] = true + if line.Invoice == nil || *line.Invoice != invoice.ID { + t.Fatalf("attached line = %#v, want invoice %s", line, invoice.ID) + } + } + if !gotIDs[first.ID] || !gotIDs[second.ID] { + t.Fatalf("include lines ids = %#v, want %s and %s", invoice.Lines.Data, first.ID, second.ID) + } + + listed := getJSON[struct { + Data []invoiceItemResponse `json:"data"` + }](t, handler, "/v1/invoiceitems?invoice="+invoice.ID) + if len(listed.Data) != 2 { + t.Fatalf("listed attached items = %#v, want 2", listed.Data) + } + subLines := getJSON[struct { + Data []invoiceLine `json:"data"` + }](t, handler, "/v1/invoices/"+invoice.ID+"/lines") + if len(subLines.Data) != 2 { + t.Fatalf("subresource lines = %#v, want same 2 items as invoice.lines", subLines.Data) + } + }) + + t.Run("exclude leaves pending items unattached", func(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"pending-exclude@example.test"}, + }) + postForm[invoiceItemResponse](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "amount": {"100"}, + "currency": {"usd"}, + }) + postForm[invoiceItemResponse](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "amount": {"200"}, + "currency": {"usd"}, + }) + invoice := postForm[invoiceResponse](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "currency": {"usd"}, + "pending_invoice_items_behavior": {"exclude"}, + }) + if invoice.Subtotal != 0 || invoice.Total != 0 || invoice.AmountDue != 0 || len(invoice.Lines.Data) != 0 { + t.Fatalf("exclude invoice = %#v, want zero totals and empty lines", invoice) + } + listed := getJSON[struct { + Data []invoiceItemResponse `json:"data"` + }](t, handler, "/v1/invoiceitems?customer="+customer.ID) + if len(listed.Data) != 2 { + t.Fatalf("listed items = %#v, want both still pending", listed.Data) + } + for _, item := range listed.Data { + if item.Invoice != nil { + t.Fatalf("exclude left item attached = %#v", item) + } + } + }) + + t.Run("subscription is stored and echoed", func(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"invoice-sub@example.test"}, + }) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Invoice Sub"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"500"}, + "recurring[interval]": {"month"}, + }) + subscription := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/subscriptions", url.Values{ + "customer": {customer.ID}, + "items[0][price]": {price.ID}, + }) + created := postForm[invoiceResponse](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "currency": {"usd"}, + "subscription": {subscription.ID}, + "pending_invoice_items_behavior": {"exclude"}, + }) + if created.Subscription == nil || *created.Subscription != subscription.ID { + t.Fatalf("created invoice subscription = %#v, want %s", created.Subscription, subscription.ID) + } + got := getJSON[invoiceResponse](t, handler, "/v1/invoices/"+created.ID) + if got.Subscription == nil || *got.Subscription != subscription.ID { + t.Fatalf("retrieved invoice subscription = %#v, want %s", got.Subscription, subscription.ID) + } + }) + + t.Run("days_until_due sets due_date", func(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"invoice-due@example.test"}, + }) + created := postForm[invoiceResponse](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "currency": {"usd"}, + "collection_method": {"send_invoice"}, + "days_until_due": {"7"}, + "pending_invoice_items_behavior": {"exclude"}, + }) + wantDue := created.Created + 7*24*60*60 + if created.DueDate == nil || *created.DueDate != wantDue { + t.Fatalf("created due_date = %#v created = %d, want %d", created.DueDate, created.Created, wantDue) + } + got := getJSON[invoiceResponse](t, handler, "/v1/invoices/"+created.ID) + if got.DueDate == nil || *got.DueDate != wantDue { + t.Fatalf("retrieved due_date = %#v, want %d", got.DueDate, wantDue) + } + }) + + t.Run("lines.data includes pricing and quantity", func(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"invoice-lines@example.test"}, + }) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Lines"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"250"}, + }) + invoice := postForm[invoiceResponse](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "currency": {"usd"}, + "pending_invoice_items_behavior": {"exclude"}, + }) + item := postForm[invoiceItemResponse](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "invoice": {invoice.ID}, + "pricing[price]": {price.ID}, + "quantity": {"3"}, + }) + got := getJSON[invoiceResponse](t, handler, "/v1/invoices/"+invoice.ID) + if len(got.Lines.Data) != 1 { + t.Fatalf("lines = %#v, want 1", got.Lines.Data) + } + line := got.Lines.Data[0] + if line.ID != item.ID || line.Amount != 750 || line.Quantity != 3 { + t.Fatalf("line = %#v, want item %s amount 750 quantity 3", line, item.ID) + } + if line.Pricing == nil || line.Pricing.Type != "price_details" || line.Pricing.PriceDetails.Price != price.ID || line.Pricing.PriceDetails.Product != product.ID { + t.Fatalf("line pricing = %#v, want price_details for %s/%s", line.Pricing, price.ID, product.ID) + } + sub := getJSON[struct { + Data []invoiceLine `json:"data"` + }](t, handler, "/v1/invoices/"+invoice.ID+"/lines") + if len(sub.Data) != 1 || sub.Data[0].Quantity != 3 || sub.Data[0].Pricing == nil || sub.Data[0].Pricing.PriceDetails.Price != price.ID { + t.Fatalf("subresource lines = %#v, want same pricing/quantity as invoice.lines", sub.Data) + } + }) + + t.Run("post_payment_credit_notes_amount after credit note", func(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"invoice-credit@example.test"}, + }) + invoice := postForm[invoiceResponse](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "currency": {"usd"}, + "pending_invoice_items_behavior": {"exclude"}, + }) + postForm[invoiceItemResponse](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "invoice": {invoice.ID}, + "amount": {"10000"}, + "currency": {"usd"}, + }) + _ = postForm[invoiceResponse](t, handler, "/v1/invoices/"+invoice.ID+"/finalize", nil) + paid := postForm[invoiceResponse](t, handler, "/v1/invoices/"+invoice.ID+"/pay", nil) + if paid.Status != "paid" { + t.Fatalf("paid = %#v, want paid", paid) + } + if paid.PostPaymentCreditNotesAmount != 0 { + t.Fatalf("paid post_payment_credit_notes_amount = %d, want 0 before credit notes", paid.PostPaymentCreditNotesAmount) + } + note := postForm[struct { + ID string `json:"id"` + CreditAmount int64 `json:"credit_amount"` + }](t, handler, "/v1/credit_notes", url.Values{ + "invoice": {invoice.ID}, + "amount": {"2500"}, + }) + if note.CreditAmount != 2500 { + t.Fatalf("credit note = %#v, want credit_amount 2500", note) + } + got := getJSON[invoiceResponse](t, handler, "/v1/invoices/"+invoice.ID) + if got.PostPaymentCreditNotesAmount != 2500 { + t.Fatalf("post_payment_credit_notes_amount = %d, want 2500", got.PostPaymentCreditNotesAmount) + } + oob := postForm[struct { + CreditAmount int64 `json:"credit_amount"` + }](t, handler, "/v1/credit_notes", url.Values{ + "invoice": {invoice.ID}, + "out_of_band_amount": {"1000"}, + }) + if oob.CreditAmount != 0 { + t.Fatalf("oob credit note = %#v, want credit_amount 0", oob) + } + afterOOB := getJSON[invoiceResponse](t, handler, "/v1/invoices/"+invoice.ID) + if afterOOB.PostPaymentCreditNotesAmount != 2500 { + t.Fatalf("post_payment after oob = %d, want 2500 (credit_amount only)", afterOOB.PostPaymentCreditNotesAmount) + } + }) +} + func TestInvoiceEmailSendEvidence(t *testing.T) { handler := newTestHandler(t) diff --git a/internal/api/validation.go b/internal/api/validation.go index c803430..3b70dee 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -1160,10 +1160,14 @@ func validateInvoiceCreate(p params) error { "description", "auto_advance", "pending_invoice_items_behavior", + "subscription", + "days_until_due", }, AllowedRegex: []*regexp.Regexp{invoicePaymentSettingsRE}, Required: []string{"customer"}, BoolParams: []string{"auto_advance"}, + Int64Params: []string{"days_until_due"}, + Positive: []string{"days_until_due"}, EnumParams: map[string][]string{ "collection_method": {"charge_automatically", "send_invoice"}, "pending_invoice_items_behavior": {"exclude", "include"}, @@ -1178,13 +1182,14 @@ func validateInvoiceItemCreate(p params) error { "id", "customer", "invoice", + "subscription", "amount", "currency", "description", "pricing[price]", "quantity", }, - Required: []string{"customer", "invoice"}, + Required: []string{"customer"}, Int64Params: []string{"amount", "quantity"}, NonNegative: []string{"quantity"}, AllowMetadata: true, diff --git a/internal/billing/models.go b/internal/billing/models.go index 22c8f3e..2a27815 100644 --- a/internal/billing/models.go +++ b/internal/billing/models.go @@ -187,18 +187,19 @@ type Invoice struct { } type InvoiceItem struct { - ID string `json:"id"` - Object string `json:"object"` - CustomerID string `json:"customer"` - InvoiceID string `json:"invoice"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - Description string `json:"description,omitempty"` - PriceID string `json:"price,omitempty"` - ProductID string `json:"product,omitempty"` - Quantity int64 `json:"quantity,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Object string `json:"object"` + CustomerID string `json:"customer"` + InvoiceID string `json:"invoice"` + SubscriptionID string `json:"subscription,omitempty"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + Description string `json:"description,omitempty"` + PriceID string `json:"price,omitempty"` + ProductID string `json:"product,omitempty"` + Quantity int64 `json:"quantity,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` } type InvoicePaymentOptions struct { @@ -389,6 +390,8 @@ type InvoiceFilter struct { type InvoiceItemFilter struct { CustomerID string InvoiceID string + // Pending selects items with no invoice (NULL or empty invoice_id). + Pending bool } type PaymentIntentFilter struct { diff --git a/internal/billing/service.go b/internal/billing/service.go index a06717b..2fcf43d 100644 --- a/internal/billing/service.go +++ b/internal/billing/service.go @@ -94,6 +94,7 @@ type Repository interface { ListInvoicesFiltered(context.Context, InvoiceFilter) ([]Invoice, error) UpdateInvoice(context.Context, Invoice, []TimelineEntry) (Invoice, error) CreateInvoiceItem(context.Context, InvoiceItem, Invoice, []TimelineEntry) (InvoiceItem, Invoice, error) + AttachInvoiceItems(context.Context, Invoice, []string, []TimelineEntry) (Invoice, error) ListInvoiceItemsFiltered(context.Context, InvoiceItemFilter) ([]InvoiceItem, error) FinalizeInvoice(context.Context, Invoice, PaymentIntent, []TimelineEntry) (Invoice, PaymentIntent, error) UpdateInvoicePayment(context.Context, Subscription, Invoice, PaymentIntent, []TimelineEntry) (Subscription, Invoice, PaymentIntent, error) @@ -799,6 +800,15 @@ func (s *Service) CreateInvoice(ctx context.Context, in Invoice) (Invoice, error in.Object = ObjectInvoice in.CustomerID = customer.ID in.SubscriptionID = strings.TrimSpace(in.SubscriptionID) + if in.SubscriptionID != "" { + sub, err := s.repo.GetSubscription(ctx, in.SubscriptionID) + if err != nil { + return Invoice{}, err + } + if sub.CustomerID != customer.ID { + return Invoice{}, fmt.Errorf("%w: subscription customer must match invoice customer", ErrInvalidInput) + } + } in.Status = firstNonEmpty(strings.ToLower(strings.TrimSpace(in.Status)), "draft") in.Currency = strings.ToLower(firstNonEmpty(strings.TrimSpace(in.Currency), "usd")) in.Metadata = copyMap(in.Metadata) @@ -812,7 +822,7 @@ func (s *Service) CreateInvoice(ctx context.Context, in Invoice) (Invoice, error if in.CreatedAt.IsZero() { in.CreatedAt = now } - return s.repo.CreateInvoice(ctx, in, []TimelineEntry{billingTimelineEntry( + created, err := s.repo.CreateInvoice(ctx, in, []TimelineEntry{billingTimelineEntry( "invoice_created_"+in.ID, "invoice.created", "Invoice created", @@ -826,6 +836,10 @@ func (s *Service) CreateInvoice(ctx context.Context, in Invoice) (Invoice, error map[string]string{"source": "invoice.create", "status": in.Status}, in.CreatedAt, )}) + if err != nil { + return Invoice{}, err + } + return s.attachPendingInvoiceItems(ctx, created) } func (s *Service) ListInvoices(ctx context.Context) ([]Invoice, error) { @@ -833,23 +847,38 @@ func (s *Service) ListInvoices(ctx context.Context) ([]Invoice, error) { } func (s *Service) CreateInvoiceItem(ctx context.Context, in InvoiceItem) (InvoiceItem, Invoice, error) { - if strings.TrimSpace(in.InvoiceID) == "" { - return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: invoice is required", ErrInvalidInput) - } if in.Amount == 0 { return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: amount is required", ErrInvalidInput) } - invoice, err := s.repo.GetInvoice(ctx, in.InvoiceID) - if err != nil { - return InvoiceItem{}, Invoice{}, err + in.InvoiceID = strings.TrimSpace(in.InvoiceID) + in.SubscriptionID = strings.TrimSpace(in.SubscriptionID) + var invoice Invoice + if in.InvoiceID != "" { + var err error + invoice, err = s.repo.GetInvoice(ctx, in.InvoiceID) + if err != nil { + return InvoiceItem{}, Invoice{}, err + } } customerID := firstNonEmpty(strings.TrimSpace(in.CustomerID), invoice.CustomerID) - if customerID != invoice.CustomerID { + if customerID == "" { + return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: customer is required", ErrInvalidInput) + } + if invoice.ID != "" && customerID != invoice.CustomerID { return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: customer must match invoice customer", ErrInvalidInput) } if _, err := s.repo.GetCustomer(ctx, customerID); err != nil { return InvoiceItem{}, Invoice{}, err } + if in.SubscriptionID != "" { + sub, err := s.repo.GetSubscription(ctx, in.SubscriptionID) + if err != nil { + return InvoiceItem{}, Invoice{}, err + } + if sub.CustomerID != customerID { + return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: subscription customer must match invoice item customer", ErrInvalidInput) + } + } now := s.now() if strings.TrimSpace(in.ID) == "" { in.ID = id("ii") @@ -861,16 +890,9 @@ func (s *Service) CreateInvoiceItem(ctx context.Context, in InvoiceItem) (Invoic if in.CreatedAt.IsZero() { in.CreatedAt = now } - invoice.Subtotal += in.Amount - invoice.Total += in.Amount - if invoice.Total < 0 { - invoice.Total = 0 - } - invoice.AmountDue = invoice.Total - invoice.AmountPaid - if invoice.AmountDue < 0 { - invoice.AmountDue = 0 + if invoice.ID != "" { + addInvoiceItemAmount(&invoice, in.Amount, in.Currency) } - invoice.Currency = firstNonEmpty(invoice.Currency, in.Currency) createdItem, updatedInvoice, err := s.repo.CreateInvoiceItem(ctx, in, invoice, []TimelineEntry{billingTimelineEntry( "invoiceitem_created_"+in.ID, "invoiceitem.created", @@ -879,7 +901,7 @@ func (s *Service) CreateInvoiceItem(ctx context.Context, in InvoiceItem) (Invoic in.ID, in.CustomerID, "", - invoice.SubscriptionID, + firstNonEmpty(in.SubscriptionID, invoice.SubscriptionID), invoice.ID, invoice.PaymentIntentID, map[string]string{"source": "invoiceitem.create", "amount": strconv.FormatInt(in.Amount, 10), "currency": in.Currency}, @@ -888,6 +910,57 @@ func (s *Service) CreateInvoiceItem(ctx context.Context, in InvoiceItem) (Invoic return createdItem, updatedInvoice, err } +func (s *Service) attachPendingInvoiceItems(ctx context.Context, invoice Invoice) (Invoice, error) { + if !strings.EqualFold(strings.TrimSpace(invoice.Metadata["pending_invoice_items_behavior"]), "include") { + return invoice, nil + } + pending, err := s.repo.ListInvoiceItemsFiltered(ctx, InvoiceItemFilter{CustomerID: invoice.CustomerID, Pending: true}) + if err != nil { + return Invoice{}, err + } + itemIDs := make([]string, 0, len(pending)) + for _, item := range pending { + if item.Currency != "" && item.Currency != invoice.Currency { + continue + } + itemIDs = append(itemIDs, item.ID) + addInvoiceItemAmount(&invoice, item.Amount, item.Currency) + } + if len(itemIDs) == 0 { + return invoice, nil + } + return s.repo.AttachInvoiceItems(ctx, invoice, itemIDs, []TimelineEntry{billingTimelineEntry( + "invoice_pending_items_"+invoice.ID, + "invoice.pending_invoice_items_attached", + "Pending invoice items attached", + ObjectInvoice, + invoice.ID, + invoice.CustomerID, + "", + invoice.SubscriptionID, + invoice.ID, + invoice.PaymentIntentID, + map[string]string{"source": "invoice.create", "pending_invoice_items_behavior": "include", "count": strconv.Itoa(len(itemIDs))}, + invoice.CreatedAt, + )}) +} + +func addInvoiceItemAmount(invoice *Invoice, amount int64, currency string) { + if invoice == nil { + return + } + invoice.Subtotal += amount + invoice.Total += amount + if invoice.Total < 0 { + invoice.Total = 0 + } + invoice.AmountDue = invoice.Total - invoice.AmountPaid + if invoice.AmountDue < 0 { + invoice.AmountDue = 0 + } + invoice.Currency = firstNonEmpty(invoice.Currency, currency) +} + func (s *Service) ListInvoiceItems(ctx context.Context, filter InvoiceItemFilter) ([]InvoiceItem, error) { return s.repo.ListInvoiceItemsFiltered(ctx, filter) } diff --git a/internal/storage/billing.go b/internal/storage/billing.go index c106e11..da13088 100644 --- a/internal/storage/billing.go +++ b/internal/storage/billing.go @@ -11,6 +11,8 @@ import ( "github.com/hckim/billtap/internal/billing" ) +const invoiceItemColumns = `id, customer_id, invoice_id, amount, currency, description, metadata, created_at, price_id, product_id, quantity, subscription_id` + var _ billing.Repository = (*SQLiteStore)(nil) func (s *SQLiteStore) CreateCustomer(ctx context.Context, c billing.Customer) (billing.Customer, error) { @@ -807,13 +809,15 @@ func (s *SQLiteStore) CreateInvoiceItem(ctx context.Context, item billing.Invoic return billing.InvoiceItem{}, billing.Invoice{}, err } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `INSERT INTO invoice_items (id, customer_id, invoice_id, amount, currency, description, metadata, created_at, price_id, product_id, quantity) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - item.ID, item.CustomerID, item.InvoiceID, item.Amount, item.Currency, item.Description, encodeMap(item.Metadata), encodeTime(item.CreatedAt), item.PriceID, item.ProductID, item.Quantity); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO invoice_items (`+invoiceItemColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + item.ID, item.CustomerID, encodeOptionalString(item.InvoiceID), item.Amount, item.Currency, item.Description, encodeMap(item.Metadata), encodeTime(item.CreatedAt), item.PriceID, item.ProductID, item.Quantity, item.SubscriptionID); err != nil { return billing.InvoiceItem{}, billing.Invoice{}, err } - if err := updateInvoiceTx(ctx, tx, invoice); err != nil { - return billing.InvoiceItem{}, billing.Invoice{}, err + if invoice.ID != "" { + if err := updateInvoiceTx(ctx, tx, invoice); err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } } for _, entry := range timeline { if err := s.insertTimeline(ctx, tx, entry); err != nil { @@ -827,6 +831,9 @@ func (s *SQLiteStore) CreateInvoiceItem(ctx context.Context, item billing.Invoic if err != nil { return billing.InvoiceItem{}, billing.Invoice{}, err } + if invoice.ID == "" { + return createdItem, billing.Invoice{}, nil + } updatedInvoice, err := s.GetInvoice(ctx, invoice.ID) if err != nil { return billing.InvoiceItem{}, billing.Invoice{}, err @@ -834,8 +841,39 @@ func (s *SQLiteStore) CreateInvoiceItem(ctx context.Context, item billing.Invoic return createdItem, updatedInvoice, nil } +func (s *SQLiteStore) AttachInvoiceItems(ctx context.Context, invoice billing.Invoice, itemIDs []string, timeline []billing.TimelineEntry) (billing.Invoice, error) { + if invoice.ID == "" { + return billing.Invoice{}, billing.ErrInvalidInput + } + if len(itemIDs) == 0 { + return s.GetInvoice(ctx, invoice.ID) + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return billing.Invoice{}, err + } + defer tx.Rollback() + for _, id := range itemIDs { + if _, err := tx.ExecContext(ctx, `UPDATE invoice_items SET invoice_id = ? WHERE id = ? AND (invoice_id IS NULL OR invoice_id = '')`, invoice.ID, id); err != nil { + return billing.Invoice{}, err + } + } + if err := updateInvoiceTx(ctx, tx, invoice); err != nil { + return billing.Invoice{}, err + } + for _, entry := range timeline { + if err := s.insertTimeline(ctx, tx, entry); err != nil { + return billing.Invoice{}, err + } + } + if err := tx.Commit(); err != nil { + return billing.Invoice{}, err + } + return s.GetInvoice(ctx, invoice.ID) +} + func (s *SQLiteStore) GetInvoiceItem(ctx context.Context, id string) (billing.InvoiceItem, error) { - row := s.db.QueryRowContext(ctx, `SELECT id, customer_id, invoice_id, amount, currency, description, metadata, created_at, price_id, product_id, quantity FROM invoice_items WHERE id = ?`, id) + row := s.db.QueryRowContext(ctx, `SELECT `+invoiceItemColumns+` FROM invoice_items WHERE id = ?`, id) item, err := scanInvoiceItem(row) if errors.Is(err, sql.ErrNoRows) { return billing.InvoiceItem{}, billing.ErrNotFound @@ -850,11 +888,13 @@ func (s *SQLiteStore) ListInvoiceItemsFiltered(ctx context.Context, filter billi clauses = append(clauses, "customer_id = ?") args = append(args, filter.CustomerID) } - if filter.InvoiceID != "" { + if filter.Pending { + clauses = append(clauses, "(invoice_id IS NULL OR invoice_id = '')") + } else if filter.InvoiceID != "" { clauses = append(clauses, "invoice_id = ?") args = append(args, filter.InvoiceID) } - rows, err := s.db.QueryContext(ctx, `SELECT id, customer_id, invoice_id, amount, currency, description, metadata, created_at, price_id, product_id, quantity + rows, err := s.db.QueryContext(ctx, `SELECT `+invoiceItemColumns+` FROM invoice_items WHERE `+strings.Join(clauses, " AND ")+` ORDER BY created_at ASC, id ASC`, args...) if err != nil { return nil, err @@ -1653,11 +1693,13 @@ func scanInvoice(row scanner) (billing.Invoice, error) { func scanInvoiceItem(row scanner) (billing.InvoiceItem, error) { var item billing.InvoiceItem + var invoiceID sql.NullString var metadataRaw, createdAt string - if err := row.Scan(&item.ID, &item.CustomerID, &item.InvoiceID, &item.Amount, &item.Currency, &item.Description, &metadataRaw, &createdAt, &item.PriceID, &item.ProductID, &item.Quantity); err != nil { + if err := row.Scan(&item.ID, &item.CustomerID, &invoiceID, &item.Amount, &item.Currency, &item.Description, &metadataRaw, &createdAt, &item.PriceID, &item.ProductID, &item.Quantity, &item.SubscriptionID); err != nil { return item, err } item.Object = billing.ObjectInvoiceItem + item.InvoiceID = invoiceID.String item.Metadata = decodeMap(metadataRaw) item.CreatedAt = decodeTime(createdAt) return item, nil diff --git a/internal/storage/migrations/022_pending_invoice_items.sql b/internal/storage/migrations/022_pending_invoice_items.sql new file mode 100644 index 0000000..5873adc --- /dev/null +++ b/internal/storage/migrations/022_pending_invoice_items.sql @@ -0,0 +1,54 @@ +-- Pending invoice items have no invoice yet. invoice_id was NOT NULL with a +-- foreign key, so an empty string could not store a customer-attached pending +-- item. Recreate the table with a nullable invoice_id and persist the optional +-- subscription id the create surface already accepts. +CREATE TABLE invoice_items_new ( + id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL REFERENCES customers(id), + invoice_id TEXT REFERENCES invoices(id), + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + price_id TEXT NOT NULL DEFAULT '', + product_id TEXT NOT NULL DEFAULT '', + quantity INTEGER NOT NULL DEFAULT 0, + subscription_id TEXT NOT NULL DEFAULT '' +); + +INSERT INTO invoice_items_new ( + id, + customer_id, + invoice_id, + amount, + currency, + description, + metadata, + created_at, + price_id, + product_id, + quantity, + subscription_id +) +SELECT + id, + customer_id, + invoice_id, + amount, + currency, + description, + metadata, + created_at, + price_id, + product_id, + quantity, + '' +FROM invoice_items; + +DROP TABLE invoice_items; + +ALTER TABLE invoice_items_new RENAME TO invoice_items; + +CREATE INDEX IF NOT EXISTS idx_invoice_items_customer ON invoice_items(customer_id); +CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id); diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index bfa2546..488e34f 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) != 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) + if len(versions) != 22 || 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 || versions[21] != 22 { + 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 22]", versions) } } @@ -44,6 +44,50 @@ func TestMemoryStoreWorksInTests(t *testing.T) { } } +func TestPendingInvoiceItemAllowsNullInvoiceID(t *testing.T) { + ctx := context.Background() + store, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "billtap.db")) + if err != nil { + t.Fatalf("OpenSQLite returned error: %v", err) + } + defer store.Close() + + service := billing.NewService(store) + customer, err := service.CreateCustomer(ctx, billing.Customer{ID: "cus_pending", Email: "pending@example.test"}) + if err != nil { + t.Fatalf("CreateCustomer: %v", err) + } + item, invoice, err := service.CreateInvoiceItem(ctx, billing.InvoiceItem{ + CustomerID: customer.ID, + Amount: 500, + Currency: "usd", + Description: "pending usage", + }) + if err != nil { + t.Fatalf("CreateInvoiceItem pending: %v", err) + } + if invoice.ID != "" { + t.Fatalf("pending create returned invoice %#v, want none", invoice) + } + if item.InvoiceID != "" { + t.Fatalf("pending item invoice_id = %q, want empty", item.InvoiceID) + } + pending, err := service.ListInvoiceItems(ctx, billing.InvoiceItemFilter{CustomerID: customer.ID, Pending: true}) + if err != nil { + t.Fatalf("ListInvoiceItems pending: %v", err) + } + if len(pending) != 1 || pending[0].ID != item.ID { + t.Fatalf("pending list = %#v, want the created item", pending) + } + + _, err = store.db.ExecContext(ctx, `INSERT INTO invoice_items (`+invoiceItemColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + "ii_missing_invoice", customer.ID, "in_missing", int64(100), "usd", "", "{}", time.Now().UTC().Format(time.RFC3339Nano), "", "", int64(0), "") + if err == nil { + t.Fatal("insert with unknown invoice_id succeeded, want foreign key error") + } +} + func TestCheckoutSessionMetadataDefaultAndRoundTrip(t *testing.T) { ctx := context.Background() store, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "billtap.db")) From 99f3af34e75f38c750d022c1186563d28bc4f451 Mon Sep 17 00:00:00 2001 From: midagedev Date: Tue, 25 Aug 2026 08:46:37 +0900 Subject: [PATCH 2/2] test(api): remove wall-clock budget from slow-webhook tests The 150ms elapsed assertions flake on loaded CI runners. The receiver blocks until releaseWebhook closes at test end, so any response proves the handler did not wait on delivery; keep only a generous hang bound. --- internal/api/api_test.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 7505154..0f52090 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -585,7 +585,6 @@ func TestCheckoutCompletionDoesNotWaitForSlowWebhookDelivery(t *testing.T) { status int body string }, 1) - start := time.Now() go func() { req := httptest.NewRequest(http.MethodPost, "/api/checkout/sessions/"+session.ID+"/complete", strings.NewReader(`{"outcome":"payment_succeeded"}`)) req.Header.Set("Content-Type", "application/json") @@ -602,10 +601,10 @@ func TestCheckoutCompletionDoesNotWaitForSlowWebhookDelivery(t *testing.T) { if result.status != http.StatusOK { t.Fatalf("checkout completion status = %d body = %s", result.status, result.body) } - if elapsed := time.Since(start); elapsed > 150*time.Millisecond { - t.Fatalf("checkout completion took %s, want response before slow webhook delivery completes", elapsed) - } - case <-time.After(150 * time.Millisecond): + // The receiver blocks until releaseWebhook closes at test end, so any + // response at all proves the handler did not wait on delivery. A generous + // timeout avoids wall-clock flakes on loaded runners. + case <-time.After(5 * time.Second): t.Fatal("checkout completion waited for slow webhook delivery") } @@ -641,7 +640,6 @@ func TestDirectPaymentIntentWebhooksDoNotWaitForSlowDelivery(t *testing.T) { status int body string }, 1) - start := time.Now() go func() { form := url.Values{ "customer": {customer.ID}, @@ -665,10 +663,9 @@ func TestDirectPaymentIntentWebhooksDoNotWaitForSlowDelivery(t *testing.T) { if result.status != http.StatusOK { t.Fatalf("payment intent create status = %d body = %s", result.status, result.body) } - if elapsed := time.Since(start); elapsed > 150*time.Millisecond { - t.Fatalf("payment intent create took %s, want response before slow webhook delivery completes", elapsed) - } - case <-time.After(150 * time.Millisecond): + // See TestCheckoutCompletionDoesNotWaitForSlowWebhookDelivery: a response + // at all proves the handler did not wait; the timeout only bounds a hang. + case <-time.After(5 * time.Second): t.Fatal("payment intent create waited for slow webhook delivery") }