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..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") } @@ -5322,6 +5319,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"))