Files
commerce/api/billing/invoice_load_test.go
zooqueenandHanzo Dev bd0dabc515 Subscriptions renew from the source they were bought with, one period up front at the interval and price bought, retry a decline on days 1, 3 and 7 before dropping to free, and the cycle is a dry run unless asked
The billing cycle takes one step per live subscription (engine.Settle) and
reports every one: org, plan, interval, paid period, billed period, price,
amount, source and action. Without dryRun=false it writes nothing.

- A renewal bills the period after the paid one from sub.Plan, the plan as
  bought, on the source it was bought from: its card, or prepaid money
  (credits, then the balance) for a plan bought with credits or the balance.
  A plan collected outside Hanzo is renewed by its processor and never charged.
- A decline is retried 1, 3 and 7 days after it; the subscriber keeps the
  tier meanwhile, and the last decline ends the subscription.
- A subscription more than 72 hours past its paid period with no attempt is
  overdue: nothing is charged or written until its customer renews it, which
  bills the period from then and never the time it was not renewed.
- Realignment is an operator's act with its own dry run
  (POST /v1/billing/realign/run[-all]): a row whose PeriodStart is its current
  paid invoice's PeriodEnd moves back onto that invoice's period.
- Every payment on an invoice holds one lock and records its attempt before
  asking for money; an unknown outcome is repeated under its key, escalated to
  unpaid after the schedule, and resolved before any cancel or end voids it.
- A cancel landing while a renewal is at the processor stays canceled; a
  charge that does not answer in 30 seconds is unknown; one org's cycle stops
  starting subscriptions after 10 minutes.
- A voided or uncollectible invoice returns what it collected to prepaid
  money; tax is set on drafts only; a platform operator voids an unresolved
  attempt through POST /v1/billing/invoices/:id/void-unresolved with a reason
  recorded in the billing event ledger.
- How a subscription was bought is read from its earliest invoice that bills
  a period.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 06:36:35 -07:00

34 lines
1.1 KiB
Go

package billing
import (
"net/http"
"testing"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/util/test/ae"
)
// TestPayInvoice_KeepsTheInvoiceListed: paying an invoice writes it back, and
// it stays in the invoice list. The list reads invoices under no ancestor, so a
// row read by id and written back is listed like any other.
func TestPayInvoice_KeepsTheInvoiceListed(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("inv-listed")
withFakeSquare(t, squareMock("", "", "sqpay_listed"))
db := datastore.New(org.Namespaced(ctx))
sub := seedCardBackedSub(t, db, "inv-listed", "dev", "ccof_il", "cust_il")
inv := seedOpenInvoice(t, db, sub, 2000)
if resp := invokePay(org, ctx, inv.Id()); resp.StatusCode != http.StatusOK {
t.Fatalf("pay status=%d, want 200", resp.StatusCode)
}
listed, err := ListInvoices(ctx, org, "", "", sub.Id(), 0, 0)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(listed) != 1 || listed[0].ID != inv.Id() || listed[0].Status != "paid" {
t.Fatalf("invoice list after paying = %+v, want the one invoice, paid", listed)
}
}