mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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]>
75 lines
2.2 KiB
Go
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)
|
|
}
|
|
}
|