Documentation
¶
Overview ¶
Package credits keeps prepaid credit for accounts: a ledger, purchases through a payment provider, and the charges the app makes for its work.
One credit is one EUR cent. Every mutation locks the account row and appends to the ledger; corrections are new entries. Purchases and reversals are idempotent per payment, and the app's charges per account and reference. Apps that reserve credit for work in progress report those holds through Config.Holds, and use WithAccount to change their own rows under the same lock.
templ: version: v0.3.1070
Index ¶
- Constants
- Variables
- func FormatEuros(cents int64) string
- func Label(e Entry) string
- func ParseEuros(s string) (int64, bool)
- type Config
- type Credits
- func (cr *Credits) Adjust(ctx context.Context, accountID string, cents int64, ...) error
- func (cr *Credits) Debit(ctx context.Context, accountID string, cents int64, ...) error
- func (cr *Credits) History(ctx context.Context, accountID string, page, perPage int) ([]Entry, bool, error)
- func (cr *Credits) Mount(r gin.IRouter, layout ui.Layout)
- func (cr *Credits) PaymentStatus(ctx context.Context, accountID, intentID string) (string, error)
- func (cr *Credits) Purchase(ctx context.Context, account accounts.Account, creditCents int64, ...) (Purchase, error)
- func (cr *Credits) Purchasing() bool
- func (cr *Credits) Quote(creditCents int64) (Quote, error)
- func (cr *Credits) Summary(ctx context.Context, accountID string) (Summary, error)
- func (cr *Credits) Webhook(c *gin.Context)
- func (cr *Credits) WithAccount(ctx context.Context, accountID string, fn func(tx *Tx) error) error
- type Entry
- type Policy
- type Purchase
- type Quote
- type Summary
- type Tx
Constants ¶
const ( KindTopUp = "top_up" // a purchase KindUsage = "usage" // the app's charge for work KindRefund = "refund" // a refunded purchase KindChargeback = "chargeback" // a disputed purchase KindAdjustment = "adjustment" // an operator's or the app's correction KindExpiry = "expiry" // credit that expired unused KindForfeit = "forfeit" // credit left when the account was deleted )
The ledger's entry kinds.
const MaxChargeCents = 99_999_999
MaxChargeCents is the largest charge one purchase can have, fee included.
const MaxHistoryPage = 100_000
MaxHistoryPage is the last page History serves, so a huge page number cannot make the query skip millions of rows.
Variables ¶
var ( // ErrInsufficient means a debit exceeds the available credit. ErrInsufficient = errors.New("credits: insufficient credit") // ErrConflict means a reference was already used for a different amount or kind. ErrConflict = errors.New("credits: the reference was already used differently") // ErrNotFound means the account does not exist. ErrNotFound = errors.New("credits: account not found") // ErrInvalid means an amount, reference or description is out of range. ErrInvalid = errors.New("credits: invalid amount, reference or description") )
var ( // ErrAmount means a purchase amount is outside the policy's limits. ErrAmount = errors.New("credits: the purchase amount is outside the allowed range") // ErrPurchasesOff means the module has no payment provider. ErrPurchasesOff = errors.New("credits: buying credit is not available") )
var Migrations = app.Migrations{Namespace: "credits", Files: sub(migrationFiles, "migrations")}
Migrations create the credit tables. List them after accounts.Migrations.
Functions ¶
func FormatEuros ¶
FormatEuros renders cents as an amount such as "€12.50" or "−€3.00".
func ParseEuros ¶
ParseEuros reads an amount such as "12", "12.5" or "€12,50" as cents.
Types ¶
type Config ¶
type Config struct {
// Payments takes purchases. Nil turns purchases off: the balance and history stay,
// the pages say that buying credit is not available, and Webhook answers 404. A
// process that only spends credit, such as an API gateway, holds no payment
// configuration this way.
Payments payments.Provider
// Policy is required with Payments.
Policy Policy
// Holds reports how much of an account's credit the app has reserved for work in
// progress. It runs inside the transaction that holds the account lock, and must
// only read. Nil means the app keeps no holds.
Holds func(ctx context.Context, tx *sql.Tx, accountID string) (int64, error)
}
Config configures the module.
type Credits ¶
type Credits struct {
// contains filtered or unexported fields
}
Credits is the credits module.
func New ¶
New returns the credits module. It forfeits an account's remaining credit when the account is deleted, and registers the credits operator commands.
func (*Credits) Adjust ¶
func (cr *Credits) Adjust(ctx context.Context, accountID string, cents int64, reference, description string) error
Adjust records a correction or grant, positive or negative, idempotent per reference. Unlike Debit it may take the balance below zero.
func (*Credits) Debit ¶
func (cr *Credits) Debit(ctx context.Context, accountID string, cents int64, reference, description string) error
Debit takes credit for work. See Tx.Debit.
func (*Credits) History ¶
func (cr *Credits) History(ctx context.Context, accountID string, page, perPage int) ([]Entry, bool, error)
History returns one page of an account's entries, newest first, and whether more follow. Pages start at 1.
func (*Credits) Mount ¶
Mount adds the credits page to r, a group at the page's path that uses accounts.RequireSignIn, such as /account/credits:
- GET "" shows the balance, a purchase form and the transactions.
- POST "" starts a purchase and redirects to the provider's checkout.
- GET "/quote" prices an amount for the purchase form.
- GET "/summary" renders one page of transactions ("page", from 1).
- GET "/balance" renders the available balance.
- GET "/payment-status" reports a purchase started from this browser after the buyer returns, or answers 204 when there is none.
The pages allow the provider's checkout origin as a form target, because starting a purchase redirects there. Without a payment provider they show the balance and transactions, and say that buying credit is not available.
func (*Credits) PaymentStatus ¶
PaymentStatus returns the provider status of an account's purchase: created, pending, paid, refunded, charged_back, failed, canceled or expired. A purchase of another account, or none, returns ErrNotFound.
func (*Credits) Purchase ¶
func (cr *Credits) Purchase(ctx context.Context, account accounts.Account, creditCents int64, successURL string) (Purchase, error)
Purchase prices creditCents, records the purchase, and creates a checkout at the payment provider. The webhook later credits what the purchase records.
func (*Credits) Purchasing ¶
Purchasing reports whether buying credit is available.
func (*Credits) Quote ¶
Quote prices creditCents of credit, or returns ErrAmount outside the policy's limits.
func (*Credits) Summary ¶
Summary reads the balance and holds in one consistent snapshot, without locking.
func (*Credits) Webhook ¶
Webhook receives payment provider webhooks. Mount it on a public route with neither CSRF nor sign-in, such as POST /webhooks/payments. docs/credits.md lists its outcomes. Without a payment provider it answers 404.
It logs event and intent IDs and outcomes only, never bodies or buyer details.
func (*Credits) WithAccount ¶
WithAccount runs fn in a transaction that holds the account's lock. Framework debits and the app's own changes through Tx.SQL commit together, or not at all. Lock the account before any app rows, and do no remote calls inside fn.
type Entry ¶
type Entry struct {
ID string
Kind string
AmountCents int64
// Reference is the app's reference for usage and adjustments, or empty.
Reference string
Description string
CreatedAt time.Time
}
Entry is one ledger entry.
type Policy ¶
type Policy struct {
// MinPurchaseCents and MaxPurchaseCents bound the credit one purchase buys. The
// charge, fee included, cannot exceed MaxChargeCents either.
MinPurchaseCents int64
MaxPurchaseCents int64
// FeeBasisPoints and FeeFixedCents add a fee on top of the credit, grossed up so it
// also covers the percentage charged on itself. Zero means no fee.
FeeBasisPoints int64
FeeFixedCents int64
}
Policy holds the business rules for purchases. They are the app's to choose.
type Tx ¶
type Tx struct {
// contains filtered or unexported fields
}
Tx is an account's credit inside a transaction that holds the account's lock.
func (*Tx) Available ¶
Available returns the balance minus the app's holds. It is never more than the balance and never below zero unless the balance is.
func (*Tx) Capture ¶
Capture charges for work a hold reserved, once the app has settled the hold in the same transaction. Unlike Debit it does not check the available credit: the hold did that when the work started, and finished work is charged even if a refund lowered the balance since. Repeating a capture with the same reference and amount succeeds without charging twice.
func (*Tx) Debit ¶
Debit takes credit for work. It fails with ErrInsufficient when it exceeds the available credit. Repeating a debit with the same reference and amount succeeds without taking credit twice.