mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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
100 lines
3.7 KiB
Go
100 lines
3.7 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/billing/bucket"
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/models/paymentmethod"
|
|
txutil "github.com/hanzoai/commerce/models/transaction/util"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
)
|
|
|
|
// bucketedSplit fetches the raw ledger for a subject+currency and derives the
|
|
// three-bucket split (credits granted/remaining, prepaid, holds) — the honest
|
|
// projection of the SAME transactions the gateway balance gate reads.
|
|
func bucketedSplit(ctx context.Context, subject string, cur currency.Type, test bool) (bucket.Split, error) {
|
|
raw, err := txutil.GetRawByCurrency(ctx, subject, "iam-user", cur, test)
|
|
if err != nil {
|
|
return bucket.Split{}, err
|
|
}
|
|
return bucket.Compute(raw, subject, time.Now()), nil
|
|
}
|
|
|
|
// cardOnFile is the card-payment-method status for a subject: whether a
|
|
// chargeable card is vaulted, and (best-effort) the default card's brand/last4.
|
|
// A "card on file" is a payment method that can actually be charged — a card
|
|
// type OR any method carrying a vaulted provider reference (Square card-on-file).
|
|
type cardOnFileStatus struct {
|
|
OnFile bool `json:"onFile"`
|
|
Brand string `json:"brand,omitempty"`
|
|
Last4 string `json:"last4,omitempty"`
|
|
IsDefault bool `json:"isDefault,omitempty"`
|
|
}
|
|
|
|
// getCardOnFile reports whether the subject has a chargeable card vaulted. It is
|
|
// what the balance, tier and me reads report as `card{onFile,brand,last4}`.
|
|
func getCardOnFile(db *datastore.Datastore, subject string) cardOnFileStatus {
|
|
subject = strings.ToLower(strings.TrimSpace(subject))
|
|
if subject == "" {
|
|
return cardOnFileStatus{}
|
|
}
|
|
rootKey := db.NewKey("synckey", "", 1, nil)
|
|
iter := paymentmethod.Query(db).Ancestor(rootKey).
|
|
Filter("CustomerId=", subject).
|
|
Run()
|
|
|
|
var status cardOnFileStatus
|
|
for {
|
|
pm := paymentmethod.New(db)
|
|
if _, err := iter.Next(pm); err != nil {
|
|
break
|
|
}
|
|
// A chargeable card: a card type OR a vaulted provider ref (Square
|
|
// card-on-file). A bare bank/wire/paypal method is not a chargeable card.
|
|
chargeable := pm.Card != nil || strings.TrimSpace(pm.ProviderRef) != ""
|
|
if !chargeable {
|
|
continue
|
|
}
|
|
status.OnFile = true
|
|
// Prefer the default card's display details; otherwise the first chargeable.
|
|
if pm.IsDefault || status.Brand == "" {
|
|
if pm.Card != nil {
|
|
status.Brand = pm.Card.Brand
|
|
status.Last4 = pm.Card.Last4
|
|
}
|
|
status.IsDefault = status.IsDefault || pm.IsDefault
|
|
}
|
|
if pm.IsDefault {
|
|
// A default card is authoritative — stop scanning.
|
|
break
|
|
}
|
|
}
|
|
return status
|
|
}
|
|
|
|
// bucketFields merges the three-bucket split + card status into a balance
|
|
// response. It is ADDITIVE — the caller keeps its existing balance/holds/available
|
|
// fields; these expose the split so the console can show credits (granted/left)
|
|
// distinctly from prepaid real money and the card status. The cloud-api customer
|
|
// billing proxy forwards this body verbatim, so the fields reach the console
|
|
// with no cloud change.
|
|
func bucketFields(split bucket.Split, card cardOnFileStatus) map[string]any {
|
|
return map[string]any{
|
|
"creditsGranted": int64(split.CreditsGranted),
|
|
"creditsRemaining": int64(split.CreditsRemaining),
|
|
"prepaidBalance": int64(split.PrepaidBalance),
|
|
"prepaidAvailable": int64(split.PrepaidAvailable),
|
|
// Consumer-facing aliases: "trial" == the non-cash credit bucket
|
|
// (starter/welcome/comp grants), "prepaid" == real money. Same values as
|
|
// creditsGranted/creditsRemaining, named unambiguously for the wallet/
|
|
// credit modal ("$5 trial + $X.XX credits"). Additive — never removes the
|
|
// existing fields.
|
|
"trialGranted": int64(split.CreditsGranted),
|
|
"trialBalance": int64(split.CreditsRemaining),
|
|
"card": card,
|
|
}
|
|
}
|