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]>
428 lines
17 KiB
Go
428 lines
17 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/billing/creditledger"
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/models/billinginvoice"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
)
|
|
|
|
// CollectionResult describes the outcome of a payment collection attempt.
|
|
type CollectionResult struct {
|
|
Success bool `json:"success"`
|
|
CreditUsed int64 `json:"creditUsed"`
|
|
BalanceUsed int64 `json:"balanceUsed"`
|
|
ProviderUsed int64 `json:"providerUsed"`
|
|
ProviderRef string `json:"providerRef,omitempty"`
|
|
AmountCharged int64 `json:"amountCharged"`
|
|
Error string `json:"error,omitempty"`
|
|
// Unknown is set when the processor did not say whether the money moved
|
|
// (ErrChargeUnknown). The attempt is not counted, so it is repeated under the
|
|
// same idempotency key.
|
|
Unknown bool `json:"unknown,omitempty"`
|
|
}
|
|
|
|
// ErrChargeUnknown marks a payment whose outcome the processor or the ledger did
|
|
// not report: the request failed in transit, the processor could not be reached,
|
|
// or it answered with anything but a refusal. The money may have moved. A
|
|
// ProviderCharger or a Collection wraps it (errors.Is) so the attempt is repeated
|
|
// under the SAME idempotency key, which answers with the first payment if there
|
|
// was one, instead of counting a decline and charging again under a new key.
|
|
var ErrChargeUnknown = errors.New("charge outcome unknown")
|
|
|
|
// Prepaid is the money a subject paid in advance: its credit grants, then its
|
|
// balance on the one ledger the host keeps. The collector draws from it before
|
|
// it touches a card.
|
|
//
|
|
// It is injected, like ProviderCharger, because both halves live above the
|
|
// engine — the grants in api/billing, the balance on whichever ledger the host
|
|
// installed — and the engine must not import either.
|
|
type Prepaid interface {
|
|
// Available is what Draw could take from the subject right now. An error is
|
|
// unknown, never zero.
|
|
Available(ctx context.Context, subject string, cur currency.Type) (int64, error)
|
|
// Draw takes exactly amount, credits first and then the balance, or takes
|
|
// nothing and says why: an error wrapping ErrPrepaidShort when the money
|
|
// cannot cover it. ref names the act the money pays for, so a retry under the
|
|
// same ref moves no money twice.
|
|
Draw(ctx context.Context, subject string, cur currency.Type, amount int64, ref string) (Drawn, error)
|
|
// Return puts amount back on the subject's balance, once per ref: a retry
|
|
// under the same ref returns nothing more. It answers the ledger's id for the
|
|
// posting.
|
|
Return(ctx context.Context, subject string, cur currency.Type, amount int64, ref string) (string, error)
|
|
}
|
|
|
|
// Drawn is what one prepaid draw took, and from where.
|
|
type Drawn struct {
|
|
// Credit is the part credit grants covered.
|
|
Credit int64
|
|
// Balance is the part the balance covered.
|
|
Balance int64
|
|
// Ref is the ledger's id for the balance posting. Empty when credits covered
|
|
// the whole draw.
|
|
Ref string
|
|
}
|
|
|
|
// ProviderCharger charges amountCents (the part of the invoice prepaid money does
|
|
// not cover) on the external payment provider — the subscription's vaulted
|
|
// card-on-file — and returns the processor reference on success.
|
|
//
|
|
// It is injected exactly like Prepaid so the engine never imports
|
|
// api/billing / payment / the Square SDK: no import cycle, and the collector
|
|
// stays provider-agnostic (it knows "charge the remainder", not "which
|
|
// provider"). Resolving the card is the callback's job (invoice -> subscription
|
|
// -> DefaultPaymentMethod -> providerRef + Square customer id). A nil charger
|
|
// means "no external provider" — credits + balance only, so an unpaid remainder
|
|
// leaves the invoice OPEN.
|
|
//
|
|
// Fail-closed contract: on a decline / missing card / provider error the
|
|
// callback returns a non-nil error and MUST NOT have moved money — the provider
|
|
// leg is all-or-nothing, so the collector records nothing and leaves the invoice
|
|
// open for the next attempt. An outcome the processor did not report wraps
|
|
// ErrChargeUnknown. Exactly ONE charge is attempted per call (no double-charge
|
|
// within a call).
|
|
type ProviderCharger func(ctx context.Context, db *datastore.Datastore, inv *billinginvoice.BillingInvoice, amountCents int64) (providerRef string, err error)
|
|
|
|
// CollectInvoice collects what an open invoice still owes through the one
|
|
// waterfall: prepaid money first (credits, then the balance), the card on file
|
|
// for the rest. Either may be nil, and that leg is skipped.
|
|
//
|
|
// IT IS ALL OR NOTHING AGAINST THE PREPAID MONEY. The allocation is read first
|
|
// and the card, the one leg that can refuse for reasons of its own, is charged
|
|
// before anything prepaid moves. A decline, or no card at all, therefore leaves
|
|
// the credits and the balance where they were. Drawn the other way round, a
|
|
// failed collection had already spent them on an invoice that stayed unpaid, and
|
|
// every retry spent them again.
|
|
//
|
|
// What an attempt does collect accumulates on AmountPaid, so a later attempt
|
|
// collects only the remainder.
|
|
func CollectInvoice(ctx context.Context, db *datastore.Datastore, inv *billinginvoice.BillingInvoice, prepaid Prepaid, chargeProvider ProviderCharger) (*CollectionResult, error) {
|
|
if inv.Status != billinginvoice.Open {
|
|
return nil, fmt.Errorf("invoice must be open to collect, current status: %s", inv.Status)
|
|
}
|
|
|
|
result := &CollectionResult{}
|
|
ref := ""
|
|
owed := inv.AmountDue - inv.AmountPaid
|
|
if owed < 0 {
|
|
owed = 0
|
|
}
|
|
|
|
// The allocation: prepaid covers what it holds, the card the rest. A balance
|
|
// that cannot be read is unknown, so it covers nothing and the card is asked
|
|
// for the whole amount rather than the draw being guessed at.
|
|
fromPrepaid := int64(0)
|
|
if owed > 0 && prepaid != nil {
|
|
avail, err := prepaid.Available(ctx, inv.UserId, inv.Currency)
|
|
switch {
|
|
case err != nil:
|
|
result.Error = "prepaid balance unreadable: " + err.Error()
|
|
case avail > 0:
|
|
fromPrepaid = min(avail, owed)
|
|
}
|
|
}
|
|
|
|
if rest := owed - fromPrepaid; rest > 0 {
|
|
if chargeProvider == nil {
|
|
return unpaid(inv, result, owed)
|
|
}
|
|
charged, err := chargeProvider(ctx, db, inv, rest)
|
|
if err != nil && errors.Is(err, ErrChargeUnknown) {
|
|
// The card may have been charged. The attempt is not counted, so the
|
|
// next one carries this attempt's key and gets its answer back.
|
|
result.Error, result.Unknown = err.Error(), true
|
|
inv.LastAttemptAt = time.Now()
|
|
return result, nil
|
|
}
|
|
if err != nil {
|
|
// Declined, no card on file, or a provider error. The provider leg is
|
|
// all or nothing and nothing prepaid has moved, so the invoice stays
|
|
// open exactly as it was for the next attempt.
|
|
result.Error = err.Error()
|
|
return unpaid(inv, result, owed)
|
|
}
|
|
result.ProviderUsed, result.ProviderRef = rest, charged
|
|
}
|
|
|
|
if fromPrepaid > 0 {
|
|
ref = drawRef(inv)
|
|
d, err := prepaid.Draw(ctx, inv.UserId, inv.Currency, fromPrepaid, ref)
|
|
if err != nil {
|
|
// Reachable only when the balance moved between the read and the draw.
|
|
// Whatever the card took stands as a partial payment; the rest stays owed.
|
|
result.Error = "prepaid draw failed: " + err.Error()
|
|
} else {
|
|
result.CreditUsed, result.BalanceUsed = d.Credit, d.Balance
|
|
if d.Ref != "" {
|
|
ref = d.Ref
|
|
}
|
|
}
|
|
}
|
|
|
|
paid := result.ProviderUsed + result.CreditUsed + result.BalanceUsed
|
|
result.AmountCharged = paid
|
|
inv.AmountPaid += paid
|
|
inv.CreditApplied += result.CreditUsed
|
|
inv.AttemptCount++
|
|
inv.LastAttemptAt = time.Now()
|
|
|
|
if paid < owed {
|
|
if result.Error == "" {
|
|
result.Error = fmt.Sprintf("insufficient funds: %d cents remaining after credits and balance", owed-paid)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// The settling instrument, most specific first: a card charge settled the
|
|
// remainder, credits alone covered it, or the balance did.
|
|
method := "balance"
|
|
switch {
|
|
case result.ProviderUsed > 0:
|
|
method = "card"
|
|
case result.CreditUsed > 0 && result.BalanceUsed == 0:
|
|
method = "credit"
|
|
}
|
|
if result.ProviderRef != "" {
|
|
ref = result.ProviderRef
|
|
}
|
|
result.Success, result.Error = true, ""
|
|
if err := inv.MarkPaid(method, ref); err != nil {
|
|
return result, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// unpaid records an attempt that moved no money and says what is still owed.
|
|
func unpaid(inv *billinginvoice.BillingInvoice, result *CollectionResult, owed int64) (*CollectionResult, error) {
|
|
inv.AttemptCount++
|
|
inv.LastAttemptAt = time.Now()
|
|
if result.Error == "" {
|
|
result.Error = fmt.Sprintf("insufficient funds: %d cents remaining after credits and balance", owed)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// drawRef names the prepaid draw that pays one invoice. It is the draw's
|
|
// idempotency key, and it names what the invoice bills — a subscription's
|
|
// period — not the invoice row or the attempt: a draw whose posting landed but
|
|
// whose answer was lost is found again by the next attempt, and two processes that
|
|
// each raised an invoice for the same period draw once between them. A period is
|
|
// paid from prepaid money at most once, because the card is charged before the
|
|
// draw and a draw that lands settles the invoice. An invoice for no subscription
|
|
// is its own act.
|
|
func drawRef(inv *billinginvoice.BillingInvoice) string {
|
|
if inv.SubscriptionId == "" {
|
|
return "invoice:" + inv.Id()
|
|
}
|
|
return "sub:" + inv.SubscriptionId + ":period:" + strconv.FormatInt(inv.PeriodStart.Unix(), 10)
|
|
}
|
|
|
|
// AttemptKey is the idempotency key of inv's current payment attempt: the key
|
|
// recorded as pending when there is one, else the key of the next attempt,
|
|
// scoped to (subscription, billed period, attempt number) — or to the invoice
|
|
// for one that bills no subscription. The attempt number is AttemptCount before
|
|
// the attempt is counted, so a retry after a decline is a new key while a repeat
|
|
// of an attempt whose outcome was not known is the same one.
|
|
func AttemptKey(inv *billinginvoice.BillingInvoice) string {
|
|
if inv.PendingKey != "" {
|
|
return inv.PendingKey
|
|
}
|
|
if inv.SubscriptionId == "" {
|
|
return "collect:inv:" + inv.Id() + ":attempt:" + strconv.Itoa(inv.AttemptCount)
|
|
}
|
|
return "collect:sub:" + inv.SubscriptionId +
|
|
":period:" + strconv.FormatInt(inv.PeriodStart.Unix(), 10) +
|
|
":attempt:" + strconv.Itoa(inv.AttemptCount)
|
|
}
|
|
|
|
// Pay makes one payment attempt on an OPEN invoice the caller holds the lock of
|
|
// (LockInvoice), for all it still owes, through c and nothing else — so a
|
|
// subscription's invoice is paid the way the subscription was bought, and never
|
|
// from money the customer holds for something else.
|
|
//
|
|
// The attempt is written to the invoice before any money is asked for: the
|
|
// instrument c.Choose picked, the amount, and the key. An attempt that finds one
|
|
// already written repeats it exactly, so an answer lost to a crash, a timeout or
|
|
// a 5xx is looked up under its own key rather than sent again under a new one,
|
|
// and no path can ask for a different amount under a key already used. A known
|
|
// outcome clears it; a decline counts the attempt. The caller persists the
|
|
// invoice after Pay returns.
|
|
func Pay(ctx context.Context, db *datastore.Datastore, inv *billinginvoice.BillingInvoice, c Collection, now time.Time) (*CollectionResult, error) {
|
|
if inv.Status != billinginvoice.Open {
|
|
return nil, fmt.Errorf("invoice must be open to collect, current status: %s", inv.Status)
|
|
}
|
|
result := &CollectionResult{}
|
|
if inv.PendingKey == "" {
|
|
owed := inv.AmountDue - inv.AmountPaid
|
|
if owed <= 0 {
|
|
return result, markPaid(inv, inv.PaymentMethod, "", now, result)
|
|
}
|
|
if c.Choose == nil || c.Pay == nil {
|
|
result.Error = "nothing on file pays this invoice"
|
|
inv.AttemptCount++
|
|
inv.LastAttemptAt = now
|
|
return result, nil
|
|
}
|
|
method, err := c.Choose(ctx, db, inv, owed)
|
|
if err != nil && errors.Is(err, ErrChargeUnknown) {
|
|
// Choose could not tell (a ledger read failed). Nothing was asked for, so
|
|
// nothing is recorded or counted; the next run chooses again.
|
|
result.Error, result.Unknown = err.Error(), true
|
|
return result, nil
|
|
}
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
inv.AttemptCount++
|
|
inv.LastAttemptAt = now
|
|
return result, nil
|
|
}
|
|
inv.PendingMethod, inv.PendingAmount, inv.PendingKey, inv.PendingRef = method, owed, AttemptKey(inv), ""
|
|
inv.LastAttemptAt = now
|
|
if err := inv.Update(); err != nil {
|
|
return nil, fmt.Errorf("record the attempt on invoice %s before paying: %w", inv.Id(), err)
|
|
}
|
|
}
|
|
|
|
ref, err := c.Pay(ctx, db, inv, inv.PendingMethod, inv.PendingAmount)
|
|
inv.LastAttemptAt = now
|
|
switch {
|
|
case err != nil && errors.Is(err, ErrChargeUnknown):
|
|
result.Error, result.Unknown = err.Error(), true
|
|
if ref != "" {
|
|
inv.PendingRef = ref
|
|
}
|
|
return result, nil
|
|
case err != nil:
|
|
result.Error = err.Error()
|
|
clearPending(inv)
|
|
inv.AttemptCount++
|
|
return result, nil
|
|
}
|
|
method, amount := inv.PendingMethod, inv.PendingAmount
|
|
clearPending(inv)
|
|
inv.AttemptCount++
|
|
inv.AmountPaid += amount
|
|
result.AmountCharged, result.ProviderRef = amount, ref
|
|
if method == PaidByCard {
|
|
result.ProviderUsed = amount
|
|
} else {
|
|
result.BalanceUsed = amount
|
|
}
|
|
if inv.AmountPaid < inv.AmountDue {
|
|
return result, nil
|
|
}
|
|
return result, markPaid(inv, method, ref, now, result)
|
|
}
|
|
|
|
// PayInvoice is the customer paying a subscription's open invoice themselves:
|
|
// one attempt through c (Pay), on the same recorded attempt the billing cycle
|
|
// uses, and persisted. A decline moves the next scheduled retry to the first
|
|
// point of RetrySchedule still ahead; an unknown outcome is left for the next
|
|
// run to repeat under its key. The subscription moves onto the paid period at
|
|
// the cycle's next run. The caller holds the invoice's lock (LockInvoice).
|
|
func PayInvoice(ctx context.Context, db *datastore.Datastore, inv *billinginvoice.BillingInvoice, c Collection, now time.Time) (*CollectionResult, error) {
|
|
res, err := Pay(ctx, db, inv, c, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch {
|
|
case res.Unknown:
|
|
inv.NextAttemptAt = now
|
|
case !res.Success && !inv.DueDate.IsZero():
|
|
if next := nextRetry(inv.DueDate, now); !next.IsZero() {
|
|
inv.NextAttemptAt = next
|
|
}
|
|
}
|
|
if err := inv.Update(); err != nil {
|
|
return res, fmt.Errorf("record the payment on invoice %s: %w", inv.Id(), err)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// markPaid marks inv paid by method at now.
|
|
func markPaid(inv *billinginvoice.BillingInvoice, method, ref string, now time.Time, result *CollectionResult) error {
|
|
if err := inv.MarkPaid(method, ref); err != nil {
|
|
return err
|
|
}
|
|
inv.PaidAt = now
|
|
result.Success = true
|
|
return nil
|
|
}
|
|
|
|
func clearPending(inv *billinginvoice.BillingInvoice) {
|
|
inv.PendingMethod, inv.PendingAmount, inv.PendingKey, inv.PendingRef = "", 0, "", ""
|
|
}
|
|
|
|
// The instruments a subscription's invoice is paid with, as Collection.Choose
|
|
// names them and the paid invoice records them.
|
|
const (
|
|
PaidByCard = "card"
|
|
PaidByPrepaid = "prepaid"
|
|
)
|
|
|
|
// CardPayer is a Collection that pays by the subscription's card through charge.
|
|
func CardPayer(charge ProviderCharger) Collection {
|
|
return Collection{
|
|
Choose: func(context.Context, *datastore.Datastore, *billinginvoice.BillingInvoice, int64) (string, error) {
|
|
return PaidByCard, nil
|
|
},
|
|
Pay: func(ctx context.Context, db *datastore.Datastore, inv *billinginvoice.BillingInvoice, _ string, amount int64) (string, error) {
|
|
return charge(ctx, db, inv, amount)
|
|
},
|
|
}
|
|
}
|
|
|
|
// PrepaidPayer is a Collection that pays from the subscriber's prepaid money
|
|
// through p: its credit grants, then its balance, all or nothing. The draw's
|
|
// ref is the period's (drawRef), so a period is drawn at most once however many
|
|
// attempts or invoices ask for it, and a repeat of an attempt whose answer was
|
|
// lost gets the first draw back. A draw the money cannot cover is a decline;
|
|
// any other failure is ErrChargeUnknown.
|
|
func PrepaidPayer(p Prepaid) Collection {
|
|
return Collection{
|
|
Choose: func(ctx context.Context, _ *datastore.Datastore, inv *billinginvoice.BillingInvoice, amount int64) (string, error) {
|
|
avail, err := p.Available(ctx, inv.UserId, invoiceCurrency(inv))
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: read the prepaid money: %v", ErrChargeUnknown, err)
|
|
}
|
|
if avail < amount {
|
|
return "", fmt.Errorf("%w: prepaid money holds %d cents, the renewal is %d", ErrPrepaidShort, avail, amount)
|
|
}
|
|
return PaidByPrepaid, nil
|
|
},
|
|
Pay: func(ctx context.Context, _ *datastore.Datastore, inv *billinginvoice.BillingInvoice, _ string, amount int64) (string, error) {
|
|
ref := drawRef(inv)
|
|
d, err := p.Draw(ctx, inv.UserId, invoiceCurrency(inv), amount, ref)
|
|
switch {
|
|
case errors.Is(err, ErrPrepaidShort):
|
|
return "", err
|
|
case err != nil:
|
|
return "", fmt.Errorf("%w: %v", ErrChargeUnknown, err)
|
|
}
|
|
inv.CreditApplied += d.Credit
|
|
if d.Ref != "" {
|
|
return d.Ref, nil
|
|
}
|
|
return ref, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ErrPrepaidShort is a draw the subject's prepaid money cannot cover. It moved
|
|
// nothing. A Prepaid answers a short draw with an error wrapping it: the one
|
|
// ledger's own ErrShort.
|
|
var ErrPrepaidShort = creditledger.ErrShort
|
|
|
|
func invoiceCurrency(inv *billinginvoice.BillingInvoice) currency.Type {
|
|
if inv.Currency == "" {
|
|
return currency.USD
|
|
}
|
|
return inv.Currency
|
|
}
|