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]>
64 lines
1.9 KiB
Go
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
|
|
}
|