Files
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

75 lines
2.2 KiB
Go

package engine
import (
"errors"
"testing"
"github.com/hanzoai/commerce/models/idempotencykey"
)
// TestLockInvoice_OneHolderAtATime: a held invoice answers ErrInvoiceBusy
// without waiting, another invoice is unaffected, and a released lock can be
// taken again.
func TestLockInvoice_OneHolderAtATime(t *testing.T) {
db, done := settleDB(t, "lock-one")
defer done()
release, err := LockInvoice(db, "inv_a")
if err != nil {
t.Fatalf("first lock: %v", err)
}
if _, err := LockInvoice(db, "inv_a"); !errors.Is(err, ErrInvoiceBusy) {
t.Fatalf("second lock on a held invoice: %v, want ErrInvoiceBusy", err)
}
other, err := LockInvoice(db, "inv_b")
if err != nil {
t.Fatalf("lock on another invoice: %v", err)
}
other()
release()
again, err := LockInvoice(db, "inv_a")
if err != nil {
t.Fatalf("lock after release: %v", err)
}
again()
}
// TestLockInvoice_HonorsAnotherProcessLease: a lease row another process holds
// refuses the lock until it expires.
func TestLockInvoice_HonorsAnotherProcessLease(t *testing.T) {
db, done := settleDB(t, "lock-lease")
defer done()
if _, _, err := idempotencykey.Begin(db, invoiceLockScope, "inv_c"); err != nil {
t.Fatalf("lease: %v", err)
}
if _, err := LockInvoice(db, "inv_c"); !errors.Is(err, ErrInvoiceBusy) {
t.Fatalf("lock under another process's live lease: %v, want ErrInvoiceBusy", err)
}
}
// TestLockInvoice_LeaseHoldsAfterARelease: the lease row has a deterministic id,
// so it is written, released and written again for every payment on an invoice.
// A lease one process takes after an earlier release still refuses the lock to
// another process that shares the store and not the first one's memory.
func TestLockInvoice_LeaseHoldsAfterARelease(t *testing.T) {
db, done := settleDB(t, "lock-relock")
defer done()
const id = "inv_relock"
first, err := LockInvoice(db, id)
if err != nil {
t.Fatalf("first lock: %v", err)
}
first()
if _, replay, err := idempotencykey.Begin(db, invoiceLockScope, id); err != nil || replay {
t.Fatalf("the other process's lease: replay=%v err=%v", replay, err)
}
if release, err := LockInvoice(db, id); !errors.Is(err, ErrInvoiceBusy) {
if release != nil {
release()
}
t.Fatalf("took an invoice another process holds a lease on (err=%v)", err)
}
}