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

64 lines
1.9 KiB
Go

package engine
import (
"errors"
"fmt"
"sync"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/idempotencykey"
)
// ErrInvoiceBusy means another path holds the invoice's lock: a payment, a void
// or a tax change on it is under way.
var ErrInvoiceBusy = errors.New("a payment or change on this invoice is in progress")
// invoiceLockScope names the invoice leases among the idempotency records.
const invoiceLockScope = "invoice-lock"
// held is the invoices this process has locked, by namespace and id.
var held = struct {
sync.Mutex
ids map[string]bool
}{ids: map[string]bool{}}
// LockInvoice takes the one lock every path that moves money on an invoice, or
// changes what it owes, holds: the billing cycle, the customer's own payment,
// void and tax. It never waits: a held lock answers ErrInvoiceBusy, and the
// caller answers "in progress" or leaves the invoice for the next run.
//
// The lock is two claims. An in-process one serializes the paths inside the
// single writer commerce runs as. A lease row keyed by the invoice id is what
// another process honors; a holder that crashes leaves it to expire after
// idempotencykey.StartedTTL. A lease that cannot be read or written refuses the
// lock rather than guessing.
func LockInvoice(db *datastore.Datastore, invoiceID string) (release func(), err error) {
key := db.GetNamespace() + "/" + invoiceID
held.Lock()
if held.ids[key] {
held.Unlock()
return nil, ErrInvoiceBusy
}
held.ids[key] = true
held.Unlock()
unhold := func() {
held.Lock()
delete(held.ids, key)
held.Unlock()
}
lease, replay, err := idempotencykey.Begin(db, invoiceLockScope, invoiceID)
if err != nil {
unhold()
return nil, fmt.Errorf("take the lease on invoice %s: %w", invoiceID, err)
}
if replay {
unhold()
return nil, ErrInvoiceBusy
}
return func() {
_ = lease.Delete()
unhold()
}, nil
}