Files
commerce/billing/engine/collector.go
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

187 lines
6.4 KiB
Go

package engine
import (
"context"
"fmt"
"time"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/transaction"
"github.com/hanzoai/commerce/models/types/currency"
txutil "github.com/hanzoai/commerce/models/transaction/util"
)
// 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"`
}
// CreditBurner is the function signature for burning credits.
// This matches the existing BurnCredits function in api/billing/credit_grants.go.
type CreditBurner func(db *datastore.Datastore, userId string, amount int64, meterId string) (int64, error)
// ProviderCharger charges amountCents (the invoice remainder left unpaid after
// credits + balance) on the external payment provider — the subscription's
// vaulted card-on-file — and returns the processor reference on success.
//
// It is injected exactly like CreditBurner 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 (the prior Phase-5
// stub), 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 dunning workflow to retry. Exactly ONE charge is attempted per
// CollectInvoice 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 attempts to collect payment for an invoice using the
// provider-agnostic waterfall: credits -> balance -> external provider.
// burnCredits and chargeProvider are injected to avoid circular imports; either
// may be nil (that leg is skipped).
func CollectInvoice(ctx context.Context, db *datastore.Datastore, inv *billinginvoice.BillingInvoice, burnCredits CreditBurner, 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{}
remaining := inv.AmountDue
// Step 1: Burn credits
if remaining > 0 && burnCredits != nil {
afterCredits, err := burnCredits(db, inv.UserId, remaining, "")
if err != nil {
// Non-fatal: continue without credits
_ = err
} else {
result.CreditUsed = remaining - afterCredits
remaining = afterCredits
}
}
// Step 2: Deduct from transaction balance
if remaining > 0 {
balanceUsed, err := DeductFromBalance(ctx, db, inv.UserId, inv.Currency, remaining)
if err != nil {
// Non-fatal: continue to external provider
_ = err
} else {
result.BalanceUsed = balanceUsed
remaining -= balanceUsed
}
}
// Step 3: External provider — charge the subscription's vaulted card-on-file
// for whatever credits + balance did not cover. Injected (ProviderCharger),
// like burnCredits, so the engine never imports the payment/Square layer.
if remaining > 0 && chargeProvider != nil {
ref, err := chargeProvider(ctx, db, inv, remaining)
if err != nil {
// Declined / no card on file / provider error. Non-fatal here: the
// remainder stays unpaid and the invoice is left OPEN below (the
// dunning workflow retries it). The provider leg is all-or-nothing —
// no partial charge is recorded, so a later retry never double-charges.
result.Error = err.Error()
} else {
result.ProviderUsed = remaining
result.ProviderRef = ref
remaining = 0
}
}
if remaining > 0 {
// Still unpaid after credits + balance (+ any provider attempt): partial
// failure — the invoice stays open. Preserve a provider decline message
// when there was one, else report the shortfall.
result.Success = false
if result.Error == "" {
result.Error = fmt.Sprintf("insufficient funds: %d cents remaining after credits and balance", remaining)
}
} else {
result.Success = true
}
result.AmountCharged = inv.AmountDue - remaining
// Update invoice
inv.CreditApplied = result.CreditUsed
inv.AmountPaid = result.AmountCharged
inv.AttemptCount++
inv.LastAttemptAt = time.Now()
if result.Success {
// The settling instrument, most-specific first: a card charge (the
// provider leg) settled the remainder → "card"; a pure credit burn →
// "credit"; otherwise the transaction balance → "balance".
method := "balance"
switch {
case result.ProviderUsed > 0:
method = "card"
case result.CreditUsed > 0 && result.BalanceUsed == 0:
method = "credit"
}
if err := inv.MarkPaid(method, result.ProviderRef); err != nil {
return result, err
}
}
return result, nil
}
// DeductFromBalance withdraws the specified amount from the user's
// transaction balance if sufficient funds exist.
func DeductFromBalance(ctx context.Context, db *datastore.Datastore, userId string, cur currency.Type, amount int64) (int64, error) {
if cur == "" {
cur = "usd"
}
// Check available balance
data, err := txutil.GetTransactionsByCurrency(ctx, userId, "user", cur, false)
if err != nil {
return 0, err
}
balData, ok := data.Data[cur]
if !ok || int64(balData.Balance) < amount {
// Use whatever is available
available := int64(0)
if ok {
available = int64(balData.Balance)
}
if available <= 0 {
return 0, nil
}
amount = available
}
// Create withdrawal transaction
tx := transaction.New(db)
tx.Parent = db.NewKey("synckey", "", 1, nil)
tx.Type = transaction.Withdraw
tx.Amount = currency.Cents(amount)
tx.Currency = cur
tx.SourceKind = "user"
tx.SourceId = userId
tx.DestinationKind = "billing"
tx.DestinationId = "invoice"
tx.Notes = "Invoice payment"
if err := tx.Create(); err != nil {
return 0, fmt.Errorf("failed to create withdrawal: %w", err)
}
return amount, nil
}