Skip to content

The 80% is a number the ledger can stand behind now - #13

Merged
ralyodio merged 1 commit into
mainfrom
worktree-revenue-ledger
Sep 6, 2026
Merged

The 80% is a number the ledger can stand behind now#13
ralyodio merged 1 commit into
mainfrom
worktree-revenue-ledger

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Phase 3 of the Knowledge Influencer PRD: the ledger that makes the 20–80% share something the database can stand behind, rather than a number rendered on a page.

What is here

  • Migration 0011revenue_events, revenue_allocations, payout_accounts, payouts, payout_allocations.
  • packages/db/src/revenue.js — recording, finalisation, balances, payout scheduling and settlement.
  • Routes — one signed internal endpoint, /api/v1/me/revenue, /api/v1/me/payouts, /dashboard/payouts, /dashboard/niches/<slug>/revenue, /admin/payouts.
  • Docsdocs/revenue-ledger.md.

The allocation arithmetic (allocate, attributableNetMinor) was already written and tested in Phase 1; this wires it to storage.

The rule that shapes it

An allocation records the share that was in force when the event settled. Reaching Expert tomorrow does not reach back and re-price yesterday's sale at 40%.

Verified by driving it against a real Postgres:

1000c less 30c fee -> net 970
split: knowledge_influencer 2000bps 194c | platform 8000bps 776c

sarah is now expert at 4000 bps
new sale splits: knowledge_influencer 4000bps 400c | platform 6000bps 600c
the earlier sale still says: 2000bps 194c   <- not rewritten

The niche revenue page shows today's split and each settled allocation's own share side by side, and explains why they can disagree.

What the database refuses

Each of these is a way somebody gets paid twice or paid wrong, so each is a constraint rather than a code path that has to remember:

revenue_events_adds_up a row where net ≠ gross − cost cannot be written
external_id unique a settlement delivered twice books once
revenue_allocations_once one allocation per party per event — including the platform row whose influencer_id is null, which a plain unique index over a nullable column would not catch
payout_allocations PK on allocation_id alone an allocation belongs to at most one payout, so the same earning cannot be paid twice however many times the button is pressed
payouts_positive a payout of nothing is not a payout

Finalisation claims the event with update … where finalized_at is null inside the transaction that writes the allocations, so two requests racing produce one split.

One of these caught a bug in my own test, which had tried to allocate five times to the same person out of one earning.

Payouts send nothing, deliberately

CoinPay's payout API pays a connected merchant account (us), not an arbitrary third party's address — there is no automated disbursement to call. The partner programme already has this shape, so this matches it: accrue accurately, pay deliberately. An operator saves an address, an admin confirms it and schedules, the money moves out of band, the reference is recorded.

Two safeguards: changing an address clears its confirmation (whoever confirmed the old one did not confirm this one), and a failed payout returns its allocations to owed rather than stranding them in a state nothing picks up again.

Verification

  • bun test165 pass, 0 fail (12 new ledger schema tests, 4 money-rendering, plus the existing suite). Needs no server.
  • bun run lint — clean; 4 CSS warnings and the biome.json deprecation are pre-existing on main.
  • Driven end to end against Postgres 18: unattributed earning → all platform; attributed → split; redelivery → no second allocation; promotion → new share applies only forward; payout refused with no address, refused unverified, scheduled once, "scheduled again" finds nothing owed; paid moves the balance; failed returns it to owed. Every finalised event allocates exactly its net, checked across all of them.
  • Signed endpoint probed: unsigned 401, signed 201 (776 + 1164 = 1940), redelivery 200 duplicate, unknown source type 400, negative gross 400.
  • All five pages render 200; money renders as $45.00-style currency with no raw cent integers leaking.
  • Production image builds.

No new environment variables (the endpoint reuses CHOVY_SIGNING_SECRET).

What is next

Phase 4 is now small: machineRevenueEvent already normalises what the x402 gateway hands onSale, and it is tested — it needs wiring to recordRevenueEvent.

🤖 Generated with Claude Code

https://claude.ai/code/session_01W9NGGLDvNayi6uWheSXDGF

Phase 3 of the Knowledge Influencer PRD. Until now an operator could climb to
80% and the number was a label on a page. An earning now arrives, is finalised,
and at that instant is divided between the niche's operators and the platform.

The rule that shapes everything: an allocation records the share that was in
force when the event settled. Reaching Expert tomorrow does not reach back and
re-price yesterday's sale at 40%. The niche revenue page shows today's split
and each settled allocation's own share side by side, and says why they can
disagree. Verified by driving it: a sale split 2000bps/194c, the operator was
then promoted to 4000bps, the next sale split 4000bps/400c, and the first row
still says 2000bps/194c.

Money is integer minor units and shares are integer basis points the whole way.
The only division is formatMinor, at the moment a number is handed to a person.

The things that would pay somebody twice are constraints rather than code paths
that have to remember. A row whose net is not gross minus cost cannot be
written. external_id is unique, so a settlement delivered twice books once.
One allocation per party per event, including the platform row whose influencer
is null, which a plain unique index over a nullable column would not catch. And
payout_allocations has its primary key on allocation_id alone: an allocation
belongs to at most one payout, so the same earning cannot be paid twice however
many times the button is pressed. Finalisation claims the event inside the
transaction that writes the split, so a race produces one split rather than two.

Nothing sends money, and that is deliberate rather than unfinished. CoinPay's
payout API pays a connected merchant account, not an arbitrary third party's
address, so there is no automated disbursement to call. An operator saves an
address, an admin confirms it and schedules, the money moves out of band, the
reference is recorded. Same shape as the partner programme. Changing an address
clears its confirmation, and a failed payout returns its allocations to owed
rather than stranding them.

Phase 4 is now small: machineRevenueEvent already normalises what the x402
gateway hands onSale, and it is tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W9NGGLDvNayi6uWheSXDGF
@ralyodio
ralyodio merged commit 505d76a into main Sep 6, 2026
3 checks passed
@ralyodio
ralyodio deleted the worktree-revenue-ledger branch September 6, 2026 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant