Post balanced journal entries, keep an append-only ledger and emit transaction events for statements and reconciliation, with Encore in your own AWS account.
A payment ledger is the book of record for who owes whom. Wallets, neobanks and processors all end up with the same shape: accounts, append-only journal entries, and a hard rule that every entry's legs sum to zero. The API that posts those entries has to be idempotent, because a timeout after commit is normal, and it has to emit events so statements, fraud checks and partner reports do not run inside the posting transaction.
This guide builds a small double-entry ledger in Go on AWS: post a balanced entry, reject unbalanced ones, publish a domain event and reconcile account balances on a schedule, covering the hand-built AWS path and the same shape in application code. Pave Bank built a licensed bank on Encore while keeping full control of their own GCP account, and Echo ran a fintech Go backend on Encore in their own AWS account from day one through a Coinbase acquisition.
accounts, journal_entries and journal_legs. Enforce the balance rule in the posting transaction: reject the write when SUM(amount) <> 0, and unique-index idempotency_key.Post runs.ledger-posted with an SQS subscription for the statements worker. After commit, the API publishes the entry id; if publish fails, an outbox table polled by a worker is the durable fix.GetSecretValue on those ARNs only, and card PANs never enter this database.A posting transaction without a framework looks like:
// Hand-rolled: reject unbalanced legs, then insert entry + legs in one tx.
func post(ctx context.Context, db *sql.DB, key, memo string, legs []Leg) (int64, error) {
var sum int64
for _, leg := range legs {
sum += leg.Amount
}
if sum != 0 {
return 0, fmt.Errorf("legs must sum to zero")
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
// SELECT existing by idempotency_key, else INSERT entry + legs, COMMIT.
return 0, tx.Commit()
}
Terraform typically owns the VPC, RDS, ECS service, SNS/SQS, Secrets Manager entries, EventBridge rule and IAM roles. Locally you run Postgres in Docker Compose and either a real SNS topic in a sandbox account or an emulator, and you re-apply the same modules for staging and production.
Encore combines an open source infrastructure SDK with a platform that provisions and deploys into your own AWS or GCP account. The ledger is a Postgres database for accounts and journal legs, a Pub/Sub topic for posted events, a cron job for reconciliation and secrets for any payment-processor API key the service calls.

Run in your terminal to get started locally.
-- ledger/migrations/1_create_ledger.up.sql
CREATE TABLE accounts (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
currency TEXT NOT NULL CHECK (char_length(currency) = 3),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE journal_entries (
id BIGSERIAL PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
memo TEXT NOT NULL DEFAULT '',
posted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE journal_legs (
id BIGSERIAL PRIMARY KEY,
entry_id BIGINT NOT NULL REFERENCES journal_entries(id),
account_id BIGINT NOT NULL REFERENCES accounts(id),
-- Positive amount = debit, negative = credit, in minor units.
amount BIGINT NOT NULL,
currency TEXT NOT NULL CHECK (char_length(currency) = 3)
);
CREATE INDEX journal_legs_account_id_idx ON journal_legs (account_id);
// ledger/ledger.go
package ledger
import (
"context"
"errors"
"time"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/cron"
"encore.dev/pubsub"
"encore.dev/rlog"
"encore.dev/storage/sqldb"
)
var db = sqldb.NewDatabase("ledger", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
// Optional: call a processor for payouts. Values are not stored in the repo.
var secrets struct {
StripeSecretKey string
AuthToken string // local/dev bearer; replace with IdP verification in production
}
type Leg struct {
AccountCode string `json:"account_code"`
Amount int64 `json:"amount"` // minor units; debit > 0, credit < 0
Currency string `json:"currency"`
}
type PostParams struct {
IdempotencyKey string `json:"idempotency_key"`
Memo string `json:"memo"`
Legs []Leg `json:"legs"`
}
type PostResponse struct {
EntryID int64 `json:"entry_id"`
}
type LedgerPosted struct {
EntryID int64 `json:"entry_id"`
IdempotencyKey string `json:"idempotency_key"`
PostedAt time.Time `json:"posted_at"`
}
var Posted = pubsub.NewTopic[*LedgerPosted]("ledger-posted", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
//encore:authhandler
func AuthHandler(ctx context.Context, token string) (auth.UID, error) {
// Local/dev: compare to a secret. In production, verify a JWT from your IdP.
if token == "" || token != secrets.AuthToken {
return "", &errs.Error{Code: errs.Unauthenticated, Message: "invalid token"}
}
return auth.UID("teller"), nil
}
// Post writes a balanced entry or returns the existing one for the same key.
//
//encore:api auth method=POST path=/ledger/entries
func Post(ctx context.Context, p *PostParams) (*PostResponse, error) {
if len(p.Legs) < 2 {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "need at least two legs"}
}
var sum int64
currency := p.Legs[0].Currency
for _, leg := range p.Legs {
if leg.Currency != currency {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "mixed currencies"}
}
sum += leg.Amount
}
if sum != 0 {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "legs must sum to zero"}
}
tx, err := db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback()
var existing int64
err = tx.QueryRow(ctx, `
SELECT id FROM journal_entries WHERE idempotency_key = $1
`, p.IdempotencyKey).Scan(&existing)
if err == nil {
return &PostResponse{EntryID: existing}, nil
}
if !errors.Is(err, sqldb.ErrNoRows) {
return nil, err
}
var entryID int64
if err := tx.QueryRow(ctx, `
INSERT INTO journal_entries (idempotency_key, memo)
VALUES ($1, $2) RETURNING id
`, p.IdempotencyKey, p.Memo).Scan(&entryID); err != nil {
return nil, err
}
for _, leg := range p.Legs {
var accountID int64
var accountCurrency string
if err := tx.QueryRow(ctx, `
SELECT id, currency FROM accounts WHERE code = $1
`, leg.AccountCode).Scan(&accountID, &accountCurrency); err != nil {
return nil, &errs.Error{Code: errs.NotFound, Message: "unknown account " + leg.AccountCode}
}
if accountCurrency != leg.Currency {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "currency mismatch for " + leg.AccountCode}
}
if _, err := tx.Exec(ctx, `
INSERT INTO journal_legs (entry_id, account_id, amount, currency)
VALUES ($1, $2, $3, $4)
`, entryID, accountID, leg.Amount, leg.Currency); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
if _, err := Posted.Publish(ctx, &LedgerPosted{
EntryID: entryID,
IdempotencyKey: p.IdempotencyKey,
PostedAt: time.Now().UTC(),
}); err != nil {
// Entry is committed; returning an error would confuse the client.
// A follow-up outbox or retry of the publish is the durable fix.
rlog.Error("publish ledger-posted failed", "entry_id", entryID, "err", err)
}
_ = secrets.StripeSecretKey // available for payouts that reference this entry
return &PostResponse{EntryID: entryID}, nil
}
type BalanceResponse struct {
AccountCode string `json:"account_code"`
Balance int64 `json:"balance"`
Currency string `json:"currency"`
}
//encore:api auth method=GET path=/ledger/accounts/:code/balance
func Balance(ctx context.Context, code string) (*BalanceResponse, error) {
var out BalanceResponse
err := db.QueryRow(ctx, `
SELECT a.code, COALESCE(SUM(l.amount), 0), a.currency
FROM accounts a
LEFT JOIN journal_legs l ON l.account_id = a.id AND l.currency = a.currency
WHERE a.code = $1
GROUP BY a.code, a.currency
`, code).Scan(&out.AccountCode, &out.Balance, &out.Currency)
if err != nil {
return nil, &errs.Error{Code: errs.NotFound, Message: "account not found"}
}
return &out, nil
}
// Daily check that every journal entry's legs still sum to zero.
var _ = cron.NewJob("reconcile-ledger", cron.JobConfig{
Title: "Reconcile ledger entries",
Schedule: "0 3 * * *",
Endpoint: Reconcile,
})
//encore:api private
func Reconcile(ctx context.Context) error {
rows, err := db.Query(ctx, `
SELECT entry_id, SUM(amount) AS s
FROM journal_legs
GROUP BY entry_id
HAVING SUM(amount) <> 0
`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var entryID, sum int64
if err := rows.Scan(&entryID, &sum); err != nil {
return err
}
rlog.Error("unbalanced entry", "entry_id", entryID, "sum", sum)
}
return rows.Err()
}
With encore run, posting a transfer that debits cash by 1000 and credits customer:42 by -1000 commits one entry, a second post with the same idempotency key returns the same ID, and an unbalanced post is rejected before any write. On deploy, Encore provisions RDS Postgres and SNS/SQS (or Pub/Sub on GCP) in your account. Encore provisions an IAM role for the service on AWS or a service account on GCP. The cron job is Encore platform Managed: the platform calls Reconcile on schedule with no EventBridge rule in your account. Secret values for processor keys are set per environment and never land in the repo.
Card numbers and CVVs stay with the processor, and the ledger stores amounts, account codes and external payment references only, which helps keep the ledger service out of PCI scope, as your QSA determines. Encore holds no PCI DSS attestation on its compliance page.
Best fit: fintech and payments teams that need an append-only double-entry ledger in their own AWS or GCP account, with posting, events and reconciliation next to the code that owns the money movement.
Fintech teams that want posting, events and reconciliation next to the ledger code can install Encore and deploy the same Go service into RDS, SNS/SQS and an IAM role in their own AWS or GCP account.
Every money movement posts at least two legs that sum to zero: a debit on one account and a credit on another. The ledger is append-only, balances are derived from the sum of legs, and a posting is rejected if the legs do not balance. That model is the backbone of books for banks, wallets and payment processors.
Store accounts and journal legs in Postgres with sqldb.NewDatabase, post entries in a single transaction that checks the legs sum to zero, publish a LedgerPosted event with pubsub.NewTopic for downstream statements, and use cron.NewJob for daily reconciliation. Encore provisions RDS and SNS/SQS in your own AWS account and an IAM role for the service.
Require a client-supplied idempotency key on each post, unique in the journal_entries table. A retry with the same key returns the original entry instead of inserting a second balanced pair, which is required when the caller timed out after the first commit.
Keep primary account numbers and CVVs with your payment processor or a PCI vault. The ledger stores amounts, currency, account IDs and references to external payment IDs, which helps keep the ledger service out of PCI scope, as your QSA determines.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.