Documentation
¶
Overview ¶
Package giftcard is the gift-card domain: a prepaid, code-addressable balance an org issues and a customer redeems against orders.
Money-correctness design (READ THIS before touching redeem):
- A GiftCard carries an immutable InitialBalanceCents. It NEVER carries a mutable "remaining" counter that two concurrent redeems could race on.
- Every debit is a GiftCardRedemption ledger line (append-only fact).
- The spendable balance is a PROJECTION: InitialBalance − Σ(active redemptions). Because balance is derived, there is no lost-update: two concurrent redeems each write their own ledger line and the projection reflects both. (Rich Hickey: the redemptions are the values; balance is a view.)
- Redemption is idempotent by construction: the ledger line's storage id is deterministic in the caller-supplied idempotency key (giftcardredemption.DeterministicID). A duplicate submit resolves to the same row via the storage ON CONFLICT(id, kind, namespace) upsert — one debit, never two — WITHOUT relying on datastore.RunInTransaction, which is a no-op on this backend (datastore/datastore.go) and provides no isolation.
Over-redeem is prevented at apply time by checking the projected balance (Σ ledger) against the requested amount before writing the new line; the deterministic id makes the check-then-write safe against retries, and the projection makes distinct concurrent redeems additive rather than lossy.
Index ¶
- Variables
- func BalanceCents(db *datastore.Datastore, g *GiftCard) (currency.Cents, error)
- func DrawnCents(db *datastore.Datastore, giftCardId string) (currency.Cents, error)
- func Query(db *datastore.Datastore) datastore.Query
- func Redeem(db *datastore.Datastore, g *GiftCard, amount currency.Cents, ...) (*giftcardredemption.GiftCardRedemption, currency.Cents, error)
- func Void(db *datastore.Datastore, g *GiftCard, redemptionId string) (currency.Cents, error)
- type GiftCard
Constants ¶
This section is empty.
Variables ¶
var ( ErrNotRedeemable = errors.New("gift card is not redeemable (disabled or expired)") ErrInsufficientFunds = errors.New("gift card has insufficient balance") ErrNonPositiveAmount = errors.New("redeem amount must be positive") ErrMissingIdempotency = errors.New("idempotency key is required to redeem") ErrCurrencyMismatch = errors.New("redeem currency does not match gift card currency") )
Money errors. Callers map these to 4xx; none of them move money.
Functions ¶
func BalanceCents ¶
BalanceCents is the spendable balance: InitialBalance − Σ(active redemptions). This is a projection over facts; there is no mutable balance field to race.
func DrawnCents ¶
DrawnCents returns the total non-voided amount already redeemed from a card. It is the Σ over the append-only redemption ledger — the authoritative debit total. Balance is derived from it, never stored, so concurrent distinct redemptions are additive (never lost).
func Redeem ¶
func Redeem(db *datastore.Datastore, g *GiftCard, amount currency.Cents, curr currency.Type, orderId, idempotencyKey string) (*giftcardredemption.GiftCardRedemption, currency.Cents, error)
Redeem draws amount from the gift card and returns the resulting redemption line plus the balance remaining AFTER this redemption.
Idempotency (money-critical): the redemption row id is deterministic in idempotencyKey (giftcardredemption.DeterministicID). A retry with the same key resolves to the same stored row via the storage ON CONFLICT upsert — the amount is debited exactly once, never twice, WITHOUT relying on datastore.RunInTransaction (a no-op that gives no isolation on this backend).
On a duplicate key we return the ALREADY-STORED line (not the caller's possibly-different amount) so a replayed request can never silently change a past debit.
db MUST be namespaced to the gift card's org (the caller passes org.Namespaced(c.Context())); every read/write below is thereby tenant-scoped.
func Void ¶
Void reverses a redemption by APPENDING a compensating reversal line (a negative-amount ledger entry) rather than mutating the original — the ledger is append-only, so a debit is never rewritten; it is cancelled by an equal and opposite fact. This restores the drawn amount to the projected balance.
Idempotent: the reversal's id is deterministic in the original redemption id ("void:"+id), so voiding the same redemption twice resolves to the same reversal row via ON CONFLICT — the balance is restored exactly once. Returns the balance after the void.
Types ¶
type GiftCard ¶
type GiftCard struct {
mixin.Model[GiftCard]
// Code is the human-facing gift-card code (e.g. "GIFT-4F2A-9C1D").
// Unique within an org namespace by convention (issue rejects collisions).
// Stored uppercased for case-insensitive lookup.
Code string `json:"code"`
InitialBalanceCents currency.Cents `json:"initialBalanceCents"`
Currency currency.Type `json:"currency" orm:"default:usd"`
// Region this card is valid in (optional; empty = any region in the org).
RegionId string `json:"regionId,omitempty"`
// Optional order this card was purchased on (audit).
OrderId string `json:"orderId,omitempty"`
// Disabled cards cannot be redeemed even if balance remains.
Disabled bool `json:"disabled"`
// EndsAt, when set and in the past, makes the card unredeemable (expiry).
EndsAt *time.Time `json:"endsAt,omitempty"`
Metadata Map `json:"metadata,omitempty" datastore:"-"`
Metadata_ string `json:"-" datastore:",noindex"`
}
GiftCard is a prepaid balance addressable by Code, scoped to the issuing org's namespace. InitialBalanceCents is immutable after issue; the spendable balance is computed from the redemption ledger (see BalanceCents in the giftcard API), so a gift card row is a fact, not a mutable wallet.
func (*GiftCard) Redeemable ¶
Redeemable reports whether the card may be debited right now, ignoring balance (balance is checked separately against the projected ledger sum).