Scrooge & Marley · 1 Cornhill, London · Est. 1836
📋 Explanation · Proposed Extension 01 of 03

THE LEDGER

Debt tracking, repayment scheduling, and account status — for creditors who require more than a folder of letters
Version 1.0 | March 2026 | Reviewed by Dev the Dev
Version 1.0 | March 2026 | Reviewed by Dev the Dev
What this document is A technical specification for The Ledger — the Scrooge & Marley debt tracking extension. It covers the business case, the full database schema, new admin routes, the creditor-facing status page, and the effort estimate. Read this before beginning implementation. The Dead Letter Office and The Counting Room both depend on this extension — build this first.

1. The business case

A counting house that manages debt recovery by email and memory is not a counting house — it is a hope. The Firm currently has no structured record of who owes what, what has been promised, what has been paid, and what remains outstanding. Every engagement lives in a correspondence thread. That is the condition of a business that does not yet believe it will survive long enough to need records.

The Firm is older than most of its debtors' grandparents. It requires a ledger.

"An unrecorded debt is a forgiven debt. We do not forgive debts."

2. What it does

The Ledger is a structured debt management system within the admin dashboard. Each debt is a record: debtor name and contact, principal amount, date incurred, interest rate, repayment schedule (if any), payments received to date, outstanding balance calculated to the day, and current status.

The creditor who engaged the Firm is linked to each debt. The Firm's fee is tracked separately from the principal. Neither field is optional.

The creditor status page

The public-facing component is a secure, token-authenticated status page a creditor can visit to see the current state of their engagement. The page shows exactly three things: status, amount recovered to date, and outstanding balance. Nothing more. The Firm does not explain itself to creditors — it reports to them.

Each creditor receives a unique URL containing a token. The token is generated by the Firm and delivered by correspondence. It does not expire by default, but can be revoked or set with an expiry at the Firm's discretion. The token grants read-only access to the single debt it references. It grants nothing else.

3. Database schema

Five new tables. All in Neon PostgreSQL. All with row-level security enabled.

-- Debtors
CREATE TABLE IF NOT EXISTS debtors (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  address TEXT,
  contact_email TEXT,
  notes TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE debtors ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_debtors" ON debtors
  FOR ALL USING (true) WITH CHECK (true);

-- Debt accounts
CREATE TABLE IF NOT EXISTS debts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  debtor_id UUID NOT NULL REFERENCES debtors(id) ON DELETE RESTRICT,
  creditor_name TEXT NOT NULL,         -- who engaged the Firm
  principal_pence INTEGER NOT NULL,    -- stored in pence — no floats near money
  interest_rate_bps INTEGER DEFAULT 0, -- basis points per annum (500 = 5.00%)
  date_incurred DATE NOT NULL,
  date_referred DATE NOT NULL,         -- when the Firm took it on
  firm_fee_pence INTEGER DEFAULT 0,    -- the Firm's recovery fee, tracked separately
  status TEXT DEFAULT 'active',        -- active | settled | written_off | disputed
  notes TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE debts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_debts" ON debts
  FOR ALL USING (true) WITH CHECK (true);

-- Repayment schedule (optional — not all debts have agreed schedules)
CREATE TABLE IF NOT EXISTS repayment_schedule (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE CASCADE,
  due_date DATE NOT NULL,
  amount_pence INTEGER NOT NULL,
  status TEXT DEFAULT 'pending',       -- pending | paid | missed | waived
  paid_date DATE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE repayment_schedule ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_schedule" ON repayment_schedule
  FOR ALL USING (true) WITH CHECK (true);

-- Payments received
CREATE TABLE IF NOT EXISTS payments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE CASCADE,
  amount_pence INTEGER NOT NULL,
  received_date DATE NOT NULL,
  method TEXT,                         -- cash | draft | cheque | wire
  notes TEXT,
  recorded_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_payments" ON payments
  FOR ALL USING (true) WITH CHECK (true);

-- Creditor access tokens (for the public status page)
CREATE TABLE IF NOT EXISTS creditor_tokens (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE CASCADE,
  token TEXT NOT NULL UNIQUE,          -- UUID v4, generated server-side
  expires_at TIMESTAMPTZ,              -- NULL = no expiry
  revoked BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE creditor_tokens ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_tokens" ON creditor_tokens
  FOR ALL USING (true) WITH CHECK (true);

Outstanding balance calculation

The outstanding balance is never stored — it is computed at render time from the principal, accrued interest, and payments received. Storing it would create a second source of truth that will eventually disagree with the first.

-- Outstanding balance query (run at render time, not stored)
SELECT
  d.principal_pence,
  d.interest_rate_bps,
  d.date_incurred,
  COALESCE(SUM(p.amount_pence), 0) AS total_paid_pence,

  -- Accrued interest: principal × rate × (days elapsed / 365)
  -- Calculated in pence throughout; round only at display layer
  ROUND(
    d.principal_pence
    * (d.interest_rate_bps::NUMERIC / 10000)
    * (CURRENT_DATE - d.date_incurred) / 365.0
  )::INTEGER AS accrued_interest_pence,

  d.principal_pence
    + ROUND(
        d.principal_pence
        * (d.interest_rate_bps::NUMERIC / 10000)
        * (CURRENT_DATE - d.date_incurred) / 365.0
      )::INTEGER
    - COALESCE(SUM(p.amount_pence), 0)
  AS outstanding_pence

FROM debts d
LEFT JOIN payments p ON p.debt_id = d.id
WHERE d.id = $1
GROUP BY d.id;
No floats near money All monetary values are stored in pence (integer). All calculations use integer or NUMERIC arithmetic in PostgreSQL. JavaScript receives integers. Formatting to pounds and pence (£X,XXX.XX) happens only at the display layer. A float that represents £1,234.56 as 1234.5600000000002 has no place in a ledger.

4. New routes

Admin routes

Admin API routes

Public route (token-gated)

5. Creditor status page

The /account/[token] route is a public page — no login required — but the token must be valid, unexpired, and not revoked. If any of these conditions fail, the page returns a 404. It does not explain why. It does not invite the visitor to try a different token. The Firm does not assist people who arrive without proper credentials.

The page renders three values and nothing else:

FieldValue shownNotes
Status Active / Settled / Disputed / Written Off The status field from the debt record, formatted for a layperson.
Recovered to date £X,XXX.XX Sum of all payments received, formatted from pence.
Outstanding £X,XXX.XX Computed at render time. Includes accrued interest.

The page does not show the debtor's name, the creditor's name, the debt reference, the interest rate, or any correspondence history. A token grants the right to know the current state of one engagement. It grants nothing else.

6. Effort and dependencies

ItemDetail
Effort estimateMedium — 8–12 days
New database tables5
New admin routes4 pages, 7 API routes
New public routes1 (token-gated)
External dependenciesResend (for token delivery by email — optional at launch)
Required byThe Dead Letter Office, The Counting Room — both depend on this extension

The schema and routes are straightforward. The complexity is in the outstanding balance calculation — interest accrual to the day, partial payments applied correctly, balance computed at render time not stored. The creditor token page introduces a new auth pattern (token-in-URL rather than session cookie) but is entirely self-contained and does not touch the existing admin auth.

Build this first The Dead Letter Office requires debt records to trigger against. The Counting Room requires debt records to assign to clerks. Neither can be built without The Ledger in place. Do not begin the other two extensions until this one is complete and tested.