Files
commerce/api/billing/cycle_lows_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

107 lines
4.3 KiB
Go

package billing
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/engine"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/util/test/ae"
)
// TestCycleEndpoints_RefuseAnUnclearDryRun: a cycle endpoint charges cards only
// from a request that says dryRun=false. Saying nothing is a dry run, and a
// misspelled parameter or a dryRun that is not true or false is refused.
func TestCycleEndpoints_RefuseAnUnclearDryRun(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("cyc-dryparse")
m := squareMock("", "", "sqpay_dp")
withFakeSquare(t, m)
db := datastore.New(org.Namespaced(ctx))
cardSubThrough(t, db, "cyc-dryparse", time.Now().Add(-time.Minute))
run := func(query string) int {
req := httptest.NewRequest(http.MethodPost, "/v1/billing/cycle/run"+query, nil)
return driveSeeded(func(c *zip.Ctx) { c.Locals("organization", org); c.SetContext(ctx) }, "/v1/billing/cycle/run", req, RunBillingCycle).StatusCode
}
for _, q := range []string{"?dryRun=yes", "?dryrun=true", "?dry_run=1", "?dryRun=true&force=1"} {
if code := run(q); code != http.StatusBadRequest {
t.Fatalf("%s: %d, want 400", q, code)
}
}
if code := run("?dryRun=1"); code != http.StatusOK || m.chargeCalls != 0 {
t.Fatalf("?dryRun=1: %d with %d charges, want a dry run", code, m.chargeCalls)
}
if m.chargeCalls != 0 {
t.Fatalf("a refused request charged %d times", m.chargeCalls)
}
if code := run(""); code != http.StatusOK || m.chargeCalls != 0 {
t.Fatalf("no dryRun: %d with %d charges, want a dry run", code, m.chargeCalls)
}
if code := run("?dryRun=false"); code != http.StatusOK || m.chargeCalls != 1 {
t.Fatalf("?dryRun=false: %d with %d charges, want the real run", code, m.chargeCalls)
}
}
// TestCycle_UnsettledPaymentIsLookedUpNotChargedAgain: Square answers a charge
// with a payment it took and did not settle. That is not a decline: the payment
// id is kept, and the next run looks it up; once it has settled, the renewal is
// paid by it and the card is not charged again.
func TestCycle_UnsettledPaymentIsLookedUpNotChargedAgain(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("cyc-unsettled")
m := squareMock("", "", "sqpay_held")
m.chargeStatus = "APPROVED"
withFakeSquare(t, m)
db := datastore.New(org.Namespaced(ctx))
d := time.Now().Truncate(time.Second)
sub := cardSubThrough(t, db, "cyc-unsettled", d)
only(t, cycleAt(t, ctx, org, d, false), engine.Skipped)
inv := invoicesForSub(t, db, sub.Id())[0]
if inv.PendingRef != "sqpay_held" || inv.AttemptCount != 0 {
t.Fatalf("pending ref %q attempts %d, want the payment id kept and nothing counted", inv.PendingRef, inv.AttemptCount)
}
m.txStatus = map[string]string{"sqpay_held": "COMPLETED"}
only(t, cycleAt(t, ctx, org, d.Add(time.Hour), false), engine.Retried)
if m.chargeCalls != 1 {
t.Fatalf("card charged %d times, want the one payment", m.chargeCalls)
}
if paid, _ := invoiceRow(db, inv.Id()); paid.Status != billinginvoice.Paid || paid.PaymentRef != "sqpay_held" {
t.Fatalf("invoice %s ref %q, want paid by the looked-up payment", paid.Status, paid.PaymentRef)
}
}
// TestLineItems_KeepTheInvoiceListed: editing a draft invoice writes it back,
// and it stays in the invoice list.
func TestLineItems_KeepTheInvoiceListed(t *testing.T) {
ctx := ae.NewContext()
defer ctx.Close()
org := moneyOrg("inv-edit")
view, f := RaiseInvoice(ctx, org, InvoiceIn{UserID: "inv-edit/alice", Lines: []InvoiceLine{{Description: "setup", Amount: 500}}})
if f != nil {
t.Fatalf("raise: %+v", f)
}
body := `{"type":"one_off","description":"extra","amount":300}`
req := httptest.NewRequest(http.MethodPost, "/v1/billing/invoices/"+view.ID+"/line-items", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := driveSeeded(func(c *zip.Ctx) { c.Locals("organization", org); c.SetContext(ctx) },
"/v1/billing/invoices/:id/line-items", req, AddInvoiceLineItem)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
t.Fatalf("add line item: %d", resp.StatusCode)
}
listed, err := ListInvoices(ctx, org, "inv-edit/alice", "", "", 0, 0)
if err != nil || len(listed) != 1 || listed[0].ID != view.ID {
t.Fatalf("listed %+v (err %v), want the edited draft", listed, err)
}
}