Documentation
¶
Overview ¶
Package postgres is the Postgres storage adapter for the ledger. It implements the domain.Repository port on top of pgx and sqlc-generated queries. It holds no business rules: the double-entry invariant is enforced by the domain (Transaction.Validate) and, from Week 4, by a database CHECK constraint.
Index ¶
- Variables
- func IsUniqueViolationError(err error) bool
- func NewPool(ctx context.Context, dsn string, maxConns int32) (*pgxpool.Pool, error)
- type Repository
- func (r *Repository) AllAccountBalances(ctx context.Context, tenantID string) ([]domain.AccountBalanceRow, error)
- func (r *Repository) Balance(ctx context.Context, tenantID, accountID string) (domain.Money, error)
- func (r *Repository) CountPendingOutbox(ctx context.Context, tenantID string) (int, error)
- func (r *Repository) CreateAccount(ctx context.Context, tenantID string, a *domain.Account) error
- func (r *Repository) CreateDispute(ctx context.Context, tenantID string, d *domain.Dispute) error
- func (r *Repository) CreateTenant(ctx context.Context, tenantID, name string) error
- func (r *Repository) CreateTransaction(ctx context.Context, tenantID string, t *domain.Transaction) error
- func (r *Repository) CreateWebhookSubscription(ctx context.Context, sub *domain.WebhookSubscription, secret string) error
- func (r *Repository) CurrentTenantDEK(ctx context.Context, tenantID string) (wrappedDEK []byte, version int, shredded, found bool, err error)
- func (r *Repository) GetAPIKeyByHash(ctx context.Context, hash string) (domain.APIKey, error)
- func (r *Repository) GetAPIKeyByID(ctx context.Context, id string) (domain.APIKey, error)
- func (r *Repository) GetAccount(ctx context.Context, tenantID, id string) (domain.Account, error)
- func (r *Repository) GetAuditHead(ctx context.Context, tenantID string) (chainSeq int64, rowHash string, ok bool, err error)
- func (r *Repository) GetDispute(ctx context.Context, tenantID, id string) (domain.Dispute, error)
- func (r *Repository) GetIdempotencyKey(ctx context.Context, tenantID, key string) (domain.IdempotencyRecord, error)
- func (r *Repository) GetOrCreateClearingAccount(ctx context.Context, tenantID string, currency domain.Currency) (domain.Account, error)
- func (r *Repository) GetPendingByIdempotencyKey(ctx context.Context, tenantID, key string) (*domain.PendingTransaction, error)
- func (r *Repository) GetPendingTransaction(ctx context.Context, tenantID, id string) (*domain.PendingTransaction, error)
- func (r *Repository) GetReversalOf(ctx context.Context, tenantID, originalID string) (domain.Transaction, error)
- func (r *Repository) GetTenant(ctx context.Context, tenantID string) (domain.Tenant, error)
- func (r *Repository) GetTransaction(ctx context.Context, tenantID, id string) (domain.Transaction, error)
- func (r *Repository) InsertAPIKey(ctx context.Context, k domain.APIKey, keyHash string) error
- func (r *Repository) InsertFXRate(ctx context.Context, tenantID *string, base, quote domain.Currency, ...) error
- func (r *Repository) InsertPendingTransaction(ctx context.Context, tenantID string, p *domain.PendingTransaction) error
- func (r *Repository) LatestAuditAnchor(ctx context.Context, tenantID string) (domain.AuditAnchor, bool, error)
- func (r *Repository) ListAPIKeysByTenant(ctx context.Context, tenantID string) ([]domain.APIKey, error)
- func (r *Repository) ListAccounts(ctx context.Context, tenantID string, limit int) ([]domain.Account, error)
- func (r *Repository) ListAudit(ctx context.Context, tenantID string, after *domain.StatementCursor, limit int) ([]domain.AuditEntry, error)
- func (r *Repository) ListAuditByAccount(ctx context.Context, tenantID, accountID string, after *domain.StatementCursor, ...) ([]domain.AuditEntry, error)
- func (r *Repository) ListAuditByTransaction(ctx context.Context, tenantID, transactionID string) ([]domain.AuditEntry, error)
- func (r *Repository) ListAuditForVerify(ctx context.Context, tenantID string) ([]domain.AuditEntry, error)
- func (r *Repository) ListAuditForVerifyPage(ctx context.Context, tenantID string, afterChainSeq int64, limit int) ([]domain.AuditEntry, error)
- func (r *Repository) ListDisputes(ctx context.Context, tenantID string, status *domain.DisputeStatus, ...) ([]domain.Dispute, error)
- func (r *Repository) ListPendingTransactions(ctx context.Context, tenantID string, status *domain.PendingStatus, ...) ([]domain.PendingTransaction, error)
- func (r *Repository) ListTenants(ctx context.Context, limit int) ([]domain.Tenant, error)
- func (r *Repository) ListTransactions(ctx context.Context, tenantID string, filter domain.TransactionFilter, ...) ([]domain.TransactionListItem, error)
- func (r *Repository) ListWebhookSubscriptionsByTenant(ctx context.Context, tenantID string) ([]domain.WebhookSubscription, error)
- func (r *Repository) MintTenantDEKVersion(ctx context.Context, tenantID string, version int, candidateWrappedDEK []byte) (wrappedDEK []byte, shredded bool, err error)
- func (r *Repository) PendingApprovedForTransaction(ctx context.Context, tenantID, txID string) (bool, error)
- func (r *Repository) ResolveDispute(ctx context.Context, tenantID, id string, status domain.DisputeStatus, ...) (domain.Dispute, error)
- func (r *Repository) RevokeAPIKey(ctx context.Context, id string) error
- func (r *Repository) RolledUpBalance(ctx context.Context, tenantID, accountID string) (domain.Money, error)
- func (r *Repository) RunInTx(ctx context.Context, tenantID string, ...) error
- func (r *Repository) SetAPIKeyScopesByHash(ctx context.Context, keyHash string, scopes []domain.Scope) error
- func (r *Repository) SetAccountParent(ctx context.Context, tenantID, accountID string, parentID *string) (int64, error)
- func (r *Repository) SetAccountStatus(ctx context.Context, tenantID, id string, status domain.AccountStatus) error
- func (r *Repository) SetTenantSettings(ctx context.Context, tenantID string, settings json.RawMessage) error
- func (r *Repository) SetTenantStatus(ctx context.Context, tenantID string, status domain.TenantStatus) error
- func (r *Repository) SetWebhookSubscriptionActive(ctx context.Context, id string, active bool) error
- func (r *Repository) ShredTenantCryptoKey(ctx context.Context, tenantID string) error
- func (r *Repository) Statement(ctx context.Context, tenantID, accountID string, currency domain.Currency, ...) ([]domain.StatementEntry, error)
- func (r *Repository) StatementExport(ctx context.Context, tenantID, accountID string, currency domain.Currency, ...) ([]domain.StatementEntry, error)
- func (r *Repository) SweepExpiredIdempotencyKeys(ctx context.Context) (int64, error)
- func (r *Repository) SweepExpiredPending(ctx context.Context, olderThan time.Duration) ([]domain.PendingTransaction, error)
- func (r *Repository) TenantDEKVersion(ctx context.Context, tenantID string, version int) (wrappedDEK []byte, shredded, found bool, err error)
- func (r *Repository) TouchAPIKeyLastUsed(ctx context.Context, id string, when time.Time) error
- func (r *Repository) TrialBalanceAccounts(ctx context.Context, tenantID string) ([]domain.AccountBalance, error)
- func (r *Repository) TrialBalanceByCurrency(ctx context.Context, tenantID string) ([]domain.CurrencyTotal, error)
Constants ¶
This section is empty.
Variables ¶
var Migrations embed.FS
Migrations holds the goose SQL migration files, embedded so callers (the server at startup, integration tests) can run them without a path on disk. Use it with goose.SetBaseFS and a dir of "migrations".
Functions ¶
func IsUniqueViolationError ¶
IsUniqueViolationError reports whether err is a Postgres unique-violation (23505). Exported so a caller outside this package (cmd/server's idempotent API key provisioning, see ADR-012) can treat "a row with this key already exists" as success rather than an error, without duplicating the pgconn error-code check.
func NewPool ¶
NewPool opens a connection pool tuned for the ledger: a bounded number of connections and per-statement, per-lock, and idle-in-transaction timeouts. maxConns <= 0 uses defaultMaxConns. Callers (the server, tests) should use this rather than pgxpool.New so the safety limits are applied consistently.
Types ¶
type Repository ¶
type Repository struct {
// contains filtered or unexported fields
}
Repository is a domain.Repository backed by a pgx connection pool.
func NewRepository ¶
func NewRepository(pool *pgxpool.Pool) *Repository
NewRepository returns a Repository that uses pool for all queries.
func (*Repository) AllAccountBalances ¶ added in v1.0.0
func (r *Repository) AllAccountBalances(ctx context.Context, tenantID string) ([]domain.AccountBalanceRow, error)
AllAccountBalances returns every account for the tenant with its own derived balance and parent_id (ADR-023), for the caller to build the account tree and roll up balances in memory in one pass.
func (*Repository) Balance ¶
Balance returns the derived balance of an account in the account's currency.
func (*Repository) CountPendingOutbox ¶
CountPendingOutbox returns the number of tenantID's audit_outbox rows the chainer has not yet processed (ADR-017): the audit chain's lag, surfaced by audit verify alongside the chained head.
func (*Repository) CreateAccount ¶
CreateAccount assigns an identity if a.ID is empty, validates the account, and inserts it.
func (*Repository) CreateDispute ¶
CreateDispute assigns an identity if d.ID is empty, validates d, and inserts it (Task 6.3, audit A9.2). The caller (ledger.DisputeService) is expected to have already confirmed d.TransactionID names a real transaction within tenantID; the composite FK (migration 0029, disputes_txn_fk) enforces it here too.
func (*Repository) CreateTenant ¶
func (r *Repository) CreateTenant(ctx context.Context, tenantID, name string) error
CreateTenant inserts a new tenant row with the given id and name, active by default (the column default; this method never sets status explicitly). It returns domain.ErrTenantAlreadyExists if id is already in use.
func (*Repository) CreateTransaction ¶
func (r *Repository) CreateTransaction(ctx context.Context, tenantID string, t *domain.Transaction) error
CreateTransaction validates t and writes the transaction and all its postings atomically. It is a convenience wrapper around RunInTx for the common case of posting a single transaction; it inherits the SERIALIZABLE isolation and retry behavior. Validation happens once here, before the transaction starts, rather than inside the retried unit of work.
func (*Repository) CreateWebhookSubscription ¶
func (r *Repository) CreateWebhookSubscription(ctx context.Context, sub *domain.WebhookSubscription, secret string) error
CreateWebhookSubscription assigns an identity if sub.ID is empty and inserts sub with secret as its stored HMAC signing key (Task 4.1, audit A7.1). It precisely mirrors InsertFXRate's own tenant-existence precheck (a plain GetTenant lookup before ever writing) rather than catching the webhook_subscriptions_tenant_id_fkey violation after the fact, so a missing tenant surfaces as domain.ErrTenantNotFound instead of a raw foreign-key-violation error reaching the caller.
func (*Repository) CurrentTenantDEK ¶
func (r *Repository) CurrentTenantDEK(ctx context.Context, tenantID string) (wrappedDEK []byte, version int, shredded, found bool, err error)
CurrentTenantDEK implements crypto.KeyStore. It runs through withTenant (RLS GUC set to tenantID, migration 0027), the same tenant scoping every other request-path read/write in this file uses: a request that forgot to check its own tenant boundary elsewhere still cannot read another tenant's key.
func (*Repository) GetAPIKeyByHash ¶
GetAPIKeyByHash resolves an unrevoked api_keys row by hash, or domain.ErrAPIKeyNotFound if none exists.
func (*Repository) GetAPIKeyByID ¶
GetAPIKeyByID returns the api_keys row with the given id, revoked or not, or domain.ErrAPIKeyNotFound if none exists (Task 2.2b).
func (*Repository) GetAccount ¶
GetAccount returns the account, or domain.ErrAccountNotFound if absent.
func (*Repository) GetAuditHead ¶
func (r *Repository) GetAuditHead(ctx context.Context, tenantID string) (chainSeq int64, rowHash string, ok bool, err error)
GetAuditHead returns the tenant's current chain head: the chain_seq and row_hash of its latest audit_log row (Task 5.3). ok is false when the tenant has no audit rows yet (pgx.ErrNoRows), not an error: an empty chain simply has no head to report.
func (*Repository) GetDispute ¶
GetDispute returns the dispute, or domain.ErrDisputeNotFound if absent (Task 6.3, audit A9.2).
func (*Repository) GetIdempotencyKey ¶
func (r *Repository) GetIdempotencyKey(ctx context.Context, tenantID, key string) (domain.IdempotencyRecord, error)
GetIdempotencyKey returns the stored record for (tenantID, key), or domain.ErrIdempotencyKeyNotFound if none exists.
func (*Repository) GetOrCreateClearingAccount ¶
func (r *Repository) GetOrCreateClearingAccount(ctx context.Context, tenantID string, currency domain.Currency) (domain.Account, error)
GetOrCreateClearingAccount returns the tenant's per-currency FX clearing account, creating it (as a Liability, System account) on first use. The underlying query is INSERT ... ON CONFLICT (tenant_id, name) WHERE is_system DO UPDATE (a no-op update, just to force Postgres to return the existing row), so two callers racing to create the same tenant's first clearing account for a currency (including two callers in different processes) resolve to the same row rather than creating duplicates, erroring, or (the DO NOTHING version's bug) both losing the race against each other's snapshot and returning no row at all.
func (*Repository) GetPendingByIdempotencyKey ¶ added in v1.0.0
func (r *Repository) GetPendingByIdempotencyKey(ctx context.Context, tenantID, key string) (*domain.PendingTransaction, error)
GetPendingByIdempotencyKey returns the pending transaction whose idempotency_key equals key within the tenant, or domain.ErrPendingTransactionNotFound if none exists (ADR-025 section 6, Lifecycle).
func (*Repository) GetPendingTransaction ¶ added in v1.0.0
func (r *Repository) GetPendingTransaction(ctx context.Context, tenantID, id string) (*domain.PendingTransaction, error)
GetPendingTransaction returns the pending transaction, or domain.ErrPendingTransactionNotFound if absent (Task 4, ADR-025).
func (*Repository) GetReversalOf ¶
func (r *Repository) GetReversalOf(ctx context.Context, tenantID, originalID string) (domain.Transaction, error)
GetReversalOf returns the transaction that reverses originalID within tenantID, or domain.ErrTransactionNotFound if none exists yet (Task 4.2, audit A1.2). transactions_one_reversal_idx (migration 0017) guarantees at most one row can ever match.
func (*Repository) GetTenant ¶
GetTenant returns the tenant with the given id, or domain.ErrTenantNotFound if none exists.
func (*Repository) GetTransaction ¶
func (r *Repository) GetTransaction(ctx context.Context, tenantID, id string) (domain.Transaction, error)
GetTransaction returns the transaction and its postings, or domain.ErrTransactionNotFound if absent.
func (*Repository) InsertAPIKey ¶
InsertAPIKey assigns an identity if k.ID is empty and inserts k with keyHash as its stored credential. Only the hash is ever written. An empty k.Scopes defaults to {read, post} (Task 2.2b), matching the api_keys.scopes column's own default, so every caller that predates scopes (cmd/server's demo and load-test provisioning, and every pre-2.2 test) keeps working unchanged instead of hitting the scopes NOT NULL / api_keys_scopes_valid constraint.
func (*Repository) InsertFXRate ¶
func (r *Repository) InsertFXRate(ctx context.Context, tenantID *string, base, quote domain.Currency, midRateE8 int64, spreadBps int32, source string, effectiveAt *time.Time) error
InsertFXRate appends a new fx_rates row (Task 2.4, audit A3.3). tenantID nil inserts the global default rate (tenant_id NULL); a non-nil tenantID inserts that tenant's own rate, after confirming the tenant exists (a clean domain.ErrTenantNotFound rather than a raw foreign-key-violation error from the fx_rates_tenant_id_fkey constraint).
Validation mirrors the fx_rates CHECK constraints and runs before any write, the same defense-in-depth style CreateAccount and CreateTransaction use elsewhere in this file: base and quote must each be a valid currency code and must differ, midRateE8 must be positive, and spreadBps must be in [0, 10000).
effectiveAt nil leaves the sqlc param unset (pgtype.Timestamptz{Valid: false}), which the InsertFXRate query's COALESCE(sqlc.narg('effective_at'), now()) resolves to the DATABASE SERVER's now(), not this process's clock (see the query's doc comment and domain.Repository.InsertFXRate for why that distinction is a real correctness fix, not a style choice). A non-nil effectiveAt (an explicit, possibly future, scheduled rate) is passed through untouched.
func (*Repository) InsertPendingTransaction ¶ added in v1.0.0
func (r *Repository) InsertPendingTransaction(ctx context.Context, tenantID string, p *domain.PendingTransaction) error
InsertPendingTransaction assigns an identity if p.ID is empty, defaults Status to pending, and inserts p (Task 4, ADR-025): an over-threshold transaction held as intent. Nothing in postings/transactions is touched by this call. It returns domain.ErrInvalidPendingTransaction if p carries an unrecognized Kind, an empty Payload, or an empty ThresholdCcy/CreatedBy.
func (*Repository) LatestAuditAnchor ¶
func (r *Repository) LatestAuditAnchor(ctx context.Context, tenantID string) (domain.AuditAnchor, bool, error)
LatestAuditAnchor returns the tenant's most recently recorded off-box anchor (Task 5.3, migration 0025). ok is false when no anchor has ever been recorded for this tenant (pgx.ErrNoRows), not an error.
func (*Repository) ListAPIKeysByTenant ¶
func (r *Repository) ListAPIKeysByTenant(ctx context.Context, tenantID string) ([]domain.APIKey, error)
ListAPIKeysByTenant returns every api_keys row for tenantID, oldest first, revoked or not (Task 2.2b).
func (*Repository) ListAccounts ¶
func (r *Repository) ListAccounts(ctx context.Context, tenantID string, limit int) ([]domain.Account, error)
ListAccounts returns up to limit of the tenant's accounts, ordered by name.
func (*Repository) ListAudit ¶ added in v1.0.0
func (r *Repository) ListAudit(ctx context.Context, tenantID string, after *domain.StatementCursor, limit int) ([]domain.AuditEntry, error)
ListAudit returns up to limit of the tenant's audit rows, newest first, keyset-paged by id (see ListAuditByAccount for why id drives ordering). The whole-tenant view backing GET /v1/audit.
func (*Repository) ListAuditByAccount ¶
func (r *Repository) ListAuditByAccount(ctx context.Context, tenantID, accountID string, after *domain.StatementCursor, limit int) ([]domain.AuditEntry, error)
ListAuditByAccount returns one keyset page of audit rows for every transaction with a posting touching the account, newest first.
Paging keys on id alone, not (created_at, id) (ADR-017): id is the chainer's true chain-insertion order, and created_at (copied from the originating event's post time) is not guaranteed monotonic with that order under concurrent posts (see GetLastAuditHash's doc comment in internal/postgres/queries/audit.sql). after.CreatedAt is accepted (the domain.StatementCursor type is shared with Statement's own, unrelated posting pagination) but not used here beyond round-tripping through the cursor: only after.ID drives the query.
func (*Repository) ListAuditByTransaction ¶
func (r *Repository) ListAuditByTransaction(ctx context.Context, tenantID, transactionID string) ([]domain.AuditEntry, error)
ListAuditByTransaction returns the audit rows for a transaction, oldest first.
func (*Repository) ListAuditForVerify ¶
func (r *Repository) ListAuditForVerify(ctx context.Context, tenantID string) ([]domain.AuditEntry, error)
ListAuditForVerify returns every audit row for the tenant, oldest first, including PrevHash and RowHash: the full walk used to recompute and check the tamper-evident hash chain end to end.
func (*Repository) ListAuditForVerifyPage ¶
func (r *Repository) ListAuditForVerifyPage(ctx context.Context, tenantID string, afterChainSeq int64, limit int) ([]domain.AuditEntry, error)
ListAuditForVerifyPage returns up to limit of the tenant's audit rows with ChainSeq strictly greater than afterChainSeq, in chain order, including ChainSeq (Task 5.3, audit A2.4): the bounded-memory counterpart to ListAuditForVerify, which AuditService.Verify calls in a loop instead of loading the whole chain at once. Runs through withTenant, exactly like every other tenant-scoped audit read here: paging must keep working with migration 0024's RLS in force for the tenant being verified (Task 5.4b).
func (*Repository) ListDisputes ¶
func (r *Repository) ListDisputes(ctx context.Context, tenantID string, status *domain.DisputeStatus, after *domain.StatementCursor, limit int) ([]domain.Dispute, error)
ListDisputes returns up to limit of the tenant's disputes, newest first, keyset paged, optionally filtered by status (Task 6.3, audit A9.2).
func (*Repository) ListPendingTransactions ¶ added in v1.0.0
func (r *Repository) ListPendingTransactions(ctx context.Context, tenantID string, status *domain.PendingStatus, after *domain.StatementCursor, limit int) ([]domain.PendingTransaction, error)
ListPendingTransactions returns up to limit of the tenant's pending transactions, newest first, keyset paged, optionally filtered by status (Task 4, ADR-025).
func (*Repository) ListTenants ¶
ListTenants returns up to limit tenants, oldest first.
func (*Repository) ListTransactions ¶
func (r *Repository) ListTransactions(ctx context.Context, tenantID string, filter domain.TransactionFilter, after *domain.StatementCursor, limit int) ([]domain.TransactionListItem, error)
ListTransactions returns up to limit of tenantID's transactions matching filter, newest first, keyset paged by (created_at, id) descending, the same cursor shape Statement uses (Task 4.4, audit A7.2). after is the keyset position to page from; nil starts at the newest transaction.
Postings for the whole returned page are fetched in one extra batched round trip (ListPostingsByTransactionIDs) rather than one query per transaction, so this stays O(1) queries regardless of how many transactions the page contains, not O(n).
func (*Repository) ListWebhookSubscriptionsByTenant ¶
func (r *Repository) ListWebhookSubscriptionsByTenant(ctx context.Context, tenantID string) ([]domain.WebhookSubscription, error)
ListWebhookSubscriptionsByTenant returns every webhook_subscriptions row for tenantID, oldest first, active or not (Task 4.1). Never carries a secret: domain.WebhookSubscription has no field for one.
func (*Repository) MintTenantDEKVersion ¶
func (r *Repository) MintTenantDEKVersion(ctx context.Context, tenantID string, version int, candidateWrappedDEK []byte) (wrappedDEK []byte, shredded bool, err error)
MintTenantDEKVersion implements crypto.KeyStore. Like CurrentTenantDEK, it runs through withTenant, even though the mint statement itself also takes a per-tenant advisory lock (see queries/crypto_keys.sql's own comment): the RLS scoping and the advisory-lock serialization guard against two different things (a forgotten tenant boundary vs. a mint/shred race) and are both applied uniformly with every other write in this file.
func (*Repository) PendingApprovedForTransaction ¶ added in v1.0.0
func (r *Repository) PendingApprovedForTransaction(ctx context.Context, tenantID, txID string) (bool, error)
PendingApprovedForTransaction reports whether an approved pending transaction produced txID (Task 6, ADR-025): the reverse-of-approved exemption's read, called by TransactionService.ReverseTransaction before gating a reversal, never from inside RunInTx.
func (*Repository) ResolveDispute ¶
func (r *Repository) ResolveDispute(ctx context.Context, tenantID, id string, status domain.DisputeStatus, resolutionTransactionID *string) (domain.Dispute, error)
ResolveDispute transitions the dispute from open to status, stamping resolution_transaction_id and resolved_at (Task 6.3, audit A9.2). It returns domain.ErrDisputeAlreadyResolved if the guarded UPDATE (WHERE status = 'open') affected zero rows because the dispute is not currently open, or domain.ErrDisputeNotFound if no dispute matches id within tenantID at all: a second read (GetDispute) after the zero-row UPDATE distinguishes the two, since the guarded UPDATE alone cannot tell "does not exist" from "exists but already resolved".
func (*Repository) RevokeAPIKey ¶
func (r *Repository) RevokeAPIKey(ctx context.Context, id string) error
RevokeAPIKey sets revoked_at (if not already set) for the key identified by id (Task 2.2b), or returns domain.ErrAPIKeyNotFound if no key matches id.
func (*Repository) RolledUpBalance ¶ added in v1.0.0
func (r *Repository) RolledUpBalance(ctx context.Context, tenantID, accountID string) (domain.Money, error)
RolledUpBalance returns the balance of accountID and all its descendants (ADR-023). It returns domain.ErrAccountNotFound if accountID does not exist: GetAccount is used first, both to produce that clear error (rather than a silent zero from an empty recursive base row) and to get the account's own currency for the returned Money.
func (*Repository) RunInTx ¶
func (r *Repository) RunInTx(ctx context.Context, tenantID string, fn func(context.Context, domain.Tx) error) error
RunInTx executes fn inside a SERIALIZABLE transaction, committing on success and rolling back on error. SERIALIZABLE can abort a transaction with a serialization conflict (SQLSTATE 40001), and the conflict often surfaces only at COMMIT, so both fn and the commit are watched and the whole unit of work is replayed up to maxPostAttempts times. fn must therefore be safe to run more than once. Each attempt acquires its own connection from the pool via BeginTx and releases it (Commit or Rollback) before the next attempt's backoff wait: no connection is ever held across a backoff or across attempts.
Until ADR-017, RunInTx also acquired tenantID's lock from an in-process keyed mutex before opening any transaction, and held it for every attempt: each post read the tenant's latest audit row_hash and then inserted the next one (ADR-012), and two concurrent same-tenant attempts racing on that read were a genuine read-write antidependency that SERIALIZABLE would abort, which under high same-tenant concurrency could repeatedly exhaust the retry budget and surface as a 503. Worse, that mutex lived in one process's memory: with more than one instance, two instances could still post the same tenant concurrently, both read the same chain head, and fork the chain, since SERIALIZABLE is not guaranteed to see every such conflict across processes (ADR-017's Context).
ADR-017 removes the audit chain read from the posting transaction entirely: a post now writes an append-only outbox row (see domain.Tx.AppendAuditOutbox), and a single background chainer (internal/audit.Chainer) builds the chain asynchronously, so no posting transaction ever reads or extends a chain head. That removes the reason for the mutex, so RunInTx no longer takes one: same-tenant calls, from any number of instances, now run fully concurrently, serialized only by whatever SERIALIZABLE itself detects (the balance invariant and the idempotency primary key, both still enforced in-transaction) and retried exactly as any other serialization conflict is.
func (*Repository) SetAPIKeyScopesByHash ¶
func (r *Repository) SetAPIKeyScopesByHash(ctx context.Context, keyHash string, scopes []domain.Scope) error
SetAPIKeyScopesByHash overwrites the scopes of the api_keys row matching keyHash to exactly scopes (ADR-019 follow-up): unlike InsertAPIKey, which is insert-or-ignore on the unique key_hash, this always applies, so it is how a caller reconciles an already-existing key's scopes (for example the demo key's, on every boot) without needing that row's id. A hash with no matching row affects zero rows and returns nil.
func (*Repository) SetAccountParent ¶ added in v1.0.0
func (r *Repository) SetAccountParent(ctx context.Context, tenantID, accountID string, parentID *string) (int64, error)
SetAccountParent sets, changes, or clears (parentID nil) accountID's parent (ADR-023). It returns the number of rows updated (0 if no such account). Cycle, currency, and same-tenant are enforced by accounts_hierarchy_guard and the accounts_parent_fk composite foreign key, mapped here via mapHierarchyErr into domain.ErrInvalidHierarchy / domain.ErrParentNotFound.
func (*Repository) SetAccountStatus ¶
func (r *Repository) SetAccountStatus(ctx context.Context, tenantID, id string, status domain.AccountStatus) error
SetAccountStatus updates the account's status (Task 5.5, audit A1.5). It returns domain.ErrInvalidAccount if status is not one of AccountStatus.Valid()'s three values, or domain.ErrAccountNotFound if no account matches id within tenantID.
func (*Repository) SetTenantSettings ¶
func (r *Repository) SetTenantSettings(ctx context.Context, tenantID string, settings json.RawMessage) error
SetTenantSettings overwrites the tenant's settings jsonb column with settings (Task 2.4b, audit A3.4): a whole-document replace, not a merge (see domain.Repository.SetTenantSettings). It returns domain.ErrTenantNotFound if no tenant matches id.
func (*Repository) SetTenantStatus ¶
func (r *Repository) SetTenantStatus(ctx context.Context, tenantID string, status domain.TenantStatus) error
SetTenantStatus updates the tenant's status. It returns domain.ErrInvalidTenant if status is not one of TenantStatus.Valid()'s three values, or domain.ErrTenantNotFound if no tenant matches id.
func (*Repository) SetWebhookSubscriptionActive ¶
func (r *Repository) SetWebhookSubscriptionActive(ctx context.Context, id string, active bool) error
SetWebhookSubscriptionActive sets active for the subscription identified by id (Task 4.1), or returns domain.ErrWebhookSubscriptionNotFound if no subscription matches id.
func (*Repository) ShredTenantCryptoKey ¶
func (r *Repository) ShredTenantCryptoKey(ctx context.Context, tenantID string) error
ShredTenantCryptoKey implements domain.Repository.ShredTenantCryptoKey (Task 6.2, audit A9.3; versioned per ADR-018): see that interface method's doc comment for the full crypto-shredding argument. It destroys only tenantID's CURRENT (highest-version) key: internal/crypto.Cipher's next Encrypt call for this tenant mints a fresh, forward version rather than failing closed forever, so the tenant keeps operating after an erasure request. Run through withTenant like every other tenant-scoped write in this file, even though this is an admin-only operation (internal/admin.Service.ShredTenantPII is its only caller today): the RLS backstop applies uniformly, not just to the ordinary request path.
func (*Repository) Statement ¶
func (r *Repository) Statement(ctx context.Context, tenantID, accountID string, currency domain.Currency, after *domain.StatementCursor, limit int) ([]domain.StatementEntry, error)
Statement returns one keyset page of the account's postings, newest first, each with its running balance, in the given currency.
func (*Repository) StatementExport ¶
func (r *Repository) StatementExport(ctx context.Context, tenantID, accountID string, currency domain.Currency, from, to *time.Time, limit int) ([]domain.StatementEntry, error)
StatementExport returns up to limit postings affecting the account within an optional [from, to) created_at window, newest first, each with its running balance (Task 6.3, audit A9.2): the per-account period statement export's bounded, unpaged counterpart to Statement.
func (*Repository) SweepExpiredIdempotencyKeys ¶
func (r *Repository) SweepExpiredIdempotencyKeys(ctx context.Context) (int64, error)
SweepExpiredIdempotencyKeys deletes every idempotency_keys row whose expires_at has passed, across every tenant, and returns how many rows it deleted in total (Task 4.5, audit A1.4). It runs directly against the pool, not inside RunInTx: it is a plain maintenance statement, never part of a request's unit of work.
It deletes in bounded batches of sweepBatchSize rows (a single unbounded DELETE could lock and remove an arbitrarily large number of rows in one statement, contending with live posts under a large backlog) rather than one statement for the whole table. It loops the batched delete until a batch reports 0 rows deleted, up to sweepMaxIterations batches as a safety valve, respecting context cancellation between batches. It is best-effort maintenance: an error from any batch is returned (and logged by the caller) without panicking, and rows already deleted by prior batches in this call stay deleted.
func (*Repository) SweepExpiredPending ¶ added in v1.0.0
func (r *Repository) SweepExpiredPending(ctx context.Context, olderThan time.Duration) ([]domain.PendingTransaction, error)
SweepExpiredPending moves every still-pending row older than olderThan to domain.PendingStatusExpired (decided_by "system"), across every tenant, and returns the rows it expired (Task 4, ADR-025). Not tenant-scoped and not run inside RunInTx, the same convention SweepExpiredIdempotencyKeys follows: it runs directly against r.q (the pool), with no app.tenant_id GUC ever set on that connection, so the RLS policy's allow-when-unset branch is what lets it see and update every tenant's rows.
func (*Repository) TenantDEKVersion ¶
func (r *Repository) TenantDEKVersion(ctx context.Context, tenantID string, version int) (wrappedDEK []byte, shredded, found bool, err error)
TenantDEKVersion implements crypto.KeyStore. Unlike CurrentTenantDEK it never creates a row and never looks past the exact version requested: found is false when tenantID has no crypto_keys row at that version at all.
func (*Repository) TouchAPIKeyLastUsed ¶
TouchAPIKeyLastUsed sets api_keys.last_used_at for the key identified by id. Called best-effort and throttled from the auth resolver (Task 2.2), so its caller (internal/auth.Resolver) fires it asynchronously and ignores its error beyond a debug log: a failed touch must never fail the request it rode in on.
func (*Repository) TrialBalanceAccounts ¶
func (r *Repository) TrialBalanceAccounts(ctx context.Context, tenantID string) ([]domain.AccountBalance, error)
TrialBalanceAccounts returns every one of tenantID's accounts with its derived balance, including system (FX clearing) accounts (Task 6.3, audit A9.2).
func (*Repository) TrialBalanceByCurrency ¶
func (r *Repository) TrialBalanceByCurrency(ctx context.Context, tenantID string) ([]domain.CurrencyTotal, error)
TrialBalanceByCurrency returns tenantID's net posted total per currency (Task 6.3, audit A9.2): the double-entry balance proof. Runs under withTenant, so it inherits the RLS defense in depth every other tenant-scoped read here does.