wallet

package module
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 9, 2026 License: MIT Imports: 30 Imported by: 0

README

Arandu Wallet

An Arandu package. It registers its own routes, owns its own tables, and decides for itself who may reach either.

Install

go get github.com/hyz-is/arandu-wallet

Wire it

An Arandu application registers a module explicitly. There is no service provider, no container and no discovery, so these are the lines to paste into bootstrap/app.go and there are no others.

The import, with the other module imports:

import (
	wallet "github.com/hyz-is/arandu-wallet"
)

The construction, in Build, after the session store exists and before k.Register:

	walletModule, err := wallet.New(wallet.Config{
		Tenant: cfg.Auth.Tenant,
		CSRF:   cfg.CSRF,
	}, db, sessions)
	if err != nil {
		return App{}, err
	}

And the registration, inside the k.Register(...) call already there:

		walletModule,

Then, once, before the application serves:

aru migrate

This package owns six tables -- the wallets, the operations, the ledger, the recorded rates, the recorded charges and the purchases -- which is why the migration step is not optional and why arandu.mod.toml says migrations = true.

Quote a rate, if your wallets are not all counted the same way

A transfer between two wallets that hold different currencies, or the same currency at different scales, is an exchange. It needs a rate, and a rate comes from outside the process, so this package does not fetch one: arandu.mod.toml says network = false and means it.

Leave Config.Rates nil and every such transfer is refused with ErrCurrencyMismatch. That is the ordinary case, not a degraded one -- an application whose wallets all hold one currency never needs a rate.

To allow them, supply the provider you trust:

type RateProvider interface {
	Rate(ctx context.Context, g security.Grant, from, to Currency) (Rate, error)
}

It answers with the rate, not with a converted amount. Applying it, rounding it and recording it belong to this package, so every exchange in the application is rounded the same way and leaves the same row behind whatever the provider is. A Rate is the exact fraction Numerator/Denominator -- 5.4321 is 54321/10000 -- because a rate held as a float is already a different rate than the one somebody quoted.

The Grant comes first because a rate can be a tenant's own. A provider that cannot tell whose rate it is asked for is a provider that answers with somebody else's.

Publish the views

This package carries the markup of its own pages and hands it over instead of rendering it from the inside, because a page you cannot edit is a page that says the wrong thing in your product.

Look at what would be written, then write it:

aru vendor:publish --tag=view
aru vendor:publish --tag=view --apply

Nothing is written without --apply. The preview lists every file as create, update, unchanged or conflict, and running the command a second time writes nothing. A file changed outside its arandu:begin custom markers is reported as a conflict and left alone; --force publishes over one, and even then what is inside the markers is carried forward.

The files land under resources/views/modules/wallet/, and from that point they are yours. Nothing of this package is compiled beside them, so no view name is registered twice and no rule has to decide which of two files won — the consequence being that a view of this package that changes later does not reach a project that already published it.

Two steps are left to you, and they are left to you because a command that edited bootstrap/app.go behind your back is a command whose output nobody can explain. Compile what was written:

aru view:build

and import the directory it wrote into, with the other imports:

	_ "your/module/path/storage/framework/views/modules/wallet"

Without that import the views are not in the binary, and the module refuses to boot rather than answering the first request that reaches one of them with a 500. The refusal names the view, the command and the import.

Configuration

field required meaning
Tenant yes the customer a visitor with no session is read as. From the application's configuration, never from the request.
Prefix no where the routes are mounted. Defaults to /wallet.
PageSize no how many records one page answers with. Defaults to 25, refused above 200.
Rates no quotes the rate between two currencies. Nil refuses every transfer that would need one.
Fees no answers with what a wallet charges to be paid. Nil charges nothing.
Discounts no answers with what one payer is charged less. Nil discounts nothing.
CSRF yes issues the token every form on the screens carries. Every screen here moves money.
Translator no your own catalogue, asked before the one this package ships.
Listeners no told what the money did, after the write has committed.

New returns an error rather than starting half-wired, so a setting that cannot work fails where it is written instead of on the first request that needed it.

Routes

method path name
GET /wallet wallet.index
POST /wallet wallet.store
GET /wallet/{id} wallet.show
GET /wallet/{id}/entries wallet.entries
PUT /wallet/{id}/credit wallet.credit
POST /wallet/{id}/deposits wallet.deposit
POST /wallet/{id}/withdrawals wallet.withdraw
POST /wallet/{id}/transfers wallet.transfer
GET /wallet/{id}/purchases wallet.purchases
POST /wallet/operations/{operation}/reversals wallet.reverse
POST /wallet/operations/{operation}/confirmations wallet.confirm
POST /wallet/purchases/refunds wallet.refund

Every one of them is refused until the policy is opened. That is the state the package ships in, and it is deliberate.

Each GET answers JSON to a client that asks for it and a page to a browser, so there is one address per thing rather than one for people and one for programs.

Paying for a basket has no route. A basket names products, and a product is your type: what is for sale, what it costs this customer and how many are left are three questions this package has never been able to answer. Pay is a Go call you make from the handler that owns your catalogue.

Sell something

What is for sale is yours, through an interface this package declares and never implements:

type Book struct{ ... }

func (b *Book) ProductKey() string       { return b.sku }
func (b *Book) ReceiverWalletID() string { return b.shopWalletID }
func (b *Book) Price(ctx context.Context, g security.Grant, buyer wallet.Wallet) (wallet.Amount, error) {
	return b.price, nil
}

A catalogue that keeps a stock answers LimitedProduct.CanBuy as well, and it is asked before a single balance is touched.

receipt, err := svc.Pay(ctx, actor, wallet.PayRequest{
	IdempotencyKey: key,
	PayerWalletID:  buyer.ID,
	Cart: wallet.NewCart(
		wallet.CartItem{Product: book, Quantity: 2},
		wallet.CartItem{Product: pen, BeneficiaryWalletID: friend.ID},
	),
})

Every line is one movement out of the payer, one into the wallet that sells it and one into whoever collects the fee, all in one operation and one transaction. A line bought for somebody else is a gift: the money still leaves the payer, and the record says the beneficiary bought it -- which is what Bought reads afterwards, one statement for a whole page of questions.

A basket is undone line by line, with Refund, so a basket half of which was already given back cannot be given back whole.

Be told what the money did

	Listeners: []wallet.Listener{func(ctx context.Context, e wallet.Event) {
		log.Printf("%s %s on %s", e.Kind, e.Amount.Format(e.DecimalPlaces), e.WalletID)
	}},

A listener is called after the write has committed, never inside it. A basket that runs out of money on its last line announces nothing at all, though every earlier line really moved a balance for as long as the transaction lasted.

There is no dispatcher and no queue here: an application that wants the work off the request hands it to whatever it already uses.

Give an operator something to run

	commands, err := wallet.Commands(wallet.Deps{
		Service:  walletModule.Service(),
		Operator: func(tenant string) security.Subject { return app.Operator(tenant) },
	})

wallet:wallets, wallet:statement, wallet:purchases and wallet:audit. Every one of them reads, and the last one reports what a ledger adds up to without ever repairing it. Who a command runs as is yours to answer: a package that minted a subject for itself would be a package that authorizes itself.

Open the policy

policy.go denies every action and has no branch that allows one. Open what this package needs, one action at a time, inside the custom block:

	// arandu:begin custom
	if a == WalletView && (s.ID == record.ID || s.HasRole("admin")) {
		return nil
	}
	// arandu:end custom

What is not written there stays closed, including every action added later.

Model-first data path

Wallet embeds model.Model[Wallet], and Wallets(db) is the one configured entry point for its table. WalletService owns *data.DB and follows validate -> security.Authorize -> Grant -> Model terminal; handlers never hold the database or construct a Model.

Create writes TenantID from data.Tenant(g). Find authorizes before reading and again against the row it found. List authorizes before building its scoped, allowlisted query. The Model keeps its default tenant_id scope on every terminal.

Terminals return *Wallet and []*Wallet. Keep those pointers intact: copying an embedded Model leaves its Entity pointer aimed at the original allocation. Resource and Collection are explicit response snapshots and do not expose tenant or Model internals.

There is no CRUD Repository. Add one only for a complex query, read model, report, export or raw SQL contract that the common Model path cannot express.

Layout

module.go      registration, routes, handlers and migrations
config.go      what the application passes in
money.go       the amount type, its scale and its arithmetic
rate.go        the rate, its arithmetic, and the seam that quotes it
fee.go         the fee, its arithmetic, and the seams that price a payment
meta.go        what the application attaches to a movement
cart.go        the basket, and the seams that say what is for sale
model.go       the entities, and what they may answer with
purchase.go    the record of what was bought, and its own read model
policy.go      who may do what
service.go     the rules and authorized Model access
event.go       what a listener is told, once the write has committed
commands.go    what an operator runs from a terminal
translation.go the sentences the screens say
views.go       the screens, and the files the application takes ownership of

See it run, against SQLite in a temporary directory, with nothing to configure:

go run -tags example ./example

What is already correct, and has to stay that way

The policy denies everything. There is no permit-all branch to delete later. The Service calls security.Authorize before its first Wallets(db) reach, and every Model terminal requires the Grant that call produced.

Authorization precedes the Model. The package audit checks that order in every exported Service method. A Model terminal enforces tenant scope; the preceding Policy call decides whether the action itself is allowed.

The tenant comes from data.Tenant(g). Never from the path, the body, the query string or a header. The value on the Grant came from the session; a value that arrived with the request is a value the caller chose.

arandu.mod.toml declares what the package does — network, filesystem, exec, migrations — and the suite compares the declaration against what the code calls, not against what it imports: net/http is imported by everything with a route and says nothing. A package that says it makes no outbound calls and then opens one fails its own tests, and that is the only place the comparison happens. aru doctor audits the application it is run inside and never loads a dependency, so nothing audits an installed package except the package itself.

Tests

go test -race ./...

The denial suite constructs the Service with a nil database, so even building Wallets(nil) would panic. The structural twin reads the allowed path and rejects any Service method that reaches the Model before Authorize.

Licence

MIT. See LICENSE.md. Copyright Paulo R. Lima.

Documentation

Overview

Package wallet keeps balances and the ledger that explains them.

A wallet holds an integer of minor units in one currency; a holder may have several. Money enters and leaves through operations -- deposit, withdrawal, transfer, exchange, reversal, confirmation -- and every operation appends to a ledger that is never rewritten. The balance column is the projection of that ledger and is only ever moved by a statement carrying its own guard, so a withdrawal that would take the balance past what the wallet may hold is refused by the write itself rather than by a comparison made before it.

How far past zero a wallet may go is its credit limit, a column the guard reads in the same statement that moves the money.

A movement can be recorded without counting. Its entries are in the ledger saying what was proposed, the balance has not moved, and a confirmation appends the settled entry beside each of them under an operation naming the one it settles. Nothing is ever flipped: the sum of the settled entries is the balance, and what is still waiting is readable beside it.

A transfer between wallets that are not counted the same way is an exchange, which is its own kind of operation and not a transfer with a note on it. The rate is quoted once, applied under one rounding rule, and written down beside the operation with both currencies, both scales, both amounts, the moment it was quoted and the part no minor unit could carry -- so the arithmetic can be done again from the row alone.

A payment between two wallets can cost more or less than it moves. What the payer is charged less and what the receiver charges to be paid are both the application's to answer, through seams this package asks once and records: the share, its floor and its ceiling, what it was computed from and where it went are all on one row, so a receipt that says a different number from the request says why. What leaves is what arrives plus the fee, exactly, and the share that no minor unit could carry is written down rather than dropped.

Every operation carries an idempotency key the caller chose. The same key twice moves money once: the second call answers with the first one's receipt, and for an exchange that means the first one's rate and the first one's price. That key is also what makes a movement safe to send again: a transaction the engine refuses as a conflict with another one wrote nothing and is retried here, and one that committed without the caller hearing so is answered with what it did rather than repeated. A conflict that survives the attempts is ErrConcurrencyConflict.

Every transaction this package opens names its own isolation level rather than taking the engine's, because the guard on a balance is a predicate on an update and what an update sees of a row another transaction is changing is the level's answer. The engines it is run against are PostgreSQL, MySQL and SQLite, and New refuses any other.

A ledger that stops explaining the balance beside it freezes the wallet: every balance statement names the column that says so, so a wallet found to disagree stops being served at the write rather than at a flag somebody read. The difference is closed by appending the entry the ledger was missing, which leaves the balance column exactly as it was -- there is no path here that corrects a balance, because a repair that left no row behind would be the one write nobody could audit.

The files are laid out by role rather than by layer, so the whole package reads top to bottom:

module.go      -> registration, routes, handlers and migrations
config.go      -> what the application passes in
money.go       -> the amount type, its scale and its arithmetic
model.go       -> the entities, and what they may answer with
policy.go      -> who may do what
rate.go        -> the rate, its arithmetic, and the seam that quotes it
fee.go         -> the fee, its arithmetic, and the seams that price a payment
service.go     -> the rules and Model access, after authorization
views.go       -> the files the application takes ownership of

An application registers it explicitly. There is no service provider, no container and no discovery: the wiring is three lines somebody wrote, and reading them is how they learn what the application is made of.

Index

Constants

View Source
const (
	// DefaultPrefix is where the routes are mounted when Config leaves Prefix
	// empty.
	DefaultPrefix = "/wallet"
	// DefaultPageSize is how many records one page answers with when Config
	// leaves PageSize at zero.
	DefaultPageSize = 25
	// MaxPageSize is the ceiling PageSize is refused above. A page nobody
	// bounded is a page that reads the whole table on the day the table is
	// large.
	MaxPageSize = 200
)

The defaults for the optional settings. They are constants rather than literals inside Config.withDefaults, so the value a reader finds here is the value the package uses.

View Source
const (
	// WalletView is reading one wallet, balance included.
	WalletView security.Action = "wallet.view"
	// WalletList is paging through wallets.
	WalletList security.Action = "wallet.list"
	// WalletCreate is opening one.
	WalletCreate security.Action = "wallet.create"
	// WalletHistory is reading the ledger of one wallet.
	WalletHistory security.Action = "wallet.history"
	// WalletDeposit is putting money into one.
	WalletDeposit security.Action = "wallet.deposit"
	// WalletWithdraw is taking money out of one.
	WalletWithdraw security.Action = "wallet.withdraw"
	// WalletTransfer is moving money out of one and into another.
	WalletTransfer security.Action = "wallet.transfer"
	// WalletReverse is undoing an operation.
	WalletReverse security.Action = "wallet.reverse"
	// WalletConfirm is making an operation that was only recorded count.
	WalletConfirm security.Action = "wallet.confirm"
	// WalletCredit is setting how far below zero a wallet may go.
	WalletCredit security.Action = "wallet.credit"
	// WalletForce is moving money past the limit that would otherwise refuse
	// it. It is its own decision and not a field the caller is trusted with:
	// the request says that it wants the limit ignored, and this says who may
	// be answered.
	WalletForce security.Action = "wallet.force"
	// WalletPay is buying with a wallet's money.
	//
	// It is not WalletTransfer under another name. A transfer says who is paid;
	// a purchase says what for, leaves a record of it, and can be given back
	// line by line -- so an application that lets somebody move their own money
	// but not spend it in its shop has a rule to write rather than a fork to
	// maintain.
	WalletPay security.Action = "wallet.pay"
	// WalletRefund is giving back a line of a purchase. It sits beside
	// WalletReverse and not under it, for the same reason: undoing a movement
	// that has already settled is not the holder's decision.
	WalletRefund security.Action = "wallet.refund"
	// WalletPurchases is reading what a wallet has bought.
	WalletPurchases security.Action = "wallet.purchases"
	// WalletDescribe is changing what a wallet is called, what it is for, and
	// the facts the application keeps about it.
	//
	// It is separate from WalletCreate because relabelling an existing wallet
	// and opening a new one are different things to be trusted with, and it is
	// separate from every action that moves money because it moves none.
	WalletDescribe security.Action = "wallet.describe"
	// WalletClose is taking a wallet out of service, and putting it back.
	//
	// One action for both, because deciding that a wallet is no longer used and
	// deciding that it is again are the same authority over the same fact: an
	// action per direction would let somebody hold one half of it, and the half
	// they held would be the one that stops other people's money moving.
	WalletClose security.Action = "wallet.close"
	// WalletReconcile is checking that a wallet's ledger still adds up to its
	// balance, freezing it where it does not, and appending the entry that
	// closes the difference.
	//
	// It is its own decision and not a share of WalletHistory, although it
	// begins by reading the same rows: what it leaves behind is a wallet that
	// no longer moves, or a ledger with a row in it that no request produced.
	// Neither is a reading of somebody's money, and neither is theirs to do.
	WalletReconcile security.Action = "wallet.reconcile"
)

The actions of Wallet. Constants rather than strings at the call site: a typo in an action name would silently authorize nothing, or worse, everything.

They carry the entity in the name because an application registers many packages, and the name of an action shows up in logs and in audit trails where "view" on its own says nothing about what was viewed.

Money is split finer than read and write. A person who may see a balance is not thereby a person who may spend it, and the one who may spend their own is not the one who may undo somebody else's payment, raise their own overdraft limit or spend past it -- so each of those is its own decision rather than a share of one.

View Source
const (
	// TranslationGroup is the catalogue group, so every key of this package
	// reads "wallet." followed by what it names.
	TranslationGroup = "wallet"
	// FallbackLocale is the locale a line is read from when the one asked for
	// has none.
	FallbackLocale = "en"
)

What the catalogue is called and which locale is the floor under every other.

View Source
const (
	// ViewIndex is the listing of a customer's wallets.
	ViewIndex = "modules.wallet.index"
	// ViewStatement is one wallet's ledger, with the rates its exchanges were
	// made at and what its payments were charged.
	ViewStatement = "modules.wallet.statement"
	// ViewOperations is one wallet and what may be done with its money.
	ViewOperations = "modules.wallet.operations"
)

The names the screens are rendered by.

They are constants because each one is written in a handler and derived again from a path, and a page rendered by a name nothing registered is a 500 that says nothing about which of the two spellings was wrong. ViewNames derives the same set from the archive, and a test holds the two together.

View Source
const CommandPrefix = "wallet:"

CommandPrefix is what every command of this package is called under, so `aru list` groups them and two packages cannot claim one name.

View Source
const DefaultSlug = "default"

DefaultSlug is the slug of the wallet a holder has when nobody said which.

A constant rather than a rule: this package opens no wallet by itself and never falls back to one, so what this names is a convention an application can share with its own code and with anybody reading its rows. An application whose holders have exactly one wallet opens it under this and never writes the word again.

View Source
const IdempotencyHeader = "Idempotency-Key"

IdempotencyHeader is where a caller writes the name of its request.

A header rather than a body field, because the key belongs to the delivery of the request and not to what it asks for: a retry is the same body sent again, and the thing that says "this is that same request" has to be readable without parsing the body twice.

View Source
const MaxCartLines = 100

MaxCartLines is how many lines one basket may carry.

A bound rather than none, because a basket is paid in one transaction and every line of it takes a row lock on a wallet: a basket nobody bounded is a transaction that holds a shop's wallet for as long as somebody's script felt like adding to it.

View Source
const MaxDecimalPlaces = 9

MaxDecimalPlaces is the largest scale a wallet may declare.

Nine, because the amount is an int64 of minor units and the scale is what decides how much of that range is left for the major unit: at nine places the range still reaches nine billion whole units, and at ten it stops reaching a billion. A scale nobody can spend the range of is a scale that trades a real limit for digits nothing produces.

View Source
const MaxItemQuantity = 10_000

MaxItemQuantity is how many of one product a line may carry.

View Source
const MaxMetaBytes = 4096

MaxMetaBytes is the largest a movement's metadata may be once written.

A bound rather than none, because the column travels with every row of a ledger that only grows: a caller that attached a document to each of ten thousand movements would have written a document store nobody chose, inside the table a statement is paged from.

View Source
const MaxMetaKeys = 32

MaxMetaKeys is how many names one metadata value may carry.

View Source
const MaxPurchaseQuestions = 100

MaxPurchaseQuestions is how many questions one batch may carry.

View Source
const MaxPurchaseScan = 2000

MaxPurchaseScan is how many purchase rows one batch question reads.

A bound rather than none, for the reason a page has one: an unbounded read is how one call takes a production database down on the day a customer has a long history. It is stated rather than hidden because it is a real limit -- a question about a wallet with more recent purchases than this, from the wallets named beside it, is answered from what the scan reached.

View Source
const MaxRateDenominator = 1_000_000_000

MaxRateDenominator is the largest denominator a rate may be quoted with.

A billion, and the number is not arbitrary: the leftover of a conversion is recorded over a denominator of Denominator multiplied by ten to the source's scale, and both of those are bounded here and by MaxDecimalPlaces so that the product still fits in the int64 the column holds. Ten to the ninth times ten to the ninth is ten to the eighteenth, and an int64 reaches past nine of those.

It bounds the denominator and never the numerator, because bounding the numerator would bound the rate: a currency worth a hundred million of another is a rate somebody quotes, and refusing it would be refusing the pair.

View Source
const OperatorRole = "wallet.operator"

OperatorRole is the role an application grants to the people who run its money: support staff, finance, whoever is trusted to move funds that are not their own and to undo what was already done.

One role and not several. A package that shipped a hierarchy of roles would be a package deciding an application's organisation chart, and the rules below need exactly one distinction -- the holder, and somebody acting on the holder's behalf.

View Source
const PublishCommand = "aru vendor:publish --apply"

PublishCommand is what an application runs to take ownership of the views this package offers.

It is spelled out as a constant so that whatever says it -- the refusal below, or a message an application writes for its own operators -- says one thing. A person who is told two different commands for one job tries both.

The command reads the modules the application registered and writes what each one declares, which is why it is the application's command and not this package's: nothing outside the application knows which modules it holds.

Variables

View Source
var (
	// ErrCartEmpty is returned when a basket has nothing in it. A payment of
	// nothing is an operation with no entries, which is a record that says
	// something happened when nothing did.
	ErrCartEmpty = errors.New("wallet: the basket is empty, and paying for nothing is not a movement")

	// ErrCartTooLarge is returned when a basket carries more lines than
	// MaxCartLines.
	ErrCartTooLarge = fmt.Errorf("wallet: the basket carries more than %d lines", MaxCartLines)

	// ErrItemQuantity is returned when a line asks for a quantity that is not a
	// positive number within MaxItemQuantity.
	ErrItemQuantity = fmt.Errorf("wallet: a line is for between 1 and %d of a product", MaxItemQuantity)

	// ErrProductWallet is returned when a line does not say where its money
	// arrives, or names a wallet that is not this customer's.
	ErrProductWallet = errors.New("wallet: the line names no wallet for its money to arrive in")

	// ErrProductStock is returned when the application says a product cannot be
	// bought in this quantity by this buyer. It is the application's answer,
	// asked before any money moves.
	ErrProductStock = errors.New("wallet: the application refused the quantity asked for")

	// ErrPaysItself is returned when a line would move money out of a wallet and
	// back into it. It is a pair of entries that cancel, which is a statement
	// that says something happened when nothing did.
	ErrPaysItself = errors.New("wallet: a line cannot be paid to the wallet paying for it")
)

The refusals about a basket. Each is a different thing to fix: an empty basket is the caller's, a product that ran out is the application's stock, and a basket that would need a rate is the wallets it names.

View Source
var (
	// ErrFeeShare is returned when a fee is not a positive fraction smaller
	// than one. Zero would be no fee written as one, and anything from one
	// upwards would take at least the whole payment, which is not a fee on a
	// payment but the payment itself.
	ErrFeeShare = errors.New("wallet: a fee has to be a fraction of two positive integers, smaller than one")

	// ErrFeeBounds is returned when the floor and the ceiling of a fee cannot
	// both be honored: either is negative, or the floor is above the ceiling.
	ErrFeeBounds = errors.New("wallet: the smallest fee is larger than the largest, or one of them is negative")

	// ErrFeeWallet is returned when a fee has nowhere to go: no wallet named,
	// a wallet that is one of the two in the movement, or one that does not
	// exist. A fee that is taken and not credited is money that leaves the
	// wallets and arrives nowhere, which is a difference nobody can add up
	// afterwards.
	ErrFeeWallet = errors.New("wallet: a fee needs a third wallet to be credited to")

	// ErrFeeCurrencyMismatch is returned when a fee would have to cross a rate:
	// the two wallets are not counted the same way, or the wallet the fee is
	// credited to is not counted like them.
	//
	// It is refused rather than converted. The fee is a share of the payment
	// and is charged in the payment's money; carrying it through a rate would
	// round a number that is already the result of a rounding, and neither side
	// of the movement would add up afterwards.
	ErrFeeCurrencyMismatch = errors.New("wallet: a fee is charged in the money of the payment, and these wallets are not counted the same way")

	// ErrFeeExceedsAmount is returned when the fee would leave nothing to
	// deliver. A payment that arrives as zero is a payment that took money and
	// delivered none.
	ErrFeeExceedsAmount = errors.New("wallet: the fee is not smaller than the payment it is charged on")

	// ErrDiscountNegative is returned when a discount is a negative number. A
	// discount lowers what is paid; one that raised it would be a fee nobody
	// declared, charged through the field that says it is not one.
	ErrDiscountNegative = errors.New("wallet: a discount cannot be negative")
)

The refusals about a fee or a discount. Each is a different thing to fix: a malformed schedule is the provider, a destination that cannot take the money is the wiring, and an amount that a fee would consume is the request.

View Source
var (
	// ErrMetaTooLarge is returned when metadata does not fit in the column.
	ErrMetaTooLarge = fmt.Errorf("wallet: the metadata is larger than %d bytes once written", MaxMetaBytes)
	// ErrMetaTooManyKeys is returned when metadata carries more names than a
	// movement may.
	ErrMetaTooManyKeys = fmt.Errorf("wallet: the metadata carries more than %d names", MaxMetaKeys)
	// ErrMetaUnreadable is returned when the column holds something that is not
	// the object this package writes.
	ErrMetaUnreadable = fmt.Errorf("wallet: the metadata column does not hold an object of names and text")
)

The refusals about metadata. They are separate values because each is a different thing to fix: too much of it is the caller's payload, and a column that cannot be read is the row.

View Source
var (
	// ErrNotFound is returned when no row matches, including when the row
	// exists in another tenant. The two cases are deliberately
	// indistinguishable.
	ErrNotFound = errors.New("wallet: record not found")

	// ErrInsufficientFunds is returned when a withdrawal would take a balance
	// past what the wallet may hold: below zero, or below the negative of its
	// credit limit where it has one. It is the answer of the statement that
	// would have moved the money, not of a check that ran before it.
	ErrInsufficientFunds = errors.New("wallet: the balance and the credit limit are not enough for this withdrawal")

	// ErrBalanceEmpty is returned beside ErrInsufficientFunds when the wallet
	// had nothing at all: a balance of zero and no credit limit to spend
	// against. It is a different thing for a caller to do -- an empty wallet is
	// topped up, and one that is merely short is asked for a smaller amount --
	// and it is read off the row the refusing statement matched nothing on.
	//
	// The error returned wraps both, so a caller that only asks whether the
	// money was there goes on testing ErrInsufficientFunds and gets the same
	// answer it always did.
	ErrBalanceEmpty = errors.New("wallet: this wallet holds nothing and has no credit limit to spend against")

	// ErrCreditNegative is returned when a credit limit is written as a
	// negative number. The limit is how far below zero the wallet may go, so it
	// is a magnitude; a negative one would read as a balance the wallet has to
	// keep, which is a different rule nobody asked for.
	ErrCreditNegative = errors.New("wallet: a credit limit is how far below zero a wallet may go, and cannot be negative")

	// ErrCreditBelowBalance is returned when lowering a credit limit would
	// leave the wallet already past it. The refusal comes from the statement
	// that would have written it, so a balance that moved in between changes
	// the answer -- and no wallet is left in a state no rule would have
	// allowed it to reach.
	ErrCreditBelowBalance = errors.New("wallet: the balance is already further below zero than the new credit limit allows")

	// ErrCurrencyMismatch is returned when a transfer names two wallets that
	// are not counted the same way -- a different currency, or the same
	// currency at a different scale. Moving between them needs a rate, and a
	// rate comes from a RateProvider the application supplies.
	ErrCurrencyMismatch = errors.New("wallet: the two wallets are not counted the same way, which needs a rate")

	// ErrSameWallet is returned when a transfer names one wallet twice. It
	// would be a pair of entries that cancel, which is a statement that says
	// something happened when nothing did.
	ErrSameWallet = errors.New("wallet: a transfer needs two different wallets")

	// ErrAlreadyReversed is returned when an operation has already been
	// undone. The refusal comes from a unique index, so two concurrent
	// reversals of one operation cannot both succeed.
	ErrAlreadyReversed = errors.New("wallet: this operation has already been reversed")

	// ErrNotReversible is returned when the operation named is itself a
	// reversal. Undoing an undo is a new operation with its own reason, not a
	// second reversal of the same movement.
	ErrNotReversible = errors.New("wallet: a reversal cannot itself be reversed")

	// ErrNotSettled is returned when a reversal names an operation that never
	// moved anything. There is nothing to undo: what a pending operation wrote
	// is a record of what was proposed, and appending its opposite would take
	// out money that was never put in.
	ErrNotSettled = errors.New("wallet: that operation has not moved any money, so there is nothing to undo")

	// ErrNotPending is returned when a confirmation names an operation that has
	// no pending entry. Everything it wrote already counts, and confirming it
	// again would move the money a second time.
	ErrNotPending = errors.New("wallet: that operation has nothing waiting to be confirmed")

	// ErrAlreadyConfirmed is returned when an operation has already been made
	// to count. The refusal comes from a unique index, so two concurrent
	// confirmations of one operation cannot both succeed.
	ErrAlreadyConfirmed = errors.New("wallet: this operation has already been confirmed")

	// ErrWalletExists is returned when the holder already has a wallet under
	// this slug.
	ErrWalletExists = errors.New("wallet: this holder already has a wallet with that slug")

	// ErrOperationConflict is returned when an idempotency key names a request
	// that asked for something else. Two different requests under one key
	// cannot both be that key's answer, so neither is guessed at.
	ErrOperationConflict = errors.New("wallet: that idempotency key belongs to a different request")

	// ErrConcurrencyConflict is returned when the engine refused a movement as
	// a serialization failure or a deadlock, and retrying it did not get past
	// that. It is a distinct answer because it is the one the caller can act
	// on: nothing was written, the request is still valid, and sending it again
	// under the same idempotency key is safe.
	ErrConcurrencyConflict = errors.New("wallet: the engine refused this movement as a conflict with another transaction, and the retries did not clear it")

	// ErrWalletFrozen is returned when a movement names a wallet whose ledger
	// no longer explains its balance. The refusal comes from the statement that
	// would have moved the money, so a wallet frozen between the read and the
	// write is refused at the write.
	ErrWalletFrozen = errors.New("wallet: this wallet is frozen because its ledger and its balance disagree, and Rebuild is what closes that")

	// ErrWalletNotFrozen is returned when a rebuild names a wallet that is not
	// frozen. The freeze is what holds the balance and the ledger still while
	// the difference is measured and written, so a rebuild without one would be
	// an adjustment computed from numbers that can move underneath it.
	ErrWalletNotFrozen = errors.New("wallet: this wallet is not frozen, so a rebuild would adjust it by a difference that can still change")

	// ErrLedgerMoved is returned when a wallet moved while its ledger was being
	// read. What the scan concluded is about a wallet that no longer exists in
	// that state, so it is reported rather than acted on.
	ErrLedgerMoved = errors.New("wallet: the wallet moved while its ledger was being read, so the reconciliation is about a state it has left")

	// ErrLedgerBalanced is returned when a rebuild names a wallet whose ledger
	// already explains its balance. There is no difference to close, and an
	// adjustment of nothing would be a row in the ledger saying something
	// happened when nothing did.
	ErrLedgerBalanced = errors.New("wallet: this wallet's ledger already adds up to its balance, so there is nothing to adjust")

	// ErrWalletClosed is returned when a movement names a wallet somebody has
	// taken out of service. It is a different answer from ErrWalletFrozen and
	// has a different cure: a freeze is lifted by closing the difference the
	// ledger shows, and this is lifted by deciding to put the wallet back.
	ErrWalletClosed = errors.New("wallet: this wallet is closed, and money does not move in or out of a wallet that is out of service")

	// ErrWalletHoldsMoney is returned when closing a wallet that still holds a
	// balance. Closing one would leave money nobody can reach, so it is moved
	// out first and the refusal comes from the statement that would have closed
	// it.
	ErrWalletHoldsMoney = errors.New("wallet: this wallet still holds money, and closing it would leave a balance nothing can reach")

	// ErrUnsupportedDialect is returned when the handle speaks an engine this
	// package does not verify. The guard on a withdrawal is a predicate on an
	// update, and what an update sees of a row another transaction is changing
	// is the engine's answer rather than this package's -- so an engine no test
	// here runs against is an engine whose answer nobody has read.
	ErrUnsupportedDialect = errors.New("wallet: this package is verified on PostgreSQL, MySQL and SQLite, and refuses an engine its suite has never run against")
)

The refusals this package answers with. Each one is a different thing for the caller to do, which is why they are separate values rather than one error with a message.

View Source
var (
	// ErrAmountOverflow is returned when an operation would leave the range an
	// int64 of minor units can hold.
	ErrAmountOverflow = errors.New("wallet: the amount does not fit in the range of an int64 of minor units")
	// ErrAmountNotPositive is returned when a movement of money is zero or
	// negative. Direction is the operation's, never the number's: a withdrawal
	// of a negative amount is a deposit nobody authorized.
	ErrAmountNotPositive = errors.New("wallet: the amount has to be greater than zero")
	// ErrAmountScale is returned when a decimal carries more fraction digits
	// than the scale it is being read at.
	ErrAmountScale = errors.New("wallet: the amount carries more fraction digits than the scale allows")
	// ErrAmountSyntax is returned when text is not a decimal number.
	ErrAmountSyntax = errors.New("wallet: the amount is not a decimal number")
	// ErrDecimalPlaces is returned when a scale is outside 0..MaxDecimalPlaces.
	ErrDecimalPlaces = errors.New("wallet: the scale has to be between 0 and 9 places")
)

Errors this package returns about an amount. They are distinguishable because each one has a different answer: a scale error is a request to fix, an overflow is a request to split, and a non-positive amount is a request that meant something else.

View Source
var (
	// ErrAlreadyRefunded is returned when a line has already been given back.
	// The refusal comes from a unique index, so two concurrent refunds of one
	// line cannot both succeed.
	ErrAlreadyRefunded = errors.New("wallet: this line has already been refunded")

	// ErrNotRefundable is returned when the line named is itself a refund.
	// Undoing a refund is buying the thing again, with its own record, rather
	// than a second refund of the same line.
	ErrNotRefundable = errors.New("wallet: a refund cannot itself be refunded")

	// ErrPurchaseOperation is returned when a reversal names an operation that
	// bought or refunded something. A basket is undone by its lines, so that a
	// basket half of which was already given back cannot be given back whole.
	ErrPurchaseOperation = errors.New("wallet: a purchase is undone line by line, with a refund")
)

The refusals about a purchase. Each is a different thing to fix: a line that was already undone is the second attempt, and a line that cannot be undone is the one being named.

View Source
var (
	// ErrRateNotPositive is returned when a rate is not a positive fraction.
	// Zero would convert every amount to nothing and a negative one would turn
	// a credit into a debit.
	ErrRateNotPositive = errors.New("wallet: a rate has to be a fraction of two positive integers")

	// ErrRateDenominator is returned when a rate is quoted over a denominator
	// larger than MaxRateDenominator.
	ErrRateDenominator = errors.New("wallet: the rate denominator is larger than a remainder can be recorded against")

	// ErrRateNotQuoted is returned when a rate does not say when it was
	// obtained. A rate with no time cannot be reproduced, and reproducing it is
	// the whole reason it is written down.
	ErrRateNotQuoted = errors.New("wallet: the rate does not say when it was quoted")

	// ErrRatePair is returned when a rate is quoted for currencies other than
	// the two being converted between. Applying it would be applying a number
	// that means something else.
	ErrRatePair = errors.New("wallet: the rate is quoted for another pair of currencies")

	// ErrConversionUnderflow is returned when an amount is worth less than one
	// minor unit of the target. Crediting nothing while debiting something is a
	// movement that takes money and delivers none.
	ErrConversionUnderflow = errors.New("wallet: the amount is worth less than one minor unit of the target")
)

The refusals about a rate. They are separate values because each one is a different thing to fix: a pair that does not match is wiring, a fraction that is not positive is the provider, and an amount that vanishes is the request.

View Source
var (
	// ErrRatePairUnknown is returned when the provider does not quote this pair
	// at all. Nothing about waiting or retrying helps: either the pair is wrong
	// or the provider is the wrong one to ask.
	//
	// It is not ErrRatePair, which is about a rate this package was handed for
	// two other currencies -- that one is wiring inside the process.
	ErrRatePairUnknown = errors.New("wallet: the rate provider does not quote this pair of currencies")

	// ErrRateProviderUnavailable is returned when the provider could not be
	// reached or answered with a failure of its own. It is the one that is worth
	// retrying, and the one that should not be turned into a refusal a customer
	// reads as final.
	ErrRateProviderUnavailable = errors.New("wallet: the rate provider could not be reached")

	// ErrRateMomentUnsupported is returned when the provider cannot quote for
	// the moment it was asked about -- a date before its history, or one it does
	// not publish. The pair exists and the provider is up.
	ErrRateMomentUnsupported = errors.New("wallet: the rate provider has no figures for that moment")

	// ErrRateCacheFailed is returned when a provider that caches its quotes
	// could not read or write its cache. The quote may still be obtainable, so
	// it is told apart from the provider being down: what it names is the
	// provider's own storage rather than the service it fronts.
	ErrRateCacheFailed = errors.New("wallet: the rate provider could not use its cache")

	// ErrRateRequestRefused is returned when the provider refused the request
	// itself: a currency code it cannot parse, a query it does not accept.
	// It is a defect in what was asked rather than in what was answered, so it
	// is fixed in the caller and not waited out.
	ErrRateRequestRefused = errors.New("wallet: the rate provider refused the request")
)

What a rate provider could not do.

A provider lives outside this process and fails in ways that are not this package's: a pair nobody quotes, a service that is down, a moment it has no figures for. Those used to travel out as whatever the provider wrote, so an application had to match on a sentence to tell "this pair does not exist" from "try again in a minute" -- and the two are opposite instructions to whoever is waiting.

The values are declared here and not where a provider is written, and that is the whole point of them: a caller tests them with errors.Is against this package, which it already imports, and never has to import the provider it happens to be wired to. A provider wraps the one that fits and adds its own sentence; one that wraps none is not wrong, and what it returns travels out unclassified rather than being guessed at.

There are five because there are five different things to do about them. Their shape is the reference's, which distinguishes the same failures; what is not carried over is its split between a failure and the runtime wrapper around the same failure, which is one distinction with no different answer.

Functions

func Charges added in v0.2.0

func Charges(db *data.DB) *model.Model[Charge]

Charges returns the configured model for the charges table.

func Commands added in v0.2.0

func Commands(deps Deps) ([]console.Command, error)

Commands builds every command of this package against one set of dependencies, so an application registers the group in a single call rather than naming each command and threading the same values through all of them.

It returns an error rather than panicking, for the reason New does: everything it refuses is a wiring mistake, and a wiring mistake found where the console is assembled costs one restart.

func Conversions added in v0.2.0

func Conversions(db *data.DB) *model.Model[Conversion]

Conversions returns the configured model for the conversions table.

func Entries

func Entries(db *data.DB) *model.Model[Entry]

Entries returns the configured model for the entries table.

func Lines added in v0.2.0

func Lines(locale string) translation.Lines

Lines are the sentences this package ships for one locale, keyed the way a translator asks for them, and nil for a locale it does not ship.

It is exported so an application can load them into its own translator, look at what there is to override, or check a locale of its own against them.

func Locales added in v0.2.0

func Locales() []string

Locales are the locales this package ships sentences for, sorted.

An application reads it to know which of its own it has to write, and a test reads it to check that none of them is missing a line the others have.

There are two, and the number is a decision rather than a stage something is at. A sentence on a money screen has to be right in a way a label on a dashboard does not -- "Withdraw", "Credit limit" and "Frozen" are the words somebody reads before deciding whether their money is safe -- and a locale this package cannot have checked by somebody who reads it is a locale that says something nearly right, in a screen where nearly is wrong. What an application that needs a third one does is write it in its own catalogue under these keys, where the person who signs off on the wording is the person who runs the product; its translator is asked before this one, so it needs no release here and no fork of this file.

func Operations

func Operations(db *data.DB) *model.Model[Operation]

Operations returns the configured model for the operations table.

func PublishedPaths

func PublishedPaths() []string

PublishedPaths are where this package's views are written, each relative to the root of the application, sorted.

They are the destination rather than the archive, so that what the module refuses at boot and what a project ends up holding are one list.

func Purchases added in v0.2.0

func Purchases(db *data.DB) *model.Model[Purchase]

Purchases returns the configured model for the purchases table.

func Slugify added in v0.4.0

func Slugify(slug, name string) string

Slugify returns the slug to use, deriving one from a name where none was given.

A slug already written is returned untouched. It is the caller's key -- it is under the unique index that says which of a holder's wallets this is, and folding one somebody wrote would silently rename a wallet they meant to open under a name they chose.

Deriving is a fold and never a translation: letters and digits are kept and lowered, everything else becomes a single hyphen, and the ends are trimmed. Only ASCII letters are lowered, so a name in a script with no case comes through as its own runes rather than through a table this package would have to keep. What derives to nothing comes back empty, and the caller refuses it -- a wallet named by punctuation would be a wallet whose slug nobody chose.

func ValidDecimalPlaces

func ValidDecimalPlaces(places int) bool

ValidDecimalPlaces reports whether a scale can be used.

func ViewNames

func ViewNames() []string

ViewNames are the names the published views are rendered by, sorted.

The name is the path a view is written at, below the project's view directory, with its separators turned into dots -- which is what the view compiler writes into the registration call. It is derived from the same archive the publication carries, so a view that was renamed cannot keep an old name here.

func ViewPackages

func ViewPackages() []string

ViewPackages are the directories the compiled views land in, each relative to the root of the application, sorted and without repeats.

Importing one is what puts its views in the binary: a compiled view calls the registry from init(), and a package nothing imports is not linked at all. The import is named rather than written into bootstrap/app.go, because one line somebody reads beats a file that changed while they were not looking.

func Wallets

func Wallets(db *data.DB) *model.Model[Wallet]

Wallets returns the configured model for the wallets table.

The primary key is application-generated text, so it does not increment. The tenant scope remains on the model's tenant_id default.

Types

type Amount

type Amount int64

Amount is a quantity of money in the minor units of the scale it belongs to.

An integer, never a float: 0.1 has no binary representation, so a float sum of ten of them is not one, and money that does not add up is a defect that only shows on the statement. The scale that says how many minor units make a major one is not part of this type -- it belongs to the wallet, which is the only place a currency and its scale are decided -- so an Amount alone is a count and Money is what a person reads.

Arithmetic is through Add and Sub rather than + and -, because an int64 that overflows wraps silently and a balance that wrapped is a balance that changed sign.

func ParseAmount

func ParseAmount(text string, places int) (Amount, error)

ParseAmount reads a decimal written for a scale of places into minor units.

It is the border, and the only one: everything inside this package is minor units. "10.50" at two places is 1050, and "10.5" is 1050 as well -- a missing digit is a zero, which is what the notation means.

Nothing is rounded. "10.505" at two places is refused with ErrAmountScale rather than turned into 1050 or 1051, because a cent that disappears into a rounding rule the caller did not choose is a cent nobody can find again. A caller who wants rounding does it before this, where the rule is theirs.

func (Amount) Add

func (a Amount) Add(b Amount) (Amount, error)

Add returns a + b, or ErrAmountOverflow.

func (Amount) Ceiling

func (a Amount) Ceiling() Amount

Ceiling is the largest balance that can still take a without overflowing.

It is what a guarded credit compares the balance against, inside the statement that performs the credit: the check then runs against the value the row holds at that moment rather than against one read a moment earlier.

func (Amount) Format

func (a Amount) Format(places int) string

Format writes the amount as a decimal at this scale.

The inverse of ParseAmount, digit for digit: what Format writes, ParseAmount reads back to the same Amount at the same scale.

func (*Amount) Scan

func (a *Amount) Scan(value any) error

Scan reads the amount back, and refuses anything that is not an integer.

A float64 here means the column is not an integer one -- a table built by hand, a migration somebody edited, an engine that widened the type. Reading it would be the one place a binary float touches money, so it is the one place that says no.

func (Amount) Sub

func (a Amount) Sub(b Amount) (Amount, error)

Sub returns a - b, or ErrAmountOverflow.

It is written out rather than expressed as a.Add(-b), and the reason is the one value that has no negative: -MinInt64 does not fit in an int64, so the shorthand had to refuse every subtraction of MinInt64 to avoid computing it. Two of those are representable and were being refused --

-1 - MinInt64 = MaxInt64
MinInt64 - MinInt64 = 0

-- which is a money primitive answering "does not fit" about results that do.

Overflow is detected the way Add detects it, on the result: it happened when the operands differ in sign and the difference does not agree with a. The three that really overflow -- 0 - MinInt64, MaxInt64 - (-1) and MinInt64 - 1 -- still answer ErrAmountOverflow.

func (Amount) Times added in v0.2.0

func (a Amount) Times(count int) (Amount, error)

Times returns a multiplied by a count, or ErrAmountOverflow.

A count and not another amount: money times money is not money, and the only place a quantity multiplies a price is a line of a basket. A count that is not positive is refused rather than read as nothing, because a line for none of something is a line somebody meant differently.

The product is computed as a wider integer and narrowed once, so a result the range cannot hold is reported rather than wrapped -- an int64 that overflows wraps silently, and a price that wrapped has changed sign.

func (Amount) Value

func (a Amount) Value() (driver.Value, error)

Value writes the amount as the integer the column holds.

Declared rather than left to the driver's reflection so that there is one answer to "what reaches the database", and it is an int64.

type Cart added in v0.2.0

type Cart struct {
	// contains filtered or unexported fields
}

Cart is what is being bought.

It is immutable, and every method that adds to it answers with a new basket. A basket that could be changed after it was priced is a basket where the receipt and the payment are about different things -- and the value handed to Pay would be one the caller could still be holding a reference to.

func NewCart added in v0.2.0

func NewCart(items ...CartItem) Cart

NewCart is a basket of these lines.

func (Cart) Items added in v0.2.0

func (c Cart) Items() []CartItem

Items are the lines, in the order they were added.

func (Cart) Lines added in v0.2.0

func (c Cart) Lines() int

Lines is how many lines the basket has.

func (Cart) Meta added in v0.2.0

func (c Cart) Meta() Meta

Meta is what the application attached to the basket as a whole.

func (Cart) Quantity added in v0.2.0

func (c Cart) Quantity() int

Quantity is how many items the basket holds, counting the quantity of each line.

func (Cart) Validate added in v0.2.0

func (c Cart) Validate() validation.Errors

Validate reports what the basket cannot be paid as.

It reads the lines and never the wallets: whether a wallet exists, whose it is and what it is counted in are questions for the service, after the policy has answered.

func (Cart) With added in v0.2.0

func (c Cart) With(items ...CartItem) Cart

With is this basket and these lines, as a new basket.

func (Cart) WithMeta added in v0.2.0

func (c Cart) WithMeta(meta Meta) Cart

WithMeta is this basket carrying these facts, as a new basket.

type CartItem added in v0.2.0

type CartItem struct {
	// Product is what is being bought.
	Product Product

	// Quantity is how many of it. Zero is read as one, which is what a line
	// somebody wrote without a number means.
	Quantity int

	// PricePerItem overrides what the product answers, as a decimal at the
	// payer's own scale.
	//
	// Empty is the ordinary line, which asks the product. It is text rather than
	// an amount because that is how every amount arrives at this package's
	// border: a caller writing "10.50" does not have to know the scale, and one
	// writing 1050 does.
	PricePerItem string

	// ReceiverWalletID overrides where the money arrives. Empty is the
	// product's own wallet, which is the ordinary line.
	ReceiverWalletID string

	// BeneficiaryWalletID is whose purchase this is, when it is not the payer's.
	//
	// Empty is the ordinary line: the payer buys for themselves. Anything else
	// is a gift -- the money still leaves the payer and still arrives at the
	// receiver, and what changes is who the record says bought it, which is what
	// "has this person already got one" is asked about afterwards.
	BeneficiaryWalletID string

	// Meta is what the application attaches to this line.
	Meta Meta
}

CartItem is one line of a basket.

type Charge added in v0.2.0

type Charge struct {
	model.Model[Charge]

	// ID is the identifier, generated by the application.
	ID string `db:"id"`

	// TenantID is the customer the row belongs to, written from the Grant.
	TenantID string `db:"tenant_id"`

	// OperationID is the operation that charged. It is unique per tenant: one
	// operation charges once, and a second row against it would be a second
	// answer to what a payment cost.
	OperationID string `db:"operation_id"`

	// Currency is the money every amount below is counted in, and
	// DecimalPlaces its scale. Both are copied onto the row rather than read
	// back off a wallet, because a wallet is a live record and a charge is a
	// fact about a moment.
	Currency      Currency `db:"currency"`
	DecimalPlaces int      `db:"decimal_places"`

	// RequestedAmount is what the caller asked to move, before anything was
	// taken off.
	RequestedAmount Amount `db:"requested_amount"`

	// Discount is what the payer was charged less. Zero where nothing was.
	Discount Amount `db:"discount"`

	// BaseAmount is what the fee was computed from: the requested amount less
	// the discount. It is written rather than left to be derived, because it is
	// the number the fraction below was applied to and a row that has to be
	// recomputed before it can be checked is a row that is checked less often.
	BaseAmount Amount `db:"base_amount"`

	// FeeNumerator and FeeDenominator are the exact fraction the fee was, and
	// FeeMinimum and FeeMaximum the bounds it was held between. All four are
	// recorded even where a bound decided the fee, because which of them
	// decided is the first question anybody asks about a fee that is not the
	// share of the payment.
	FeeNumerator   int64  `db:"fee_numerator"`
	FeeDenominator int64  `db:"fee_denominator"`
	FeeMinimum     Amount `db:"fee_minimum"`
	FeeMaximum     Amount `db:"fee_maximum"`

	// FeeDeductible says who paid it: false is the payer, on top of the
	// payment, and true is the receiver, out of what arrived.
	FeeDeductible Flag `db:"fee_deductible"`

	// FeeAmount is what was actually charged, and FeeWalletID the wallet it was
	// credited to. The ledger has the same numbers as entries under this
	// operation, so the row and the movement agree by construction.
	FeeAmount   Amount `db:"fee_amount"`
	FeeWalletID string `db:"fee_wallet_id"`

	// Rounding is the rule that produced FeeAmount from the exact share.
	Rounding Rounding `db:"rounding"`

	// RemainderNumerator is what truncating the share left behind, over
	// RemainderDenominator. It is zero where a bound decided the fee, because
	// then nothing was truncated.
	RemainderNumerator   int64 `db:"remainder_numerator"`
	RemainderDenominator int64 `db:"remainder_denominator"`

	// CreatedAt is when the charge was written, in UTC.
	CreatedAt time.Time `db:"created_at"`
}

Charge is what one operation charged beyond the money it moved: the discount that lowered the payment and the fee that was taken out of it.

It exists so that a receipt saying a different number from the request can say why, and say it in numbers rather than in a note. Every input is on the row -- what was asked for, what was taken off, what the fee was computed from, the exact fraction, both bounds, who paid it and where it went -- so the whole calculation can be done again from the row alone, by somebody who has neither the provider that answered nor the code that called it.

One row per operation, held by a unique index, and only an operation that discounted or charged has one. It is appended and never changed, for the reason the ledger and the recorded rates are: a fee that could be corrected in place is a fee that says what somebody later wished it had been.

Every amount on it is in one money, named by Currency and DecimalPlaces: the money the payment left in. A fee is refused where that money is not also the money it arrives in, so there is no rate anywhere in this row.

func (Charge) Base added in v0.2.0

func (c Charge) Base() Money

Base is what the fee was computed from, read at the scale it was counted at.

func (Charge) Exact added in v0.2.0

func (c Charge) Exact() bool

Exact reports that the share divided evenly, or that a bound decided the fee and there was nothing to divide.

func (Charge) Fee added in v0.2.0

func (c Charge) Fee() Money

Fee is what was charged, read at the scale it was counted at.

func (Charge) Requested added in v0.2.0

func (c Charge) Requested() Money

Requested is what the caller asked to move, read at the scale it was counted at.

func (Charge) Schedule added in v0.2.0

func (c Charge) Schedule() FeeSchedule

Schedule is the fee this charge was made under, as it was quoted.

type ChargeResource added in v0.2.0

type ChargeResource struct {
	// contains filtered or unexported fields
}

ChargeResource is the list of fields one charge may answer with.

Every number the arithmetic used is here, for the reason the conversion's are: a client that wants to check what it was charged multiplies the base by the numerator and finds the fee times the denominator plus the remainder, or reads the bound that decided instead. Nothing it needs for that is missing and nothing it needs is somewhere else.

func NewChargeResource added in v0.2.0

func NewChargeResource(record Charge) ChargeResource

NewChargeResource snapshots one charge for the response.

func (ChargeResource) ToArray added in v0.2.0

func (r ChargeResource) ToArray() map[string]any

ToArray returns the fields that may leave, by name.

Each amount leaves twice, as the integer the ledger is kept in and as the decimal a person reads, which is the same arrangement a balance has and for the same reason.

func (ChargeResource) With added in v0.2.0

func (r ChargeResource) With() map[string]any

With returns what goes beside the fields, and nothing does.

type ChargeRow added in v0.2.0

type ChargeRow struct {
	// Operation is the payment it belongs to.
	Operation string
	// Requested is what the caller asked to move, Discount what the payer was
	// charged less, Base what the fee was computed from and Fee what was taken.
	Requested string
	Discount  string
	Base      string
	Fee       string
	// FeeWallet is where the fee went, and Deductible says who paid it.
	FeeWallet  string
	Deductible bool
}

ChargeRow is what one operation charged beyond the money it moved, as a statement draws it.

type CloseRequest added in v0.4.0

type CloseRequest struct {
	// WalletID is the wallet being closed.
	WalletID string
	// Reason is why it was closed. It is required and it is carried on the
	// event rather than on the row: what this package keeps about a wallet is
	// what decides about its money, and why somebody stopped using it is the
	// application's record to write where it writes the rest of its history.
	Reason string
}

CloseRequest is what taking a wallet out of service takes.

func (CloseRequest) Validate added in v0.4.0

func (r CloseRequest) Validate() validation.Errors

Validate reports the errors per field.

type Collection

type Collection struct {
	// contains filtered or unexported fields
}

Collection is a page of wallets as one response.

func NewCollection

func NewCollection(records []Wallet, cursor string) Collection

NewCollection wraps a page of wallets for the response. The cursor is what the next request passes back, and is empty when there is no next page.

func (Collection) ToArray

func (c Collection) ToArray() map[string]any

ToArray returns the page under a single key, so that the shape of the response does not change when a second key is added beside it.

func (Collection) With

func (c Collection) With() map[string]any

With returns the cursor of the next page, and nothing when there is none.

type Config

type Config struct {
	// Tenant is the customer a visitor with no session is read as.
	//
	// It is required, and it comes from the application's own configuration --
	// never from the request. A tenant a visitor could name is a visitor who
	// chooses whose rows they read. Everywhere there is a session, the tenant
	// comes from the Grant instead, and this value is not consulted at all.
	Tenant string

	// Prefix is the path the routes are mounted under. Empty means
	// DefaultPrefix.
	Prefix string

	// PageSize is how many records one page answers with. Zero means
	// DefaultPageSize, and anything above MaxPageSize is refused rather than
	// clamped: a number somebody wrote and did not get is worse than a number
	// somebody wrote and was told about.
	PageSize int

	// Rates quotes the rate between two currencies, for the transfers that
	// cross wallets which are not counted the same way.
	//
	// Nil is the ordinary case and not a degraded one: an application whose
	// wallets all hold one currency at one scale never needs a rate, and a
	// transfer that would need one is refused with ErrCurrencyMismatch rather
	// than approximated. A rate comes from outside the process, which is why
	// this is the application's to supply and not this package's to fetch.
	//
	// What the provider supplies is the rate and not the converted amount.
	// Applying it, rounding it and recording it belong to this package, so that
	// every exchange in the application is rounded the same way and leaves the
	// same row behind whatever the provider is.
	Rates RateProvider

	// Fees answers with what a wallet charges to be paid, for the payments
	// between two wallets where somebody charges anything.
	//
	// Nil is the ordinary case and not a degraded one: most applications charge
	// nothing, and one that does knows its own pricing. What the provider
	// supplies is the schedule and not the fee, for the reason Rates supplies a
	// rate and not the converted amount -- the arithmetic, the rounding and the
	// record belong here, so every fee in the application is computed the same
	// way and leaves the same row behind.
	Fees FeeProvider

	// Discounts answers with what one payer is charged less on one payment.
	//
	// Nil is no discount anywhere. What it decides is the application's: who
	// gets one and why is a question about customers, which this package has
	// no way to answer and no business answering.
	Discounts DiscountProvider

	// CSRF issues the token every form on these screens carries.
	//
	// It is required, because every screen here writes: a page rendered without
	// a token is a page whose buttons the application refuses, and finding that
	// out from a form that does nothing is worse than finding it out at boot.
	CSRF *security.CSRF

	// Translator is the application's own catalogue, asked before the one this
	// package ships.
	//
	// It is optional. Nothing is asked of it when it is nil, and the screens are
	// drawn in the locales this package carries -- which is what an application
	// that renders in one language would have got anyway.
	Translator *translation.Translator

	// Listeners are told what the money did, after it did it.
	//
	// Empty is the ordinary case. Each one is called once the write has
	// committed, in the goroutine that made it, so what a listener is told is
	// what happened -- a movement that was rolled back is never announced, and
	// there is no message that would take an announcement back.
	//
	// There is no dispatcher here and no queue. What an application does with an
	// event is the application's, and one that wants the work off the request
	// hands it to whatever it already uses; a queue in this package would be a
	// second one beside the application's, with its own failures to learn.
	Listeners []Listener
}

Config is what the application passes when it wires this package.

A typed struct rather than a map: a misspelled key in a map is a setting that silently keeps its default, and the failure shows up as behaviour nobody asked for rather than as an error. Here a field that does not exist does not compile.

func (Config) Validate

func (c Config) Validate() error

Validate reports what the configuration cannot be used with.

It is called by New, so an application with a setting that cannot work fails where it is wired rather than on the first request that needed it.

type ConfirmRequest added in v0.2.0

type ConfirmRequest struct {
	// IdempotencyKey is the caller's name for this request. It is the
	// confirmation's own key, and never the key of the operation being
	// confirmed.
	IdempotencyKey string
	// OperationID is the operation to make count.
	OperationID string
	// Force asks for the movement even where the balance and the credit limit
	// do not cover it, and is answered by WalletForce on every wallet the
	// operation touches.
	Force bool
	// Meta is what the application attaches to the confirmation.
	Meta Meta
}

ConfirmRequest is what settling a pending operation takes.

func (ConfirmRequest) Validate added in v0.2.0

func (r ConfirmRequest) Validate() validation.Errors

Validate reports the errors per field.

type Conversion added in v0.2.0

type Conversion struct {
	model.Model[Conversion]

	// ID is the identifier, generated by the application.
	ID string `db:"id"`

	// TenantID is the customer the row belongs to, written from the Grant.
	TenantID string `db:"tenant_id"`

	// OperationID is the operation that converted. It is unique per tenant:
	// one operation applies one rate, and a second row against it would be a
	// second answer to what an exchange was worth.
	OperationID string `db:"operation_id"`

	// FromCurrency is what left, and FromDecimalPlaces is the scale it was
	// counted at. Both are copied onto the row rather than read back off the
	// wallet, because a wallet is a live record and a conversion is a fact
	// about a moment.
	FromCurrency      Currency `db:"from_currency"`
	FromDecimalPlaces int      `db:"from_decimal_places"`

	// FromAmount is how much left, in FromCurrency's minor units.
	FromAmount Amount `db:"from_amount"`

	// ToCurrency is what arrived, and ToDecimalPlaces is the scale it was
	// counted at.
	ToCurrency      Currency `db:"to_currency"`
	ToDecimalPlaces int      `db:"to_decimal_places"`

	// ToAmount is how much arrived, in ToCurrency's minor units. It is the
	// number the ledger was credited with, so the row and the entry agree by
	// construction.
	ToAmount Amount `db:"to_amount"`

	// RateNumerator and RateDenominator are the exact fraction the rate was.
	// Two integers rather than a decimal, because a rate that does not divide
	// evenly -- a third, a seventh -- has no decimal spelling that is still the
	// rate.
	RateNumerator   int64 `db:"rate_numerator"`
	RateDenominator int64 `db:"rate_denominator"`

	// QuotedAt is when the rate was obtained, in UTC. It is the provider's
	// answer and not the time of the write: a rate quoted at open and applied
	// at noon is a rate from the open.
	QuotedAt time.Time `db:"quoted_at"`

	// Rounding is the rule that produced ToAmount from the exact value.
	Rounding Rounding `db:"rounding"`

	// RemainderNumerator is what rounding left behind, over
	// RemainderDenominator, and it is smaller than one minor unit of the
	// target. It is written down because a value that is dropped without a
	// record is a value nobody can add up afterwards.
	RemainderNumerator int64 `db:"remainder_numerator"`

	// RemainderDenominator is what the remainder is a fraction of.
	RemainderDenominator int64 `db:"remainder_denominator"`

	// CreatedAt is when the conversion was written, in UTC.
	CreatedAt time.Time `db:"created_at"`
}

Conversion is the rate one operation applied, recorded as it was applied.

It exists so that an exchange can be reproduced rather than believed. The row carries both sides in full -- each currency, each scale, each amount -- the exact fraction the rate was, the moment it was quoted, the rule the result was rounded under, and the part of the value no minor unit could carry. From those numbers alone the arithmetic can be done again years later, by somebody who has neither the rate provider that answered nor the code that called it.

One row per operation, held by a unique index, and only an exchange has one. It is appended and never changed, for the reason the ledger is: a rate that could be corrected in place is a rate that says what somebody later wished it had been.

func (Conversion) Exact added in v0.2.0

func (c Conversion) Exact() bool

Exact reports that the rate divided evenly and nothing was left over.

func (Conversion) From added in v0.2.0

func (c Conversion) From() Money

From is the money that left, read at the scale it was counted at.

func (Conversion) Rate added in v0.2.0

func (c Conversion) Rate() Rate

Rate is the rate this conversion was made at, as it was quoted.

func (Conversion) To added in v0.2.0

func (c Conversion) To() Money

To is the money that arrived, read at the scale it was counted at.

type ConversionResource added in v0.2.0

type ConversionResource struct {
	// contains filtered or unexported fields
}

ConversionResource is the list of fields one conversion may answer with.

Every number the arithmetic used is here, and that is the point: a client that wants to check what it was charged multiplies the amount that left by the numerator and by ten to the target's scale, and finds the amount that arrived times the denominator times ten to the source's scale, plus the remainder. Nothing it needs for that is missing and nothing it needs is somewhere else.

func NewConversionResource added in v0.2.0

func NewConversionResource(record Conversion) ConversionResource

NewConversionResource snapshots one conversion for the response.

func (ConversionResource) ToArray added in v0.2.0

func (r ConversionResource) ToArray() map[string]any

ToArray returns the fields that may leave, by name.

The rate leaves twice, as the two integers and as the pair written out. A client that recomputes reads the integers, one that prints reads the string, and neither has to derive the other's form -- which is the same reason a balance leaves as both an integer and a decimal.

func (ConversionResource) With added in v0.2.0

func (r ConversionResource) With() map[string]any

With returns what goes beside the fields, and nothing does.

type ConversionRow added in v0.2.0

type ConversionRow struct {
	// Operation is the exchange it belongs to.
	Operation string
	// From and To are the two sides, each with its own currency and scale.
	From string
	To   string
	// Rate is the exact fraction it was made at, and QuotedAt when that
	// fraction was obtained.
	Rate     string
	QuotedAt string
	// Exact says the rate divided evenly, and Remainder is what was left over
	// when it did not.
	Exact     bool
	Remainder string
}

ConversionRow is the rate one operation applied, as a statement draws it.

type Converted added in v0.2.0

type Converted struct {
	// Money is what arrives, already at the target's currency and scale.
	Money Money
	// RemainderNumerator is the leftover, over RemainderDenominator, and it is
	// always smaller than it.
	RemainderNumerator int64
	// RemainderDenominator is what the leftover is a fraction of: one minor
	// unit of the target.
	RemainderDenominator int64
}

Converted is what a rate makes of an amount: the money that arrives, and the part of it that no minor unit could carry.

The remainder is a fraction of one minor unit of the target, and it is what makes the arithmetic checkable from the outside: the money that arrived, multiplied back through the denominator, plus the remainder, is exactly the money that left multiplied through the numerator. Nothing is lost that the value does not say the size of.

func (Converted) Exact added in v0.2.0

func (c Converted) Exact() bool

Exact reports that the conversion divided evenly and nothing was left over.

type CreditRequest added in v0.2.0

type CreditRequest struct {
	// WalletID is the wallet whose limit is being set.
	WalletID string
	// Limit is how far below zero the wallet may go, as a decimal at its own
	// scale, and "0" is a wallet that may not go below zero at all.
	//
	// A magnitude and never a negative number: the sign belongs to the rule,
	// which is that the balance may not end below the negative of this. A limit
	// written with a minus is refused rather than read as its own opposite.
	Limit string
}

CreditRequest is what setting a wallet's credit limit takes.

func (CreditRequest) Validate added in v0.2.0

func (r CreditRequest) Validate() validation.Errors

Validate reports the errors per field.

type Currency

type Currency string

Currency is the unit an amount is counted in, as an ISO 4217 code.

A string rather than an enumeration: the set is not this package's to close, and a wallet holding a currency this package has never heard of is a wallet that works.

type DepositRequest

type DepositRequest struct {
	// IdempotencyKey is the caller's name for this request. Sending the same
	// key twice moves money once.
	IdempotencyKey string
	// WalletID is the wallet to credit.
	WalletID string
	// Amount is the decimal to credit, written at the wallet's own scale.
	//
	// A decimal and not an integer of minor units, because the caller does not
	// have to know the scale to write "10.50" and does have to know it to write
	// 1050. A value with more fraction digits than the wallet's scale is
	// refused rather than rounded.
	Amount string
	// Pending records the movement without letting it count.
	//
	// The entry is written, the balance is not moved, and the money arrives
	// when somebody confirms the operation. False is the ordinary request and
	// the one a client that never heard of this field sends: what it asks for
	// happens, once, now.
	Pending bool
	// Meta is what the application attaches to this request: its own facts
	// about what the money was for. A movement with one leg carries them on the
	// operation, because there they are the request's.
	Meta Meta
}

DepositRequest is what putting money into a wallet takes.

func (DepositRequest) Validate

func (r DepositRequest) Validate() validation.Errors

Validate reports the errors per field.

type Deps added in v0.2.0

type Deps struct {
	// Service is the same service the routes call. One of it, holding one
	// database handle, so a balance read from a terminal and a balance read
	// through a request are the same rows decided by the same policy.
	Service *WalletService

	// Operator says who a command runs as, for the customer it names.
	//
	// It is the application's decision. A command has no session to read a
	// subject from, and a subject this package invented would be one no policy
	// of the application ever agreed to -- so the application writes the
	// function, and what it returns is what the policy is asked about.
	Operator func(tenant string) security.Subject
}

Deps is what the commands need, built by the application where it wires everything else.

It is a struct and not a list of parameters because the set grows: a constructor per command, each threading the same two values, is the same wiring written four times and corrected in three of them.

func (Deps) Validate added in v0.2.0

func (d Deps) Validate() error

Validate reports what the dependencies cannot be used with.

type DescribeRequest added in v0.4.0

type DescribeRequest struct {
	// WalletID is the wallet being relabelled.
	WalletID string
	// Name is what a person calls it. It is required, because a wallet with no
	// name is a row in a list nobody can pick out.
	Name string
	// Description is what a person is told it is for, and empty clears it.
	Description string
	// Meta is what the application attaches to the wallet, and it replaces what
	// was there rather than merging into it: a partial write would make
	// "remove this name" impossible to express.
	Meta Meta
}

DescribeRequest is what changing a wallet's labels takes.

Labels and nothing else. The slug, the currency and the scale are absent and have to be: the first names which of a holder's wallets this is and is under a unique index, and the other two decide what every amount already written means. A wallet whose scale changed would be a wallet whose whole ledger silently moved a decimal point.

func (DescribeRequest) Validate added in v0.4.0

func (r DescribeRequest) Validate() validation.Errors

Validate reports the errors per field.

type DiscountProvider added in v0.2.0

type DiscountProvider interface {
	// Discount returns how much to take off this payment, in the payer's minor
	// units. Zero is the ordinary answer, and a negative one is refused.
	Discount(ctx context.Context, g security.Grant, payer, receiver Wallet, payment Money) (Amount, error)
}

DiscountProvider answers with what one payer is charged less.

It is the seam for the part of pricing that is about who is paying rather than about what is being paid for: a negotiated rate, a first payment, a loyalty that an application tracks and this package has never heard of. Both wallets are named because a discount belongs to the pair, and the payment is there because a discount can depend on the size of it.

What comes back lowers the payment: the payer is debited less and the receiver is credited less, which is what a discount is. It is recorded on the operation, so a receipt that says a smaller number than the request asked for says why.

type Entry

type Entry struct {
	model.Model[Entry]

	// ID is the identifier, generated by the application.
	ID string `db:"id"`

	// TenantID is the customer the row belongs to, written from the Grant.
	TenantID string `db:"tenant_id"`

	// OperationID is the request this movement was part of. A transfer writes
	// two entries under one operation.
	OperationID string `db:"operation_id"`

	// WalletID is the wallet that moved.
	WalletID string `db:"wallet_id"`

	// Kind is the direction.
	Kind EntryKind `db:"kind"`

	// Sequence is this entry's position in its wallet's ledger, counting from
	// one and never repeating. It is what a statement is ordered and paged by,
	// and a unique index holds it: two entries at one position would be a
	// ledger that lost a write without saying so.
	Sequence int64 `db:"sequence"`

	// Position is this entry's place within its own operation, counting from
	// zero. A transfer writes the wallet that pays at zero and the wallet that
	// is paid at one, so a receipt reads the same way when it is produced and
	// when it is replayed.
	Position int `db:"position"`

	// Amount is how much moved, in the wallet's minor units, and it is always
	// positive: the direction is Kind's to carry, so that a sum over the
	// history cannot be made to mean the opposite of what it says by a sign
	// somebody wrote into an amount.
	Amount Amount `db:"amount"`

	// BalanceAfter is what the wallet held once this movement was applied.
	//
	// Written because a statement a person reads is a running balance, and
	// recomputing one means replaying every earlier row. It is also what makes
	// the ledger check itself: the last entry of a wallet has to equal the
	// wallet's balance column. A pending entry moved nothing, so it records the
	// balance it did not change, and that identity holds down the whole page.
	BalanceAfter Amount `db:"balance_after"`

	// Settled reports that this movement counted: that the balance moved by
	// exactly this amount, in this direction, when the row was written.
	//
	// A pending entry is false, and it is the record of money that was proposed
	// and has not moved. It is written once like every other column here and is
	// never flipped afterwards -- confirming appends the settled entry beside
	// it, under an operation that names the one it settles, so a wallet's
	// history says what was asked for and then what happened rather than
	// showing only the second of the two.
	Settled Flag `db:"settled"`

	// Meta is what the application attached to this leg in particular, and it
	// is empty where the leg said nothing the operation did not.
	//
	// A movement with one leg carries its facts on the operation, because there
	// they are the request's. A movement with two or twenty carries them here as
	// well, because a basket's line and the payment it is part of are different
	// facts and a receipt that showed one for the other would be a receipt about
	// something else.
	Meta Meta `db:"meta"`

	// CreatedAt is when the movement was written, in UTC.
	CreatedAt time.Time `db:"created_at"`
}

Entry is one movement of money on one wallet, and the ledger is the sum of them.

Rows are appended and never changed. A movement that turned out to be wrong is undone by appending the opposite movement under an operation that names the one it reverses, so what happened stays readable after it is undone -- which a status column that is rewritten in place cannot do.

func (Entry) Signed

func (e Entry) Signed() Amount

Signed is the movement as it adds to a balance: positive for a deposit, negative for a withdrawal, and zero where it has not settled.

It is how a sum over a history is taken, and it is a method rather than a column so that the sign exists in exactly one place. The zero is the same decision: a pending entry adds nothing to a balance, so the sum of this over a wallet's whole history is the balance column, exactly as it was before anything could be pending. What the amount of a pending row means is what was proposed, and Amount is where that is read.

type EntryCollection

type EntryCollection struct {
	// contains filtered or unexported fields
}

EntryCollection is a page of ledger entries as one response.

func NewEntryCollection

func NewEntryCollection(statement Statement, cursor string) EntryCollection

NewEntryCollection wraps a page of one wallet's ledger for the response.

It takes the statement rather than the entries, because an entry is not readable on its own: the scale it is written at belongs to the wallet, and what it was part of belongs to the operation. Both travel in the statement, so this is one argument instead of three that could disagree.

The rates and the charges go beside the items and not inside them. Each belongs to an operation, an operation writes an entry on each of two or three wallets, and repeating one on every entry would be repeating one fact until two copies of it could differ.

func (EntryCollection) ToArray

func (c EntryCollection) ToArray() map[string]any

ToArray returns the page under a single key.

func (EntryCollection) With

func (c EntryCollection) With() map[string]any

With returns the cursor of the next page, the rates the page's exchanges were made at and what its payments were charged, and omits any of them when there is none.

type EntryKind

type EntryKind string

EntryKind is the direction of one movement.

const (
	// EntryDeposit raised the balance.
	EntryDeposit EntryKind = "deposit"
	// EntryWithdraw lowered it.
	EntryWithdraw EntryKind = "withdraw"
)

The two directions money moves on a wallet. They are read from the wallet's side: a deposit raises the balance and a withdrawal lowers it, whether the operation around them was a transfer, a reversal or a deposit of its own.

type EntryResource

type EntryResource struct {
	// contains filtered or unexported fields
}

EntryResource is the list of fields one ledger entry may answer with.

func NewEntryResource

func NewEntryResource(record Entry, decimalPlaces int, kind OperationKind) EntryResource

NewEntryResource snapshots one entry for the response, read at the scale of the wallet it moved and under the operation that wrote it.

The operation's kind travels with the movement because the movement alone does not say what happened: a withdrawal is a withdrawal whether it paid somebody, was undone, or was converted into another currency, and a statement where those read the same is a statement that hides the exchange inside the transfers. An empty kind is an entry whose operation was not loaded, which is what a caller building one resource by hand has.

func (EntryResource) ToArray

func (r EntryResource) ToArray() map[string]any

ToArray returns the fields that may leave, by name.

Whether the movement settled leaves with it, because the amount alone does not say whether it counted: a client that added up the amounts of a page and found the balance would be a client that was right until the first pending row.

func (EntryResource) With

func (r EntryResource) With() map[string]any

With returns what goes beside the fields, and nothing does.

type EntryRow added in v0.2.0

type EntryRow struct {
	// ID is the ledger row, and Sequence its position in this wallet's history.
	ID       string
	Sequence string
	// Operation is the request it was part of, and OperationLabel what a person
	// reads in place of the kind of that request.
	Operation      string
	OperationLabel string
	// Direction is what a person reads in place of "in" or "out", and Incoming
	// says which of the two it is so a screen can colour it without comparing
	// text.
	Direction string
	Incoming  bool
	// Amount is how much moved and BalanceAfter what the wallet held once it
	// had, both as decimals at the wallet's scale.
	Amount       string
	BalanceAfter string
	// Settled says the movement counted. A row that did not is what was
	// proposed, and a statement that drew it the same way would be a statement
	// nobody could add up.
	Settled bool
	// Created is when it was written.
	Created string
}

EntryRow is one movement as a statement draws it.

type Event added in v0.2.0

type Event struct {
	// Kind is what happened.
	Kind EventKind
	// At is when, in UTC.
	At time.Time
	// Tenant is the customer it happened in. It comes from the Grant, like
	// every other tenant here.
	Tenant string
	// ActorID is who did it, off the Grant the policy issued rather than off
	// the request: it is the subject the rules agreed to and not the one
	// somebody claimed to be.
	ActorID string

	// WalletID is the wallet it happened to, and Currency and DecimalPlaces are
	// how its money is counted -- so a listener can render an amount without a
	// second read.
	WalletID      string
	Currency      Currency
	DecimalPlaces int

	// OperationID is the request the movement was part of, and OperationKind
	// what that request was. Both are empty on an event about a wallet rather
	// than about money.
	OperationID   string
	OperationKind OperationKind

	// EntryID is the ledger row, and EntryKind its direction. Both are empty on
	// an event about a wallet.
	EntryID   string
	EntryKind EntryKind

	// Amount is how much moved, always positive: the direction is EntryKind's
	// to carry. On a wallet that was opened it is zero, and on a credit limit
	// that changed it is the new limit.
	Amount Amount
	// Balance is what the wallet held afterwards.
	Balance Amount

	// Meta is what the application attached to the movement this event is
	// about.
	Meta Meta
}

Event is one thing that happened, told to whoever asked to be told.

It carries what a listener needs to write a line somebody can read a year later: who did it, whose money it was, how much, and what it left behind. It does not carry the record, because a record handed to a listener is a record a listener can save -- and a write nobody authorized is exactly what this package exists to make impossible.

type EventKind added in v0.2.0

type EventKind string

EventKind names what happened.

The set is closed and the values are constants, for the reason the actions are: a listener switches on one of these, and a kind assembled at run time is a branch nobody can find by reading the code.

const (
	// WalletOpened is a wallet that now exists.
	WalletOpened EventKind = "wallet.opened"
	// WalletCreditChanged is a change to how far below zero a wallet may go.
	WalletCreditChanged EventKind = "wallet.credit_changed"
	// WalletWasClosed is a wallet somebody took out of service. Its money is
	// zero, because that is what closing required, and its ledger is exactly as
	// readable as it was.
	WalletWasClosed EventKind = "wallet.closed"
	// WalletWasReopened is a wallet somebody put back in service.
	WalletWasReopened EventKind = "wallet.reopened"
	// MoneyMoved is one movement that counted: a balance changed by exactly the
	// amount on the event, in the direction on it.
	MoneyMoved EventKind = "wallet.money_moved"
	// MoneyProposed is one movement that was recorded and did not count. The
	// balance on it is the balance the movement did not change, and the money
	// arrives when somebody confirms the operation.
	MoneyProposed EventKind = "wallet.money_proposed"
	// MoneyAdjusted is the entry that closed a difference between a wallet's
	// ledger and the balance beside it. No balance changed: the amount is what
	// the ledger was missing, and the balance is the number it now explains.
	//
	// It is its own kind because a listener that read it as money moving would
	// tell somebody their balance had changed when nothing of theirs did, and
	// because this is the one event worth an alert -- a wallet reaching it has
	// been frozen, and something wrote a balance no request accounts for.
	MoneyAdjusted EventKind = "wallet.money_adjusted"
)

type Fee added in v0.2.0

type Fee struct {
	// Amount is the fee, in the payment's minor units.
	Amount Amount
	// RemainderNumerator is what truncating the share left, over
	// RemainderDenominator, and it is always smaller than it.
	RemainderNumerator int64
	// RemainderDenominator is what the remainder is a fraction of: one minor
	// unit of the payment.
	RemainderDenominator int64
}

Fee is what a schedule takes out of one payment.

The remainder is what truncating the share left behind, over the schedule's denominator, and it is what makes the arithmetic checkable from outside: the payment multiplied by the numerator is the fee multiplied by the denominator plus the remainder, exactly. Where a bound decided the fee instead of the share, nothing was truncated and the remainder is zero -- the row carries the bounds as well, so which of the two happened is readable rather than guessed.

func (Fee) Exact added in v0.2.0

func (f Fee) Exact() bool

Exact reports that the share divided evenly, or that a bound decided the fee and there was nothing to divide.

type FeeProvider added in v0.2.0

type FeeProvider interface {
	// Fee returns what the receiver charges to be paid this amount.
	//
	// The zero FeeSchedule is no fee, and is the ordinary answer. An error is
	// a provider that could not decide, and refuses the payment rather than
	// letting it through free.
	Fee(ctx context.Context, g security.Grant, receiver Wallet, payment Money) (FeeSchedule, error)
}

FeeProvider answers with what a wallet charges to be paid.

It is the seam and not an implementation, for the reason RateProvider is one: what a merchant charges is the application's business, it changes without this package being rebuilt, and a package that decided it would be deciding somebody's pricing. The schedule is asked for once per payment and written down as it was answered, so a provider that answers differently a moment later does not change what a receipt already said.

The wallet it is asked about is the one being paid, because a fee is charged by whoever receives the money. The payment is what would arrive before the fee, so a schedule can be decided by size -- which is what a floor and a ceiling are for.

The Grant is first because a schedule can be a tenant's own, and a provider that cannot tell whose fee it is asked for is a provider that answers with somebody else's.

type FeeSchedule added in v0.2.0

type FeeSchedule struct {
	// Numerator and Denominator are the share of the payment the fee is, as an
	// exact fraction smaller than one.
	Numerator   int64
	Denominator int64

	// Minimum is the smallest fee that may be charged, in the payment's minor
	// units, and zero is no floor. A share that comes out under it is raised to
	// it: a fee that rounds to nothing on a small payment is a fee that costs
	// more to move than it collects.
	Minimum Amount

	// Maximum is the largest fee that may be charged, and zero is no ceiling.
	Maximum Amount

	// Deductible reverses who pays. False is the ordinary payment, where the
	// fee is added to what the payer sends and the receiver is paid in full;
	// true takes the fee out of what arrives, and the payer sends exactly what
	// they were asked for.
	Deductible bool

	// WalletID is the wallet the fee is credited to. It is required, and it is
	// neither of the two wallets in the movement: a fee that goes back to the
	// payer or to the receiver is not a fee, it is a smaller payment.
	WalletID string
}

FeeSchedule is what a wallet charges to be paid: a share of the payment, with a floor and a ceiling, and the wallet the money goes to.

The share is a fraction of two integers and never a percentage in a float, for the reason a rate is: 2.9% has no binary representation, so a schedule held as a float already charges something other than what somebody wrote down. The bounds are amounts in the money the payment is made in, and the destination is a wallet counted the same way -- which together mean the whole calculation happens in one money, at one scale, with no rate anywhere in it.

The zero value charges nothing, and that is the ordinary case. Anything else has to be complete: a schedule with a floor and no destination, or a share and no denominator, is a mistake somebody made rather than a fee somebody meant, and Validate says which.

func (FeeSchedule) Charges added in v0.2.0

func (f FeeSchedule) Charges() bool

Charges reports that this schedule means to charge anything at all.

Any field set is a schedule, and the zero value is not one. A half-filled schedule is therefore a mistake Validate reports rather than a fee that quietly comes out as nothing.

func (FeeSchedule) Fee added in v0.2.0

func (f FeeSchedule) Fee(payment Money) (Fee, error)

Fee is what this schedule takes out of the payment.

The arithmetic is the exchange's, exactly: integers throughout, one division, truncated toward zero, and what truncation left is a value rather than a difference. The share of a payment is never rounded up, because a fee rounded up is money charged that no schedule justifies.

The floor and the ceiling are applied after the share and in that order, so a schedule whose ceiling is under its floor cannot be built -- Validate refuses it -- and the two can never disagree about one payment.

func (FeeSchedule) Validate added in v0.2.0

func (f FeeSchedule) Validate() error

Validate reports why this schedule cannot be applied, and nil when it can.

type Flag added in v0.2.0

type Flag bool

Flag is a yes-or-no column held as the integer 0 or 1, and it carries how that is written and how it is read back.

The column is an integer, and a Go bool is not what a driver accepts for one: a driver that has been told its parameter is a small integer refuses a bool outright, and one told the opposite refuses an integer. So the Go value spells its own column -- which is the same arrangement Amount has with the column that holds money, and for the same reason: a value that says what it writes cannot be spelled differently by an engine.

Reading is wider than writing on purpose. A yes-or-no comes back as an integer, a boolean, the text of either or the bytes of any of them depending on the engine and on how the table was created, and this is the one place that is flattened.

func (*Flag) Scan added in v0.2.0

func (f *Flag) Scan(value any) error

Scan reads the flag back from whatever an engine answers with.

The engines disagree, and this is where that is flattened: an integer, a boolean, the text of either, and the bytes of any of them all mean the same thing. Anything else is refused rather than read as false, because a column this package cannot read is a column whose meaning it would be inventing.

func (Flag) Value added in v0.2.0

func (f Flag) Value() (driver.Value, error)

Value writes the flag as the integer the column holds.

type FormState added in v0.2.0

type FormState struct{ view.Page }

FormState is what a kyse input asks for its message and for what was typed.

It exists because the component library asks for FieldError and the page the framework carries answers First. One adapter, in one place, rather than the same three lines on every screen -- and it is a type rather than a method on each page so that a screen added later cannot forget to write it.

func (FormState) FieldError added in v0.2.0

func (f FormState) FieldError(name string) string

FieldError is the first message for an input, and empty for an input nothing rejected.

type HistoryRequest

type HistoryRequest struct {
	// WalletID is the wallet whose entries are read.
	WalletID string
	// Query is the page and the ordering. The ledger is ordered by when it was
	// written and by nothing else, so Sort is not read here: a statement in
	// another order is a statement that does not add up as you read down it.
	Query data.Query
}

HistoryRequest is what reading a wallet's ledger takes.

type IndexPageData added in v0.2.0

type IndexPageData struct {
	view.Page

	// Prefix is where this module answers, so the markup composes its own
	// addresses instead of hard-coding one the configuration can change.
	Prefix string
	// Labels are the sentences this screen draws, resolved for the locale the
	// request asked for.
	Labels Labels
	// Holder is the holder the listing was narrowed to, echoed back into the
	// field so the box still says what is being looked at.
	Holder string
	// Rows are the wallets, and Next is the cursor of the following page, empty
	// on the last one.
	Rows []WalletRow
	Next string
}

IndexPageData is what the listing screen is handed.

func (IndexPageData) Form added in v0.2.0

func (d IndexPageData) Form() FormState

Form is the state the inputs of this screen read.

type Labels added in v0.2.0

type Labels struct {
	// Locale is what these were resolved for.
	Locale string
	// contains filtered or unexported fields
}

Labels is what one screen reads, resolved for the locale the request asked for.

It is filled by the handler and handed to the view, which is the whole of why there is no helper a template calls for itself. A view that reached for a translator would be a view that can be rendered outside a request, in whatever locale the process happened to be left in -- and the failure looks like one person's page coming back in somebody else's language.

The zero value answers every key with the key, which is what a screen drawn by something that forgot to fill it in should look like: obviously unfinished, rather than quietly English.

func (Labels) Entry added in v0.2.0

func (l Labels) Entry(kind EntryKind) string

Entry is what a person reads in place of the direction of a movement.

func (Labels) Operation added in v0.2.0

func (l Labels) Operation(kind OperationKind) string

Operation is what a person reads in place of the kind of an operation.

The kinds are this package's own and closed, so every one of them has a line; a kind with none reads as itself, which is a word somebody can search for rather than a blank in a statement.

func (Labels) Purchase added in v0.2.0

func (l Labels) Purchase(kind PurchaseKind) string

Purchase is what a person reads in place of the kind of a purchased line.

func (Labels) T added in v0.2.0

func (l Labels) T(key string) string

T is the sentence at this key, which is written without the group: T("field.balance") reads "wallet.field.balance".

A key with no line anywhere comes back as itself. It is the one answer that cannot be mistaken for a translation, which is what somebody staring at a screen needs in order to find the missing line.

type Leg added in v0.2.0

type Leg struct {
	// Meta is what the application attaches to this side in particular, and it
	// is empty where this side says nothing the payment does not.
	Meta Meta
	// Pending records this side without letting it count. The entry is written,
	// the balance is not moved, and it moves when somebody confirms the
	// operation.
	Pending bool
}

Leg is what one side of a movement carries.

It is a value on the request rather than a second method beside the one that moves the money, for the reason Force is a field rather than a ForceTransfer: two entry points for one movement are two places every later rule has to be written into, and the one somebody forgets is the one that is not guarded.

type LimitedProduct added in v0.2.0

type LimitedProduct interface {
	Product

	// CanBuy reports why this buyer may not take this many, and nil when they
	// may.
	//
	// It reports the reason rather than a yes or no, because "out of stock",
	// "one per customer" and "not sold in your country" are three different
	// things to tell somebody, and this package has no way to tell them apart
	// from a false.
	CanBuy(ctx context.Context, g security.Grant, buyer Wallet, quantity int) error
}

LimitedProduct is a product the application keeps a stock of.

It is asked before any money moves, so a basket with one line the application refuses leaves nothing written: the refusal happens where the basket is read and not halfway through paying for it.

It is a second interface rather than a method on the first, so a catalogue with nothing to run out of implements nothing extra -- and a product that does keep stock is recognised by what it answers rather than by a flag somebody remembered to set.

type ListRequest

type ListRequest struct {
	// Query is the page and the ordering.
	Query data.Query
	// HolderID narrows the page to one holder. It is a filter and not a
	// permission: a subject who is not an operator has it replaced by their own
	// identifier, so what they asked for cannot widen what they get.
	HolderID string
}

ListRequest is what paging through wallets takes.

type Listener added in v0.2.0

type Listener func(context.Context, Event)

Listener is something told what happened.

It is called after the write has committed, in the goroutine that made it, and what it does is on the path of the request that caused it. A listener that talks to something slow makes the screen slow; one that has to do that hands the work to a queue and returns.

It returns nothing, and that is the contract rather than an omission. The write is already durable by the time it is called, so there is no failure a listener could report that anything could still act on -- and an error that travelled back to the caller would report a write that succeeded as one that did not.

type Meta added in v0.2.0

type Meta map[string]string

Meta is what the application attaches to a movement: its own facts about what the money was for.

Names to text, and never to numbers or nested values. A JSON number read back in Go is a float64, and a float is the one thing this package keeps away from money -- so an amount, a rate or a quantity written here would come back as something that no longer adds up, in the row that exists to explain a movement. An application with a structure to attach writes it as text under one name, where it is the application's to parse and this package's only to carry.

Nothing here is read by this package. It is not indexed, not searched and not compared: it is what a receipt shows and what an export carries, and every decision about the money is made from the columns beside it.

func (Meta) Names added in v0.2.0

func (m Meta) Names() []string

Names are the names this metadata carries, sorted.

Sorted because a screen and an export both read them in order, and a Go map has none: two renderings of one row would otherwise differ in the order of their own lines.

func (*Meta) Scan added in v0.2.0

func (m *Meta) Scan(value any) error

Scan reads the metadata back from whatever an engine answers with.

An empty column is no metadata rather than an empty object, because that is what every row written before this column existed holds, and a movement nobody attached anything to is not a movement with an empty note on it.

func (Meta) Validate added in v0.2.0

func (m Meta) Validate() error

Validate reports why this metadata cannot be stored, and nil when it can.

func (Meta) Value added in v0.2.0

func (m Meta) Value() (driver.Value, error)

Value writes the metadata as the text the column holds, and the empty string where there is none.

Text rather than a document type: the engines spell one differently and only some of them have it, and nothing in this package reads what is inside. A column an application wants to query is a column it adds to a table of its own, against rows it owns.

type Module

type Module struct {
	// contains filtered or unexported fields
}

Module is what the application registers.

It implements foundation.Module, which is Name and Routes and nothing else -- that pair is the whole public contract between a package and the framework.

It also implements foundation.Migratable, because it owns tables, and foundation.Publishable, because it hands view sources to the project. The other optional interfaces are declared beside Module in the framework and are opted into the same way, by implementing them: Bootable to prepare state at boot, Background to run a loop of its own, Schedulable to declare work for the scheduler, Health to report on the storage it depends on, Closable to give resources back at shutdown.

func New

func New(cfg Config, db *data.DB, sessions *security.SessionStore) (*Module, error)

New returns the module, or the reason it cannot be built.

The collaborators are parameters and not fields somebody fills in afterwards: a module that could be registered half-wired is a module whose first request is the thing that reports the missing half.

It returns an error rather than panicking or carrying on, because everything it refuses is a wiring mistake, and a wiring mistake found at boot costs one restart. The same mistake found later is a request that reached a nil handle.

func (*Module) Boot

func (m *Module) Boot(context.Context) error

Boot refuses to serve when a view this package renders is not in the binary.

A compiled view registers itself from init(), so by the time anything boots the question has one answer already: either the application published the files, compiled them and imported the package they became, or it did not. Asking here turns "did anybody run the install command" into one refusal at start-up that names the views and the command, instead of a 500 on the first request that reached one of them -- which is where it used to be answered, once per page, to whoever happened to open it.

It also holds the destination. Every file this package offers has to land under the vendor directory named after this module: a publication that reached resources/views/home.kyse.go would land on a page the application wrote, and what publishes the files writes where the publication says.

func (*Module) Labels added in v0.2.0

func (m *Module) Labels(locale string) Labels

Labels are the sentences of this module's screens in one locale.

It is exported because it is what a handler outside this package needs in order to draw the same words -- an application that wraps a screen of its own around these, or replaces one, reads its labels from here rather than writing a second copy of them.

An empty locale is answered in the shipped one. A request that went through no negotiation middleware carries none, and a screen in the wrong language is still a screen somebody can read, where a refusal would not be.

func (*Module) Migrations

func (m *Module) Migrations() []foundation.Migration

Migrations declares the schema this module owns.

They are returned in the order their names sort in, which is the order they apply in: the name carries the order, and nothing else decides it.

func (*Module) Name

func (m *Module) Name() string

Name is the module identifier: a lowercase slug, stable, no spaces.

It is what `aru route:list` groups by and what the route names are prefixed with, so changing it changes addresses that other code has already written down.

func (*Module) Publishes

func (m *Module) Publishes() []foundation.Publication

Publishes declares the files this package offers, each at the path it takes relative to the root of the project.

One tree, under one tag. A publication may carry a page, a component, a configuration file, a migration, a catalogue of sentences or an asset, and this package offers the first of those and nothing else. Each absence is a decision rather than an omission:

  • configuration is the Config struct New is handed, checked by the compiler and validated before the module exists. A file copied into the project beside it would be a second place to say the same thing, and only one of the two could be the one the code reads.
  • a stylesheet, a script or any other asset is registered with the view layer and served from an address derived from its own bytes. Copying one into the project would put a second copy of those bytes under a second address, and a page can only reference one of them.
  • translations are overridden by writing the lines the application wants into its own vendor tree, which the catalogue loader already reads. A copy of every line this package ships is a copy that goes stale, and it goes stale without saying so.
  • migrations are declared and collected, never copied. A copy in the project's own migration directory is found by the runner as well, so one schema change applies twice under two names.

What is left is the markup, and it is here for the reason the others are not: it is the one thing the project is expected to edit. A package cannot know what a screen should say in a product it has never seen.

func (*Module) Routes

func (m *Module) Routes(r *fhttp.Router)

Routes registers the module's routes under the configured prefix.

They are named, so a URL is built from a name rather than written out a second time somewhere else -- two spellings of one address disagree, and the failure when they do is a link to a 404.

Reading and moving money are different addresses and different methods. A deposit is a POST to a collection of deposits rather than a PATCH of a balance, because the thing being created is the movement: it has an identifier, it is in the ledger afterwards, and it can be undone by name. The method and the address of each one come from routePatterns, which is also the list Config.Validate proves the configured prefix can carry. Only the handler is written here, so the two cannot describe different sets of routes.

func (*Module) Service added in v0.2.0

func (m *Module) Service() *WalletService

Service is the use cases this module holds, for the code an application writes beside the routes.

One of it, holding one database handle, so a balance moved from a command, a checkout of the application's own and a request are the same rows decided by the same policy. It is exported because paying for a basket has no route here: a basket names products, a product is the application's type, and the handler that owns the catalogue is the one that calls Pay.

It is the service and never the handle. What comes back still authorizes before it reaches a table, which is the difference between handing out a use case and handing out the database.

type Money

type Money struct {
	// Amount is the quantity, in minor units of DecimalPlaces.
	Amount Amount
	// Currency is what the quantity counts.
	Currency Currency
	// DecimalPlaces is how many minor units make one major unit.
	DecimalPlaces int
}

Money is an amount together with what makes it readable: the currency it is counted in and the scale its minor units are at.

It exists for the borders -- what a request carried, what a response says, what a rate provider is asked about -- and never for arithmetic inside a wallet, where the scale is the wallet's own and repeating it on every value would be a second place for it to be wrong.

func (Money) String

func (m Money) String() string

String writes the money as the decimal and the currency, separated by a space.

type OpenRequest

type OpenRequest struct {
	// HolderID is whose wallet this will be.
	HolderID string
	// Slug names which of the holder's wallets it is.
	//
	// Empty is derived from Name, so an application that has one word for a
	// wallet writes it once. What it derives to is Slugify's answer, and a name
	// that derives to nothing -- punctuation, or a script this package cannot
	// fold -- is refused rather than turned into a slug nobody chose.
	Slug string
	// Name is what a person will call it.
	Name string
	// Description is what a person is told it is for, and it may be empty.
	Description string
	// Meta is what the application attaches to the wallet itself: facts that
	// are true of the wallet rather than of any one movement.
	Meta Meta
	// Currency is what its balance will count.
	Currency Currency
	// DecimalPlaces is the scale of that currency's minor unit, and it is fixed
	// once the wallet exists.
	DecimalPlaces int
}

OpenRequest is what opening a wallet takes.

The fields are explicit and there is no mass assignment, so a request body cannot write a column nobody meant to expose. There is no TenantID here and there must never be one: the tenant comes from the Grant, which comes from the session. There is no Balance either -- a wallet opens empty, and money enters it through an operation that leaves a row in the ledger.

func (OpenRequest) Validate

func (r OpenRequest) Validate() validation.Errors

Validate reports the errors per field.

type Operation

type Operation struct {
	model.Model[Operation]

	// ID is the identifier, generated by the application.
	ID string `db:"id"`

	// TenantID is the customer the row belongs to, written from the Grant.
	TenantID string `db:"tenant_id"`

	// IdempotencyKey is what the caller sent to name this request. It is
	// unique per tenant, so the same key is the same operation whatever it was
	// asked to do.
	IdempotencyKey string `db:"idempotency_key"`

	// Kind is what the operation was asked to do.
	Kind OperationKind `db:"kind"`

	// SettlesID is the operation this one settles.
	//
	// A reversal holds the id of the operation it undoes and a confirmation
	// the id of the one it makes count. Everything else holds its own id,
	// which is what lets one unique index over the kind and this column carry
	// two rules at once: an operation is reversed at most once and confirmed at
	// most once, and a row that settles nothing collides with nothing -- not
	// even with the reversal or the confirmation naming it, because those
	// differ from it in kind.
	//
	// It is stored in the column named reverses_id, which is the name the
	// column was created under. Read it with Reverses and Confirms, each of
	// which answers with the empty string where this row is not that.
	SettlesID string `db:"reverses_id"`

	// Reason is what the caller said about a reversal, and is empty on
	// everything else.
	Reason string `db:"reason"`

	// Meta is what the application attached to the request as a whole: its own
	// facts about what this money was for. Nothing in this package reads it.
	Meta Meta `db:"meta"`

	// CreatedAt is when the operation was recorded, in UTC.
	CreatedAt time.Time `db:"created_at"`
}

Operation is one request that moved money, recorded before the money moves.

It is what makes a retry safe. The row carries the caller's idempotency key under a unique index, so a second request with the same key cannot insert a second operation -- the database refuses it, rather than a read deciding that it probably has not run yet.

func (Operation) Confirms added in v0.2.0

func (o Operation) Confirms() string

Confirms is the operation this one made count, and the empty string when it confirms nothing.

func (Operation) Reverses

func (o Operation) Reverses() string

Reverses is the operation this one undoes, and the empty string when it undoes nothing.

type OperationKind

type OperationKind string

OperationKind is what an operation did.

const (
	// OperationDeposit put money into one wallet.
	OperationDeposit OperationKind = "deposit"
	// OperationWithdraw took money out of one wallet.
	OperationWithdraw OperationKind = "withdraw"
	// OperationTransfer moved money between two wallets counted the same way,
	// so the number that left is the number that arrived.
	OperationTransfer OperationKind = "transfer"
	// OperationExchange moved money between two wallets that are not counted
	// the same way, so a rate decided what arrived. It is its own kind and not
	// a transfer with a note on it, because a statement that cannot tell the
	// two apart is a statement where a rate was applied and nothing says so.
	// An operation of this kind has a Conversion, and no other kind has one.
	OperationExchange OperationKind = "exchange"
	// OperationReversal undid an earlier operation.
	OperationReversal OperationKind = "reversal"
	// OperationConfirmation settled an operation that was recorded without
	// moving anything. It is its own operation and not a change to the one it
	// settles, because the ledger is appended to and never rewritten: what was
	// proposed stays on the record exactly as it was proposed, and what
	// happened is the row beside it.
	OperationConfirmation OperationKind = "confirmation"
	// OperationPurchase paid for a basket: one request, one transaction, and a
	// movement for every line of it. It is its own kind and not a run of
	// transfers, because what was bought is a fact about the request and a
	// statement that could not tell the two apart is a statement where a basket
	// reads as a person paying a shop six times in one second.
	OperationPurchase OperationKind = "purchase"
	// OperationRefund gave back some of the lines of a purchase. It is how a
	// basket is undone -- line by line, so that a basket half of which was
	// already given back cannot be given back whole.
	OperationRefund OperationKind = "refund"
	// OperationAdjustment closed a difference between a wallet's ledger and the
	// balance column beside it. It moves no balance: it appends the settled
	// entry the ledger was missing, so the sum of the entries reaches the
	// number the column already held. It is its own kind because a statement
	// that could not tell it apart from a deposit would be a statement where a
	// correction reads as money arriving.
	OperationAdjustment OperationKind = "adjustment"
)

The kinds an operation can be. An operation is one request that moved money, and the kind is what it was asked to do rather than what its entries look like: a transfer and a reversal of a deposit both write a withdrawal, and telling them apart afterwards is what a statement is for.

type OperationsPageData added in v0.2.0

type OperationsPageData struct {
	view.Page

	Prefix string
	Labels Labels
	// Wallet is the one being operated on.
	Wallet WalletRow
	// Purchases are the newest lines it bought, so that the identifier a refund
	// names is on the screen the refund is asked from.
	Purchases []PurchaseRow
	// MaySetCredit says whether the person reading may change how far below
	// zero this wallet goes. It is answered by the policy before the page is
	// drawn, so a control nobody may use is not drawn at all -- a button that
	// answers 403 is a button that teaches somebody the page is broken.
	MaySetCredit bool
}

OperationsPageData is what the screen that moves one wallet's money is handed.

func (OperationsPageData) Form added in v0.2.0

func (d OperationsPageData) Form() FormState

Form is the state the inputs of this screen read.

type PayRequest added in v0.2.0

type PayRequest struct {
	// IdempotencyKey is the caller's name for this request. Sending the same
	// key twice pays once.
	IdempotencyKey string
	// PayerWalletID is the wallet the money leaves. Every line of the basket
	// is paid from it, and every price is read at its scale.
	PayerWalletID string
	// Cart is what is being bought.
	Cart Cart
	// Force asks for the payment even where the balance and the credit limit do
	// not cover it, and is answered by WalletForce on the wallet paying.
	Force bool
}

PayRequest is what paying for a basket takes.

There is no pending mode here, and its absence is a decision. A movement is recorded without counting so that somebody can say later whether it happened; a basket that has not been paid for is a basket, and what an application wants held is the delivery, which is a transfer whose two sides settle apart. A second half-paid state, with lines that are on the record and money that is not, would be a second answer to what "has this been bought" means.

func (PayRequest) Validate added in v0.2.0

func (r PayRequest) Validate() validation.Errors

Validate reports the errors per field.

type Product added in v0.2.0

type Product interface {
	// ProductKey names this product on the record, and it is the application's
	// own identifier rather than anything derived from a Go type.
	//
	// A type name changes when its package is renamed, moved or vendored, and
	// none of those changes touch the rows already stored: every purchase would
	// go on naming a product nothing answers to, and nothing would say so.
	ProductKey() string

	// ReceiverWalletID is the wallet the money for this product arrives in.
	ReceiverWalletID() string

	// Price is what one of this product costs this buyer, in the buyer's minor
	// units.
	//
	// The buyer is passed because a price can be about who is buying -- a
	// wholesale rate, a member's price, a currency the catalogue is kept in.
	// The Grant is passed for the reason every seam here takes one: a price can
	// be a tenant's own, and a provider that cannot tell whose price it is asked
	// for is a provider that answers with somebody else's.
	Price(ctx context.Context, g security.Grant, buyer Wallet) (Amount, error)
}

Product is what an application sells.

It is an interface this package declares and never implements, for the reason RateProvider is one: what a product is, what it costs and how many of it are left are the application's questions, and a package that answered any of them would be a package deciding somebody's catalogue. What this package owns is the money -- it debits, it credits, and it records who bought what from whom.

The three answers are all it needs. A key that names the product on the record, a wallet for the money to arrive in, and a price for this buyer.

type Purchase added in v0.2.0

type Purchase struct {
	model.Model[Purchase]

	// ID is the identifier, generated by the application.
	ID string `db:"id"`

	// TenantID is the customer the row belongs to, written from the Grant.
	TenantID string `db:"tenant_id"`

	// OperationID is the operation that paid for this line, and Position is
	// where the line sat in the basket, counting from zero.
	OperationID string `db:"operation_id"`
	Position    int    `db:"position"`

	// PayerWalletID is the wallet the money left.
	PayerWalletID string `db:"payer_wallet_id"`

	// OwnerWalletID is whose purchase this is. It is the payer on an ordinary
	// line and the beneficiary on a gift, which is the whole of what a gift
	// changes: the money is still the payer's and the thing bought is not.
	OwnerWalletID string `db:"owner_wallet_id"`

	// ReceiverWalletID is the wallet the money arrived in.
	ReceiverWalletID string `db:"receiver_wallet_id"`

	// ProductKey is what was bought, as the application names it.
	ProductKey string `db:"product_key"`

	// Quantity is how many of it.
	Quantity int `db:"quantity"`

	// Currency is the money every amount below is counted in, and DecimalPlaces
	// its scale. Both are copied onto the row rather than read back off a
	// wallet, because a wallet is a live record and a purchase is a fact about a
	// moment.
	Currency      Currency `db:"currency"`
	DecimalPlaces int      `db:"decimal_places"`

	// PricePerItem is what one of the product cost, and RequestedAmount the
	// price multiplied by the quantity, before anything was taken off.
	PricePerItem    Amount `db:"price_per_item"`
	RequestedAmount Amount `db:"requested_amount"`

	// Discount is what the payer was charged less on this line, and BaseAmount
	// what the fee was computed from: the requested amount less the discount.
	Discount   Amount `db:"discount"`
	BaseAmount Amount `db:"base_amount"`

	// FeeNumerator and FeeDenominator are the exact fraction the fee was, and
	// FeeMinimum and FeeMaximum the bounds it was held between. All four are
	// recorded even where a bound decided the fee, because which of them decided
	// is the first question anybody asks about a fee that is not the share of
	// the payment.
	FeeNumerator   int64  `db:"fee_numerator"`
	FeeDenominator int64  `db:"fee_denominator"`
	FeeMinimum     Amount `db:"fee_minimum"`
	FeeMaximum     Amount `db:"fee_maximum"`

	// FeeDeductible says who paid it: false is the payer, on top of the line,
	// and true is the receiver, out of what arrived.
	FeeDeductible Flag `db:"fee_deductible"`

	// FeeAmount is what was actually charged, and FeeWalletID the wallet it was
	// credited to.
	FeeAmount   Amount `db:"fee_amount"`
	FeeWalletID string `db:"fee_wallet_id"`

	// Rounding is the rule that produced FeeAmount from the exact share, and
	// RemainderNumerator what truncating it left, over RemainderDenominator.
	Rounding             Rounding `db:"rounding"`
	RemainderNumerator   int64    `db:"remainder_numerator"`
	RemainderDenominator int64    `db:"remainder_denominator"`

	// PaidAmount is what left the payer for this line, and CreditedAmount what
	// reached the receiver. They differ by the fee when the payer paid it, and
	// are equal when the receiver did.
	//
	// Both are written rather than derived, because they are what a refund moves
	// back: a line given back reads its own row and returns exactly what left
	// and exactly what arrived, without recomputing a fee that a schedule might
	// answer differently today.
	PaidAmount     Amount `db:"paid_amount"`
	CreditedAmount Amount `db:"credited_amount"`

	// Kind is what this row records.
	Kind PurchaseKind `db:"kind"`

	// SettlesID is the line this row settles: the purchase a refund undoes, and
	// its own identifier where it settles nothing.
	//
	// With the kind beside it in a unique index, that one column carries the
	// whole rule that a line is refunded at most once -- and a row that settles
	// nothing collides with nothing, not even with the refund naming it, because
	// the two differ in kind.
	SettlesID string `db:"settles_id"`

	// Sequence is the position, in the owner's ledger, of the movement this row
	// came from.
	//
	// It is what a purchase is ordered by, and it is a number this package
	// writes rather than a timestamp: two purchases inside one tick of the clock
	// are two rows an ORDER BY over time cannot tell apart, and "the most recent
	// one" is exactly the question asked here.
	Sequence int64 `db:"sequence"`

	// CreatedAt is when the line was written, in UTC.
	CreatedAt time.Time `db:"created_at"`
}

Purchase is one line of a basket, recorded as it was paid.

It is the projection of "who bought what from whom", and it is what "has this already been bought" reads: the ledger says money moved between two wallets and would have to be read with the catalogue beside it to say what for, which is a question every application asks and none of them should have to assemble.

It is also the receipt of that line. Every number the arithmetic used is on it -- the price, the quantity, what was taken off, what the fee was computed from, the exact fraction, both bounds, what left the payer and what reached the receiver -- so the line can be recomputed from the row alone, by somebody who has neither the catalogue that priced it nor the code that called it. That is the same standard the recorded rates and charges are held to, and it is what lets a refund move back exactly what moved without reading anything else.

Rows are appended and never changed. A line that was given back is a second row naming the first, so what was bought stays readable after it is undone -- which a status column that is rewritten in place cannot do.

func (Purchase) Credited added in v0.2.0

func (p Purchase) Credited() Money

Credited is what reached the receiver.

func (Purchase) Fee added in v0.2.0

func (p Purchase) Fee() Money

Fee is what was charged on this line, read at the scale it was counted at.

func (Purchase) Free added in v0.4.0

func (p Purchase) Free() bool

Free reports that this line moved no money.

It is what the row says rather than a kind of its own: a free line is bought or given exactly as a paid one is, and what makes it free is the price on it. A second kind would mean two answers to "has this person got one", which is the question the record exists for.

func (Purchase) Gift added in v0.2.0

func (p Purchase) Gift() bool

Gift reports that the money was one wallet's and the thing bought another's.

func (Purchase) Paid added in v0.2.0

func (p Purchase) Paid() Money

Paid is what left the payer for this line.

func (Purchase) Price added in v0.2.0

func (p Purchase) Price() Money

Price is what one of the product cost, read at the scale it was counted at.

func (Purchase) Refunds added in v0.2.0

func (p Purchase) Refunds() string

Refunds is the line this row gave back, and the empty string where it gave back nothing.

func (Purchase) Requested added in v0.2.0

func (p Purchase) Requested() Money

Requested is the price times the quantity, before anything was taken off.

func (Purchase) Schedule added in v0.2.0

func (p Purchase) Schedule() FeeSchedule

Schedule is the fee this line was charged under, as it was quoted.

type PurchaseCollection added in v0.2.0

type PurchaseCollection struct {
	// contains filtered or unexported fields
}

PurchaseCollection is a page of purchases as one response.

func NewPurchaseCollection added in v0.2.0

func NewPurchaseCollection(records []*Purchase, cursor string) PurchaseCollection

NewPurchaseCollection wraps a page of purchases for the response. The cursor is what the next request passes back, and is empty when there is no next page.

func (PurchaseCollection) ToArray added in v0.2.0

func (c PurchaseCollection) ToArray() map[string]any

ToArray returns the page under a single key, so that the shape of the response does not change when a second key is added beside it.

func (PurchaseCollection) With added in v0.2.0

func (c PurchaseCollection) With() map[string]any

With returns the cursor of the next page, and nothing when there is none.

type PurchaseKind added in v0.2.0

type PurchaseKind string

PurchaseKind is what a purchase row records.

const (
	// PurchasePaid is a line somebody bought for themselves.
	PurchasePaid PurchaseKind = "paid"
	// PurchaseGift is a line somebody bought for somebody else. The money left
	// the payer and the line belongs to the beneficiary.
	PurchaseGift PurchaseKind = "gift"
	// PurchaseRefund is a line that was undone. It names the line it settles,
	// and appending it is what takes a purchase back -- the row that recorded
	// the purchase is never touched.
	PurchaseRefund PurchaseKind = "refund"
)

The kinds a purchase row can be. What was paid for is told apart from what was given, because "has this person already got one" is asked both ways and the answer differs: an application that sells a licence wants the one they paid for, and one that hands out a bonus wants either.

type PurchaseQuery added in v0.2.0

type PurchaseQuery struct {
	// OwnerWalletID is whose purchase it would be. On a gift that is the
	// beneficiary and not whoever paid.
	OwnerWalletID string
	// ReceiverWalletID is the wallet the money went to.
	ReceiverWalletID string
	// ProductKey is what was bought, as the application names it.
	ProductKey string
	// IncludeGifts asks about what was given as well as what was paid for.
	// False is the ordinary question: has this person bought one.
	IncludeGifts bool
}

PurchaseQuery is one question about what a wallet already has.

It names the three things that make a purchase what it is -- whose it would be, who was paid for it, and what it was -- because any two of them are a question with several answers, and a caller that had to filter the third in its own code would be a caller reading rows it was told not to have.

type PurchaseResource added in v0.2.0

type PurchaseResource struct {
	// contains filtered or unexported fields
}

PurchaseResource is the list of fields one purchase may answer with.

A declared list rather than the entity, for the reason Resource is one: an encoder handed the entity answers with whatever fields it happens to have, including the ones somebody adds later without opening the handler.

func NewPurchaseResource added in v0.2.0

func NewPurchaseResource(record Purchase) PurchaseResource

NewPurchaseResource snapshots one purchase for the response.

func (PurchaseResource) ToArray added in v0.2.0

func (r PurchaseResource) ToArray() map[string]any

ToArray returns the fields that may leave, by name.

Each amount leaves twice, as the integer the ledger is kept in and as the decimal a person reads, which is the same arrangement a balance has and for the same reason.

func (PurchaseResource) With added in v0.2.0

func (r PurchaseResource) With() map[string]any

With returns what goes beside the fields, and nothing does.

type PurchaseRow added in v0.2.0

type PurchaseRow struct {
	// ID is what a refund names.
	ID string
	// Kind is what a person reads in place of bought, given or refunded, and
	// Refunded says whether this row is one that gave something back.
	Kind     string
	Refunded bool
	// Product is what was bought, as the application names it, and Quantity how
	// many.
	Product  string
	Quantity string
	// Receiver is the wallet that was paid.
	Receiver string
	// Paid is what left the payer for this line and Fee what was charged on it.
	Paid string
	Fee  string
	// Created is when it was written.
	Created string
}

PurchaseRow is one line of a basket as a screen draws it.

type Rate added in v0.2.0

type Rate struct {
	// From is the currency the rate converts out of.
	From Currency
	// To is the currency it converts into.
	To Currency
	// Numerator is the top of the fraction: how much of To one unit of From is
	// worth, over Denominator.
	Numerator int64
	// Denominator is the bottom of the fraction, and it is positive and at most
	// MaxRateDenominator.
	Denominator int64
	// QuotedAt is when the rate was obtained, in UTC.
	//
	// It is required. A rate is only true of a moment, and a record that does
	// not say which moment is a record nobody can check against anything.
	QuotedAt time.Time
}

Rate is one exchange rate, as the exact fraction Numerator/Denominator.

A fraction of two integers and never a float: 5.4321 has no binary representation, so a rate held as a float is already a different rate than the one somebody quoted, and every amount converted through it inherits the difference. Two integers are exactly what was quoted, they multiply and divide without loss, and they are what the record holds -- so a conversion can be recomputed from the row long after the provider that answered it is gone.

The direction is part of the value. A rate that did not name its two currencies would be a number that could be applied backwards, and applying a rate backwards is a conversion that is wrong by the square of itself.

func (Rate) Convert added in v0.2.0

func (r Rate) Convert(from Money, to Currency, toDecimalPlaces int) (Converted, error)

Convert applies the rate to an amount and reports what arrives.

The whole calculation is integer arithmetic on exact values. The money that leaves is an integer of the source's minor units, the rate is a fraction of two integers, and the scales are powers of ten, so what arrives is one division: the amount times the numerator times ten to the target's scale, over the denominator times ten to the source's scale. The intermediate is wider than an int64 and is computed as a big integer, which is exact; only the result is narrowed, and a result that does not fit is reported rather than wrapped.

The quotient is truncated, which with every operand positive is a truncation downward: what arrives is never more than the rate justifies. The division's remainder comes back beside it, so the part that could not be credited is a number the caller has and not a difference it has to reconstruct.

func (Rate) String added in v0.2.0

func (r Rate) String() string

String writes the rate as the pair and the fraction.

func (Rate) Validate added in v0.2.0

func (r Rate) Validate() error

Validate reports why this rate cannot be applied, and nil when it can.

type RateProvider

type RateProvider interface {
	// Rate returns the rate that converts from into to.
	//
	// It reports an error rather than an approximation when the pair has no
	// rate: money that moved at a rate nobody had is money that has to be
	// unwound by hand.
	//
	// What it reports with should wrap one of the five values above where one
	// fits, so that a caller can tell a pair nobody quotes from a service that
	// is down without reading a sentence. An error that wraps none of them is
	// carried out unchanged rather than guessed at.
	Rate(ctx context.Context, g security.Grant, from, to Currency) (Rate, error)
}

RateProvider answers with the rate between two currencies.

It is the seam and not an implementation, and this package ships no implementation of it: a rate comes from somewhere outside the process, and a package that declares network = false has nowhere to get one. An application that moves money between currencies writes the provider it trusts and hands it to Config, which is the one place the choice is visible.

It answers with a rate rather than with a converted amount, and that is the difference between a conversion somebody can audit and one they cannot. A provider that returned the amount would be a provider that did the multiplication and the rounding privately: the rate would be gone by the time the money moved, the rounding rule would be whatever that provider chose, and a statement would say what arrived without saying why. Here the rate is a value this package holds, records and applies under one rule, so the row can be recomputed from the numbers on it.

The Grant is first because a rate can be a tenant's own -- a negotiated corporate rate, a rate table an application sells -- and a provider that cannot tell whose rate it is asked for is a provider that answers with somebody else's.

type RebuildRequest added in v0.3.0

type RebuildRequest struct {
	// IdempotencyKey is the caller's name for this request. It is the
	// adjustment's own key, and sending it twice adjusts once.
	IdempotencyKey string
	// WalletID is the wallet whose ledger is being made to explain its balance.
	WalletID string
	// Reason is what the adjustment is recorded as. It is required, for the
	// reason a reversal's is: a row in a ledger that says money appeared and
	// does not say why is a row nobody can account for later.
	Reason string
	// Meta is what the application attaches to the adjustment.
	Meta Meta
}

RebuildRequest is what closing a wallet's difference takes.

func (RebuildRequest) Validate added in v0.3.0

func (r RebuildRequest) Validate() validation.Errors

Validate reports the errors per field.

type Receipt

type Receipt struct {
	// Operation is the request that was recorded.
	Operation Operation
	// Entries are the movements it wrote, in the order they were applied.
	Entries []Entry
	// Conversion is the rate the operation applied, and nil where it applied
	// none. Only an exchange has one.
	Conversion *Conversion
	// Charge is what the operation charged beyond the money it moved, and nil
	// where it charged nothing. Only a payment between two wallets has one.
	Charge *Charge
	// Purchases are the lines a basket was made of, in the order they were paid
	// for, and empty on every operation that bought nothing.
	//
	// They are here rather than under Charge because a basket is not one payment
	// between two wallets: each line has its own price, its own discount, its
	// own fee and its own pair of wallets, and a single charge row for the lot
	// would be one number where there are six.
	Purchases []Purchase
	// Replayed reports that this operation had already run under the same
	// idempotency key, and that nothing moved on this call. The money in the
	// receipt is the money the first call moved, at the rate the first call
	// was quoted.
	Replayed bool
}

Receipt is what a movement of money answers with.

It carries the operation and every entry the operation wrote, so a transfer answers about both wallets in one value and a caller never has to ask a second question to learn what its own request did.

func (Receipt) Pending added in v0.2.0

func (r Receipt) Pending() bool

Pending reports that this operation is on the record and has not moved any money: every entry it wrote is waiting to be confirmed.

A receipt with no entries at all is not pending. There is nothing waiting in it, and answering that there is would be answering about a movement that does not exist.

type ReceiptResource

type ReceiptResource struct {
	// contains filtered or unexported fields
}

ReceiptResource is what a receipt may answer with.

func NewReceiptResource

func NewReceiptResource(receipt Receipt, places map[string]int) ReceiptResource

NewReceiptResource snapshots a receipt for the response.

The scales are per wallet and not one for the whole receipt, because an exchange writes two entries counted differently: rendering both at one scale moves the decimal point on one of them, which is a receipt that reports a number nobody was charged. A wallet the map does not name renders as the integer, which is worse to read and impossible to misread.

func (ReceiptResource) ToArray

func (r ReceiptResource) ToArray() map[string]any

ToArray returns the fields that may leave, by name.

The conversion is present on an exchange and null on everything else, and the charge on a payment that discounted or charged and null on everything else -- present rather than absent, because a key that appears and disappears is a key a client reads with a branch it forgets to write.

func (ReceiptResource) With

func (r ReceiptResource) With() map[string]any

With returns what goes beside the fields, and nothing does.

type Reconciliation added in v0.2.0

type Reconciliation struct {
	// Wallet is the wallet that was read.
	Wallet Wallet
	// Settled is the sum of the movements that counted, which is what the
	// balance column has to equal.
	Settled Amount
	// Proposed is the sum of the movements that are waiting, read as what they
	// would move. Nothing checks it against anything: it is here so that
	// somebody looking at a difference can see how much is in flight.
	Proposed Amount
	// Entries is how many rows were read.
	Entries int
	// LastBalanceAfter is what the newest movement recorded the balance as, and
	// LastSequence its position. The first has to equal the balance column too,
	// which is the second half of the check: a ledger can sum correctly and
	// still have been written in an order nobody can read down.
	LastBalanceAfter Amount
	LastSequence     int64
	// Gaps is how many times the sequence skipped a number. A gap is not proof
	// of a lost write -- a transaction that rolled back after taking a number
	// leaves one -- but it is where somebody looks first.
	Gaps int
	// Frozen reports that this wallet is no longer served.
	//
	// It is what Reconcile leaves behind when Difference is not zero, and it is
	// read by the statement that would move the money rather than by anything
	// before it. Rebuild is what lifts it, in the same statement that appends
	// the row closing the difference.
	Frozen bool
}

Reconciliation is what one wallet's ledger adds up to, beside what its balance column says.

The balance is a projection of the entries, so the two have to agree; this is the value that says whether they do, and by how much when they do not.

It is a conclusion about one state of the wallet and not about the wallet as it is now: the entries are read up to the position the wallet held when the read began, so what is compared against Wallet.Balance is exactly the set of rows that produced it. A wallet that moves afterwards moves both numbers by the same amount, so Difference is what it was.

Nothing here is corrected. What the difference does cause is the freeze -- the wallet stops being served, because a balance this package cannot explain is a balance it should not be paying out of. Closing the difference is Rebuild, and it appends the row that explains it rather than editing anything.

func (Reconciliation) Balanced added in v0.2.0

func (r Reconciliation) Balanced() bool

Balanced reports that the ledger and the balance column agree.

func (Reconciliation) Difference added in v0.2.0

func (r Reconciliation) Difference() Amount

Difference is what the balance column holds beyond what the ledger explains, and zero where the two agree.

type RefundRequest added in v0.2.0

type RefundRequest struct {
	// IdempotencyKey is the caller's name for this request. It is the refund's
	// own key, and never the key of the purchase being given back.
	IdempotencyKey string
	// PurchaseIDs are the lines to give back. They may come from one basket or
	// from several: what is undone is a line, and which request it was part of
	// changes nothing about the money.
	PurchaseIDs []string
	// Reason is what the refund is recorded as. It is required, for the reason a
	// reversal's is: money that moved for no recorded reason is money nobody can
	// account for later.
	Reason string
	// Force asks for the movement even where the wallet giving the money back
	// does not cover it, and is answered by WalletForce.
	Force bool
	// Meta is what the application attaches to the refund.
	Meta Meta
}

RefundRequest is what giving back some lines of a purchase takes.

func (RefundRequest) Validate added in v0.2.0

func (r RefundRequest) Validate() validation.Errors

Validate reports the errors per field.

type Resource

type Resource struct {
	// contains filtered or unexported fields
}

Resource is the list of fields one Wallet is allowed to answer with.

A declared list rather than the entity itself. An encoder handed the entity answers with whatever fields it happens to have, including the ones somebody adds later without ever opening the handler -- and TenantID is exactly such a field: it names another customer's identifier and belongs in no response.

func NewResource

func NewResource(record Wallet) Resource

NewResource snapshots one wallet for the response.

func (Resource) ToArray

func (r Resource) ToArray() map[string]any

ToArray returns the fields that may leave, by name.

The balance leaves twice: once as the integer the ledger is kept in, and once as the decimal a person reads. A client that computes anything reads balance_minor, one that prints reads balance, and neither has to know the scale to do its half -- which is there anyway, for the one that does.

The credit limit leaves beside it, in both spellings, because a balance that may go below zero is not readable without the number that says how far. It is a magnitude and never a negative: what it bounds is the negative side.

func (Resource) With

func (r Resource) With() map[string]any

With returns what goes beside the fields at the top level of the response. Nothing does here, and a resource that has nothing to add says so by answering nil.

type ReverseRequest

type ReverseRequest struct {
	// IdempotencyKey is the caller's name for this request. It is the
	// reversal's own key, and never the key of the operation being reversed.
	IdempotencyKey string
	// OperationID is the operation to undo.
	OperationID string
	// Reason is what the reversal is recorded as. It is required, because a
	// reversal with no reason is a movement nobody can account for later.
	Reason string
	// Meta is what the application attaches to the reversal.
	Meta Meta
}

ReverseRequest is what undoing an operation takes.

func (ReverseRequest) Validate

func (r ReverseRequest) Validate() validation.Errors

Validate reports the errors per field.

type Rounding added in v0.2.0

type Rounding string

Rounding names the rule that turns an exact conversion into whole minor units.

It is a named value on the record rather than a fact a reader has to know, because a row that says how it was rounded is a row somebody can reproduce without reading the code that wrote it.

const RoundDown Rounding = "down"

RoundDown is the rule this package applies, and the only one.

The exact value is truncated toward zero, so a conversion never credits more than the rate justifies -- rounding up would credit money that no rate produced and that somebody would have to fund. What truncation leaves behind is smaller than one minor unit of the target and cannot be credited, because there is no smaller unit to credit it in; it is written on the conversion as an exact fraction instead, so the part that could not move is a number somebody can find rather than a difference nobody can explain.

One rule and no setting. A rounding mode an application chooses is two answers to "what is this amount worth", and the one a statement was written under would be whichever the configuration said that day.

type Statement

type Statement struct {
	// Wallet is whose ledger this is. It is a snapshot for reading and not a
	// handle to write through.
	Wallet Wallet
	// Entries are the movements, oldest first.
	Entries []*Entry
	// Operations are the requests the entries on this page were written under,
	// by operation identifier.
	//
	// They travel with the page because a movement does not say what it was:
	// the same withdrawal is written by a payment, by a reversal and by an
	// exchange, and a reader who cannot see which is reading a ledger that
	// hides the difference. An entry names its operation, and this is where
	// that name resolves.
	Operations map[string]Operation
	// Conversions are the rates those operations applied, by operation
	// identifier. Only an exchange has one, so this map is smaller than
	// Operations and is empty on a wallet that never converted.
	Conversions map[string]Conversion
	// Charges are what those operations charged beyond the money they moved,
	// by operation identifier. Only a payment that discounted or charged has
	// one, so this map is empty on a wallet nobody charged.
	Charges map[string]Charge
}

Statement is a page of one wallet's ledger, together with the wallet it belongs to.

The wallet travels with the entries because an amount is meaningless without the scale it was written at, and the scale is the wallet's. A page of entries alone would be a page every reader has to go and ask a second question about.

type StatementPageData added in v0.2.0

type StatementPageData struct {
	view.Page

	Prefix string
	Labels Labels
	// Wallet is whose ledger this is.
	Wallet WalletRow
	// Rows are the movements, oldest first.
	Rows []EntryRow
	// Conversions are the rates the operations on this page applied, and
	// Charges what they charged. Both go beside the movements and not inside
	// them, because each belongs to an operation and an operation writes an
	// entry on two or three wallets -- repeating one on every line would be
	// repeating one fact until two copies of it could differ.
	Conversions []ConversionRow
	Charges     []ChargeRow
	// Next is the cursor of the following page, empty on the last one.
	Next string
}

StatementPageData is what the ledger screen is handed.

func (StatementPageData) Form added in v0.2.0

func (d StatementPageData) Form() FormState

Form is the state the inputs of this screen read.

type TransferRequest

type TransferRequest struct {
	// IdempotencyKey is the caller's name for this request.
	IdempotencyKey string
	// FromWalletID is the wallet the money leaves.
	FromWalletID string
	// ToWalletID is the wallet it arrives in.
	ToWalletID string
	// Amount is the decimal to move, written at the source wallet's scale.
	// What arrives is the same amount when both wallets are counted the same
	// way, and what the rate provider answers when they are not.
	Amount string
	// Withdrawal is what the leg that pays carries, and Deposit what the leg
	// that is paid carries.
	//
	// They are two values and not one flag over the pair, because the two sides
	// of a payment are not always the same decision. What leaves counting now
	// while what arrives waits is money held until somebody says it may be
	// delivered; the other way round is a delivery on credit. Both are ordinary
	// arrangements, and neither is expressible by a single yes-or-no.
	//
	// A rate, where one is needed, is quoted and recorded when the operation is
	// written, whatever either side says: what a confirmation applies is what
	// this operation wrote down.
	Withdrawal Leg
	Deposit    Leg
	// Force asks for the movement even where the source's balance and credit
	// limit do not cover it, and is answered by WalletForce.
	Force bool
	// Meta is what the application attaches to the payment as a whole. What
	// belongs to one side of it goes on that side's Leg.
	Meta Meta
}

TransferRequest is what moving money between two wallets takes.

func (TransferRequest) Validate

func (r TransferRequest) Validate() validation.Errors

Validate reports the errors per field.

type Wallet

type Wallet struct {
	model.Model[Wallet]

	// ID is the identifier. It is generated by the application rather than by
	// the database, because a DEFAULT that produces a uuid is spelled
	// differently in every engine.
	ID string `db:"id"`

	// TenantID is the customer the row belongs to. It is written from the
	// Grant and read back so the policy can compare it against the subject's
	// tenant.
	TenantID string `db:"tenant_id"`

	// HolderID is whose money this is. It is an identifier the application
	// owns -- a user, an organisation, a merchant -- and this package never
	// resolves it: what a holder is belongs to the application, and a package
	// that assumed it was a user would be wrong in every application where it
	// is not.
	HolderID string `db:"holder_id"`

	// Slug names which of the holder's wallets this is.
	Slug string `db:"slug"`

	// Name is what a person calls this wallet.
	Name string `db:"name"`

	// Description is what a person is told this wallet is for, and it is empty
	// where nobody said. It is text this package carries and never reads: what
	// a wallet is for is the application's sentence, in the application's
	// language, and a package that parsed it would be a package deciding what
	// the sentences may be.
	Description string `db:"description"`

	// Meta is what the application attaches to the wallet itself: its own facts
	// about whose money this is and why it exists.
	//
	// It is the wallet's own and not a movement's. A fact that is true of every
	// movement -- the account it settles to, the contract it belongs to -- is a
	// fact about the wallet, and attaching it to each movement instead would
	// write it into a table that only grows.
	//
	// Nothing here is read by this package, exactly as on a movement: it is not
	// indexed, not searched and not compared, and every decision about the money
	// is made from the columns beside it.
	Meta Meta `db:"meta"`

	// Currency is what the balance counts.
	Currency Currency `db:"currency"`

	// DecimalPlaces is how many minor units make one major unit of Currency.
	// It is the scale every amount on this wallet is written at, and it is
	// fixed for the life of the wallet.
	DecimalPlaces int `db:"decimal_places"`

	// Balance is the projection of the ledger, in minor units.
	//
	// The entries are the truth and this column is what they add up to. It
	// exists so that a balance is one row rather than a sum over a history
	// that only grows, and it is only ever moved by a statement that carries
	// its own guard -- never by a value read, adjusted in Go and written back.
	Balance Amount `db:"balance"`

	// CreditLimit is how far below zero this wallet may go, as a positive
	// number of minor units. Zero is the ordinary wallet, which may not go
	// below zero at all.
	//
	// It is a column and not a value the application answers for on each call,
	// because it is read by the statement that moves the money: the guard on a
	// withdrawal compares the balance against the amount less this column, in
	// the same statement, so the limit that applies is the one the row holds at
	// the moment of the write. A limit fetched a moment earlier would be a
	// limit two concurrent withdrawals could both spend.
	CreditLimit Amount `db:"credit_limit"`

	// LastSequence is the position of the newest entry on this wallet.
	//
	// It moves in the same statement as the balance, so the number an entry is
	// written with is one nothing else can be holding. It exists because a
	// statement is read in order and a timestamp cannot supply one: two entries
	// written inside the same tick of the clock are two rows an ORDER BY over
	// time cannot tell apart, and a running balance in the wrong order is a
	// statement that does not add up as somebody reads down it.
	LastSequence int64 `db:"last_sequence"`

	// Frozen stops every movement of this wallet's money.
	//
	// It is set when a reconciliation finds that the ledger no longer explains
	// the balance beside it, and cleared by the adjustment that closes the
	// difference. Nothing else writes it, and it is not a lock an application
	// takes: what it means is that this package no longer knows what this
	// wallet holds, and serving a withdrawal from a number it cannot explain is
	// how a discrepancy becomes somebody else's money.
	//
	// It is a column and not a value read before the movement, for the reason
	// the credit limit is one: the statement that moves the balance names it,
	// so the answer that applies is the one the row holds at the instant of the
	// write. A flag read a moment earlier is a flag two concurrent withdrawals
	// both saw as clear.
	Frozen Flag `db:"frozen"`

	// Closed takes the wallet out of service.
	//
	// It is somebody's decision rather than a defect: the holder left, the
	// contract ended, the account was consolidated into another. Money stops
	// moving and everything already written stays readable, which is why this
	// is a column and not a deleted row -- a wallet whose row was removed takes
	// its ledger's meaning with it, and a statement that cannot name the wallet
	// it belongs to is a statement nobody can audit.
	//
	// It is a second column beside Frozen and not a second value in one, and
	// the two are kept apart because they are answered differently. See
	// servable.
	Closed Flag `db:"closed"`

	// CreatedAt is when the wallet was opened, in UTC.
	CreatedAt time.Time `db:"created_at"`

	// UpdatedAt is when the balance last moved, in UTC.
	UpdatedAt time.Time `db:"updated_at"`
}

Wallet is a balance held for somebody, in one currency.

A holder may have several: the pair of HolderID and Slug is what names one, so "user-1"/"main" and "user-1"/"bonus" are two balances that never mix. The currency and the scale are fixed when the wallet is opened, because changing either would reinterpret every amount already written under it.

It embeds the model, so a row returned by a query carries the connection and can be saved again. Build new rows through Wallets: a struct literal has no connection and its write methods return model.ErrUnwired.

func (Wallet) Money

func (w Wallet) Money(amount Amount) Money

Money returns an amount read at this wallet's currency and scale.

type WalletPolicy

type WalletPolicy struct{}

WalletPolicy is the only authority over who does what with a Wallet.

It denies unless a rule below says otherwise, and the rules are written around two subjects: the holder, who may see and move their own money, and the operator, who may act across the tenant. Everything else -- a guest, a subject from another tenant, a signed-in person reaching for somebody else's wallet -- falls through to the refusal at the end.

Reversal is deliberately not the holder's, and neither is a refund. Undoing a payment is a decision about a movement that already settled, and letting the person who received it take it back is a hole with a name.

Reconciling is not the holder's either. It reads a whole history, which a holder may do, but what it leaves behind is a wallet that no longer moves or a ledger row no request produced -- and a holder who could write either could decide what their own balance is supposed to be.

Neither is the overdraft limit, and neither is moving money past it. A holder who could raise their own limit could spend money nobody lent them, and one who could ignore it would not need to raise it first.

func (WalletPolicy) Can

Can decides whether the subject may perform the action on the record.

It is the only place that decides. The service reaches the Model only after Authorize turns this method's nil result into a Grant.

The record is the empty Wallet where the question is "may this subject do this kind of thing at all", and the loaded row where it is "may they do it to this money". Both are asked, in that order, and the second is what a rule about ownership answers.

type WalletRow added in v0.2.0

type WalletRow struct {
	// ID is what a link addresses and a form submits.
	ID string
	// HolderID is whose money this is, as the application named them.
	HolderID string
	// Slug is which of the holder's wallets this is, and Name what a person
	// calls it.
	Slug string
	Name string
	// Currency is what the balance counts.
	Currency string
	// Balance is what it holds, and CreditLimit how far below zero it may go,
	// both as decimals at this wallet's scale.
	Balance     string
	CreditLimit string
	// Negative says the balance is below zero, so a screen can show it as such
	// without comparing text.
	Negative bool
	// Created is when the wallet was opened.
	Created string
}

WalletRow is one wallet as a screen draws it.

It is a snapshot and not the entity, for the reason Resource is one: a template handed the entity draws whatever fields it happens to have, including the ones somebody adds later without opening the markup -- and TenantID is exactly such a field.

Every amount is text, already at the wallet's own scale, because a screen shows a number and does no arithmetic on it. Rendering minor units in the markup would put the decimal point in a template, which is the one place it can be wrong in a language nobody type-checks.

type WalletService

type WalletService struct {
	// contains filtered or unexported fields
}

WalletService holds the rules of this package.

It receives its collaborators through the constructor. There is no container and no resolution by reflection: what this service is made of is written at the one place that builds it, and reading that place is how somebody learns what the package touches.

Everything a handler is allowed to do goes through here. The service is the only owner of the database handle, so the request layer cannot reach a Model before the policy has answered.

func NewWalletService

func NewWalletService(db *data.DB, rates RateProvider, fees FeeProvider, discounts DiscountProvider, listeners ...Listener) *WalletService

NewWalletService wires the service over the application's database handle.

Every provider may be nil, and nil is not a degraded mode. It is an application that moves money only between wallets counted the same way, that charges nothing to be paid, and that discounts nothing -- which is most of them. A transfer that would need a rate is refused rather than guessed at, and a payment with no schedule is a payment with no fee.

They are parameters and not a struct of options, for the reason Config is a struct and not a map: what this service is made of is written at the one place that builds it, and reading that place is how somebody learns what the package reaches for.

The listeners are last and variadic because there may be none, which is the ordinary case: an application that wants to be told what its money did says so by passing something, and one that does not passes nothing rather than a nil it has to remember the meaning of.

func (*WalletService) Bought added in v0.2.0

func (s *WalletService) Bought(ctx context.Context, actor security.Subject, questions []PurchaseQuery) ([]*Purchase, error)

Bought answers, for each question, the line that already bought it -- and nil where nothing did.

One statement for the whole set rather than one per question. The rows that could answer any of them are read together and matched in memory, so a shop checking forty products against one customer makes one read and not forty -- which is the difference between a page that loads and a page that times out on the customers who buy the most.

A line that was given back does not answer. The refund is a row of its own naming the line it settles, so what is asked here is "bought and not given back", which is what a shop deciding whether to sell something again means.

It is bounded by MaxPurchaseScan, and that bound is real: a question about a wallet with more recent purchases than that, among the wallets named beside it, is answered from what the scan reached.

func (*WalletService) CanWithdraw added in v0.6.0

func (s *WalletService) CanWithdraw(ctx context.Context, actor security.Subject, walletID, amount string) (bool, error)

CanWithdraw reports whether a wallet could pay out an amount right now.

It is a photograph, and saying so is the whole doc comment. Between this answer and a withdrawal the balance can change, so a caller that treats a true here as permission has written the read-then-check this package exists to avoid: what decides a withdrawal is the predicate on the update inside Withdraw, and nothing else ever will be.

What it is for is the question a screen asks -- whether to draw a button as enabled, whether to offer a payment method. Wrong occasionally and in one direction is fine there; wrong in a ledger is not.

It answers the same arithmetic the guard does: the balance plus the credit limit, against the amount. A wallet that is frozen or closed answers false, because those are the other two reasons the write refuses. Force is not a parameter: a movement past the limit is a decision the policy makes at the moment of the write, and asking about it here would be answering for a subject this call has not authorized for it.

Zero and negative amounts answer false, because Withdraw refuses them.

It authorizes WalletView twice, on the empty candidate and then on the row, which is what every read here does: the first refuses a subject who may not look at wallets at all, and the second refuses one who may not look at this one.

func (*WalletService) Close added in v0.4.0

func (s *WalletService) Close(ctx context.Context, actor security.Subject, in CloseRequest) (*Wallet, error)

Close takes a wallet out of service.

The row stays and the ledger stays readable, which is the difference between this and deleting: a wallet whose row was removed takes the meaning of its own statement with it, and every entry naming it becomes a movement nobody can place. What changes is one column, and every statement that moves a balance carries it -- see servable -- so a wallet closed between a read and a write is refused at the write.

It requires the balance to be zero, and the requirement is a predicate on the statement rather than a check before it: a wallet closed with money in it is money nothing can reach afterwards, and a balance read a moment earlier is a balance a concurrent deposit has already changed. Where it does not hold, the answer is ErrWalletHoldsMoney and nothing changed.

A frozen wallet can be closed. The freeze says this package cannot explain the balance; if that balance is zero, closing it is a decision somebody is entitled to make, and the ledger stays exactly as readable afterwards.

func (*WalletService) Confirm added in v0.2.0

func (s *WalletService) Confirm(ctx context.Context, actor security.Subject, in ConfirmRequest) (Receipt, error)

Confirm makes an operation that was only recorded count.

It is the second half of a movement written as pending: the entries are already in the ledger, saying what was proposed and counting for nothing, and this appends the settled entry beside each of them under an operation that names the one it settles. Nothing already written changes -- which is why this is an operation of its own rather than a column somebody flips, and why a statement afterwards reads as what was asked for and then what happened.

The money is judged here, because here is where it moves. A pending withdrawal holds nothing, so a confirmation whose wallet no longer covers it answers ErrInsufficientFunds, and the balance it is judged against is the balance at this write and not the one when the request was recorded.

An operation is confirmed once. The second attempt answers ErrAlreadyConfirmed, and the refusal is a unique index rather than a check, so two confirmations arriving together cannot both be the one that succeeds. An operation with nothing waiting -- one that settled when it was made, a reversal, another confirmation -- answers ErrNotPending.

There is one method and not the reference's pair of a safe and an unsafe one. This is the safe one: it reports why it could not settle instead of answering false, and a caller that wants the movement anyway asks for it in the request and is answered by the policy.

func (*WalletService) Deposit

func (s *WalletService) Deposit(ctx context.Context, actor security.Subject, in DepositRequest) (Receipt, error)

Deposit puts money into a wallet.

The same idempotency key twice credits once: the second call answers with the receipt the first one produced, and nothing moves. See Receipt.Replayed.

func (*WalletService) Describe added in v0.4.0

func (s *WalletService) Describe(ctx context.Context, actor security.Subject, in DescribeRequest) (*Wallet, error)

Describe changes what a wallet is called, what it is for, and the facts the application keeps about it.

It touches no money and no column any guard reads, which is why it is allowed on a frozen wallet: the freeze says this package cannot explain the balance, and a sentence about what the wallet is for is not a claim about the balance. A relabelling refused because of a discrepancy would be a refusal nobody could act on -- the person correcting the label is usually the person investigating the discrepancy.

It is asked about twice, like every other write to one wallet: once to decide whether this subject relabels wallets at all, and once about the wallet whose label it is.

func (*WalletService) Find

func (s *WalletService) Find(ctx context.Context, actor security.Subject, id string) (*Wallet, error)

Find returns one wallet, and asks the policy twice.

The first call is on the empty candidate, because there is no way to read the record without a Grant and no way to hold a Grant without a decision. What it decides is whether this subject may read wallets at all.

The second call is on the record that came back, and it is the one a rule about the record itself depends on: the first call saw an empty value, so anything the policy says about who holds the wallet never ran. Without it a policy can be written that looks correct, reads correctly, and is never consulted about the thing it protects.

The read itself is already scoped by data.Tenant, so the second call is not what keeps customers apart. It is what keeps one customer's holders apart.

func (*WalletService) FindBySlug added in v0.4.0

func (s *WalletService) FindBySlug(ctx context.Context, actor security.Subject, holderID, slug string) (*Wallet, error)

FindBySlug returns the wallet a holder keeps under this slug.

It is the read for the caller who knows whose money it is and what they call it, which is most callers: the pair is what names a wallet, it is under the unique index the table was created with, and an application that had to keep a generated identifier beside its own user row would be keeping a second key for a row it can already name.

It opens nothing. A read that created the wallet it did not find would be a write behind a name that promises a read -- and the first caller to ask about a holder who has none would silently open one, under a currency and a scale this package would have had to guess.

It asks the policy the same two questions Find asks, in the same order and for the same reason: the first decides whether this subject reads wallets, and the second is the one a rule about the holder answers.

func (*WalletService) History

History returns a page of one wallet's ledger, oldest first.

It is a read, and it asks the policy the same two questions a read of the wallet itself asks: whether this subject reads ledgers, and whether they read this one. A statement is the whole record of somebody's money, so a path to it that skipped the second question would be the widest read in the package.

func (*WalletService) List

func (s *WalletService) List(ctx context.Context, actor security.Subject, in ListRequest) ([]*Wallet, error)

List returns a page of wallets.

It authorizes once, on the empty candidate, and the statement is what bounds the rows: the tenant filter the Model applies, and the holder predicate added here for a subject who is not an operator. A policy call per row would be one call per record on a page and would still not narrow the query -- a listing that has to read a customer's rows in order to decide it may not read them has already read them.

func (*WalletService) Open

func (s *WalletService) Open(ctx context.Context, actor security.Subject, in OpenRequest) (*Wallet, error)

Open creates a wallet for a holder.

The candidate is authorized before it is stored, and the candidate is what the policy sees -- so a rule about whose wallet may be opened is a rule about the wallet being opened, and not about the person alone.

func (*WalletService) Pay added in v0.2.0

Pay buys a basket with one wallet's money.

Every line is one movement out of the payer and one into the wallet that sells it, plus a third into whoever collects the fee, and all of them are one operation and one transaction. There is no state in which half a basket was paid for: a line the application refuses, a price that does not fit or a balance that runs out on the fourth of six leaves nothing written at all.

The prices, the discounts and the fees are the application's, through the seams it already supplies. What this package owns is the arithmetic and the record: what was asked for, what was taken off, what the fee was computed from and what actually left and arrived are all on the line's own row, so a receipt that says a different number from the catalogue says why.

A basket crosses no rate. Every wallet it names has to be counted the way the payer's is, because a basket that converted line by line would round once per line and the total would not be the total of anything -- an application selling in another currency prices the line in the payer's money, which is what Product.Price is asked for.

A line bought for somebody else is a gift: the money still leaves the payer and still arrives at the seller, and the record says the beneficiary bought it. That is the whole of what a gift changes, and it is what "has this person already got one" reads afterwards.

func (*WalletService) PurchasesOf added in v0.2.0

func (s *WalletService) PurchasesOf(ctx context.Context, actor security.Subject, walletID string, page data.Query) ([]*Purchase, error)

PurchasesOf returns a page of what one wallet bought, newest first.

It is a read, and it asks the policy the same two questions a read of the wallet itself asks: whether this subject reads purchases, and whether they read this wallet's. What somebody buys is at least as private as what they hold, so a path to it that skipped the second question would be the widest read in the package.

func (*WalletService) Rebuild added in v0.3.0

func (s *WalletService) Rebuild(ctx context.Context, actor security.Subject, in RebuildRequest) (Receipt, error)

Rebuild closes the difference between a wallet's ledger and its balance, by appending the entry the ledger was missing.

Nothing already written changes, and the balance column is not touched at all. What the wallet holds is the number every withdrawal has already been guarded against and every holder has already been able to spend; taking it away because a row is missing would be moving somebody's money to repair a record. So the column stands, and the ledger gains one settled entry, in the direction and of the size that makes the entries add up to it. There is no UPDATE of a balance here and there is none anywhere else either: a repair that edited the column would be the one write in this package that leaves no row behind.

The entry moves no balance, which is what makes that arithmetic come out. An ordinary entry moves the column by exactly the amount it records, so it carries a difference forward instead of closing it; this one records the amount and moves nothing, so the ledger catches up and the column stays.

It requires the wallet to be frozen, and answers ErrWalletNotFrozen where it is not. The freeze is what holds the two numbers still between the read that measures the difference and the statement that writes it -- and that statement names both of them, so a wallet that moved anyway leaves the whole transaction rolled back rather than adjusted by a stale number.

The same statement lifts the freeze. A wallet whose ledger explains its balance again is a wallet that moves, and the two facts change together or neither does.

A wallet whose ledger already adds up answers ErrLedgerBalanced and stays frozen: an adjustment of nothing would be a row saying something happened when nothing did, and lifting the freeze without one would be lifting it for a reason nobody recorded.

func (*WalletService) Reconcile added in v0.2.0

func (s *WalletService) Reconcile(ctx context.Context, actor security.Subject, walletID string) (Reconciliation, error)

Reconcile reads a whole ledger, reports whether it adds up to the balance beside it, and stops the wallet being served where it does not.

It asks the policy the same two questions every other path does. The action is WalletReconcile and not WalletHistory: what this leaves behind is a wallet that no longer moves, so it is a decision about somebody's money rather than a reading of it, and the person whose money it is is not the person who makes it.

It reads the entries in pages rather than in one statement, because a ledger only grows and the wallet worth checking is the one with the longest one. What it costs is a statement per page and nothing held in memory but the running totals.

The pages stop at the position the wallet held when the read began. That is what makes the answer a conclusion rather than a race: the entries summed are exactly the ones that produced the balance read beside them, and a movement arriving during the scan is left for the next one. It also makes the difference stable -- every later movement adds the same amount to both sides, so what is found here is what Rebuild will find.

A difference freezes the wallet. Nothing is repaired: a number this package quietly corrected would be a defect nobody ever heard about, in the one table where the defect is money. What it does instead is refuse to keep paying out of a balance it cannot explain, which is the half the report alone was missing.

A ledger that sums correctly and records the wrong running balance on its last row is reported and not frozen. Balanced says so, and it is the wider check; but no row this package can append would close that, and a freeze nothing can lift is a wallet taken out of service for good.

func (*WalletService) Refund added in v0.2.0

func (s *WalletService) Refund(ctx context.Context, actor security.Subject, in RefundRequest) (Receipt, error)

Refund gives back some of the lines of a purchase.

It moves back exactly what moved, on each side, read off the line's own row: what left the payer goes back to the payer, what reached the seller leaves the seller, and a fee that was taken leaves whoever collected it. Nothing is recomputed -- a schedule that answers differently today would otherwise make a refund of last month's purchase a different number from the purchase.

Nothing already written changes. The line that was bought stays on the record exactly as it was bought, and a second row appears beside it naming the one it settles -- which is why a basket half of which was given back cannot be given back whole, and why a statement afterwards reads as what was bought and then what came back.

A line is given back once. The second attempt answers ErrAlreadyRefunded, and the refusal is a unique index rather than a check, so two refunds arriving together cannot both be the one that succeeds.

func (*WalletService) Reopen added in v0.4.0

func (s *WalletService) Reopen(ctx context.Context, actor security.Subject, walletID string) (*Wallet, error)

Reopen puts a closed wallet back in service.

It exists because closing is a decision and decisions are made wrongly. It changes the one column back and nothing else: the ledger was never touched, so a reopened wallet is the wallet it was, with the balance it had -- which is zero, because that is what closing required.

It is the same action as closing. Deciding that a wallet is out of service and deciding that it is back are the same authority over the same fact, and a separate action would let somebody hold one half of it.

func (*WalletService) Reverse

func (s *WalletService) Reverse(ctx context.Context, actor security.Subject, in ReverseRequest) (Receipt, error)

Reverse undoes an operation by appending its opposite.

Nothing already written changes. The original operation and its entries stay exactly as they were, and a second operation appears beside them naming the one it settles, with one mirrored entry per entry of the original. A statement therefore reads as what happened and then what was undone, which is what a person asking "why is this balance what it is" needs to see.

It is refused when the money is no longer there: a reversal that would take a balance past what the wallet may hold answers ErrInsufficientFunds.

It is refused as well when the operation never moved anything, with ErrNotSettled. What you undo is the operation that moved the money, and for a movement that waited to be confirmed that is the confirmation.

An operation can be undone once. The second attempt answers ErrAlreadyReversed, and the refusal is a unique index rather than a check, so two reversals arriving together cannot both be the one that succeeds.

Undoing an exchange moves back exactly what moved, on each side, in the currency it moved in. No rate is asked for and none is recorded: the amounts are read off the entries the exchange wrote, so what left comes back whole and what arrived goes back whole, whatever the pair is worth today. Converting again at a new rate would be a second exchange wearing the name of the first one's undoing, and it would leave one of the two wallets short.

func (*WalletService) SetCredit added in v0.2.0

func (s *WalletService) SetCredit(ctx context.Context, actor security.Subject, in CreditRequest) (*Wallet, error)

SetCredit sets how far below zero a wallet may go.

The limit is a column on the wallet and not a value the caller passes with each withdrawal, because it is read by the statement that moves the money: the guard compares the balance against the amount less this column, so what applies is what the row holds at that instant. A limit that travelled with the request would be a limit the request chose.

Lowering one is guarded the same way. The write requires the balance to be within the new limit at the moment it happens, so a wallet is never left further below zero than any withdrawal could have taken it; where it already is, the answer is ErrCreditBelowBalance and nothing changes.

It is asked about twice, like every other read of one wallet: once to decide whether this subject sets limits at all, and once about the wallet whose limit it is.

func (*WalletService) Transfer

func (s *WalletService) Transfer(ctx context.Context, actor security.Subject, in TransferRequest) (Receipt, error)

Transfer moves money out of one wallet and into another, converting it when the two are not counted the same way.

Both movements are one operation and one transaction, so there is no state in which the money has left and not arrived. The authority that is checked is the source's: money leaving is what needs permission, and money arriving is bounded by the tenant the Grant carries, which is the only set of wallets the statement can reach at all.

Two wallets counted the same way move the same number and the operation is recorded as a transfer. Two counted differently -- another currency, or the same currency at another scale -- need a rate, and the operation is recorded as an exchange with the rate it was made at beside it. Which of the two it is comes from the wallets and never from the request: a caller cannot ask for a transfer and be given a conversion, or ask for a conversion between wallets that need none, because neither is a thing the caller decides.

This is one path and not two. An Exchange method beside this one would be a second way to move money between two wallets, differing only in a field it wrote -- and the two would drift, because everything true of a transfer is true of an exchange except the rate.

Without a configured RateProvider a conversion is refused rather than approximated.

func (*WalletService) Withdraw

func (s *WalletService) Withdraw(ctx context.Context, actor security.Subject, in WithdrawRequest) (Receipt, error)

Withdraw takes money out of a wallet.

A balance that is not enough is refused with ErrInsufficientFunds, and the refusal comes from the statement that would have moved the money rather than from a comparison made a moment earlier: the guard is a predicate on the update, so a balance that changed in between changes the answer.

type WithdrawRequest

type WithdrawRequest struct {
	// IdempotencyKey is the caller's name for this request.
	IdempotencyKey string
	// WalletID is the wallet to debit.
	WalletID string
	// Amount is the decimal to debit, written at the wallet's own scale.
	Amount string
	// Pending records the movement without letting it count, and holds
	// nothing: the balance is judged where the money moves, which is at the
	// confirmation.
	Pending bool
	// Force asks for the movement even where the balance and the credit limit
	// do not cover it.
	//
	// It is a field of the request and not a method beside Withdraw, because
	// two entry points for one movement are two places every later rule has to
	// be written into, and the one somebody forgets is the one that is not
	// guarded. Asking is not being answered: WalletForce is a separate
	// decision, and a subject the policy refuses it to is refused the movement.
	Force bool
	// Meta is what the application attaches to this request.
	Meta Meta
}

WithdrawRequest is what taking money out of a wallet takes.

func (WithdrawRequest) Validate

func (r WithdrawRequest) Validate() validation.Errors

Validate reports the errors per field.

Directories

Path Synopsis
rates
frankfurter module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL