From 23cf486f480cd4ba332333669f9e2540ab5b4d03 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 10:38:43 +0000 Subject: [PATCH] The 80% is a number the ledger can stand behind now 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) Claude-Session: https://claude.ai/code/session_01W9NGGLDvNayi6uWheSXDGF --- README.md | 5 +- apps/web/src/app.js | 4 + apps/web/src/routes/revenue.js | 241 ++++++++++ apps/web/src/views/knowledge.jsx | 6 +- apps/web/src/views/revenue.jsx | 440 +++++++++++++++++ docs/architecture.md | 1 + docs/knowledge-influencers.md | 9 +- docs/revenue-ledger.md | 116 +++++ docs/revenue-share.md | 8 +- .../db/migrations/0011_revenue_ledger.sql | 129 +++++ packages/db/package.json | 3 +- packages/db/src/revenue.js | 452 ++++++++++++++++++ packages/knowledge/src/index.js | 1 + packages/knowledge/src/tiers.js | 17 + test/knowledge.test.js | 25 + test/schema.test.js | 224 +++++++++ 16 files changed, 1671 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/routes/revenue.js create mode 100644 apps/web/src/views/revenue.jsx create mode 100644 docs/revenue-ledger.md create mode 100644 packages/db/migrations/0011_revenue_ledger.sql create mode 100644 packages/db/src/revenue.js diff --git a/README.md b/README.md index 164237d..a1ca1f5 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,10 @@ guess that gets verified is worse for the niche than an open question. See Score comes only from contributions somebody verified, and volume does not buy it: repeated submissions of a type pay less each time, anything claiming a customer or a payment needs an outside reference, and a duplicate books once. -The ladder lives in `contribution_tiers`, so a deployment can tune it. See +The ladder lives in `contribution_tiers`, so a deployment can tune it. What a +niche earns is divided at the moment it settles, using the shares in force +right then, so a tier that moves tomorrow never re-prices yesterday's sale +([docs/revenue-ledger.md](docs/revenue-ledger.md)). See [docs/knowledge-influencers.md](docs/knowledge-influencers.md) and [docs/revenue-share.md](docs/revenue-share.md). diff --git a/apps/web/src/app.js b/apps/web/src/app.js index 41bf72f..156b5b6 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -14,6 +14,7 @@ import { registerKnowledge } from './routes/knowledge.js'; import { registerManage } from './routes/manage.js'; import { registerMcp } from './routes/mcp.js'; import { registerPages } from './routes/pages.js'; +import { registerRevenue } from './routes/revenue.js'; import { registerStatic } from './routes/static.js'; import { NotFound } from './views/pages.jsx'; @@ -107,6 +108,9 @@ registerMcp(app); */ registerAgents(app); +/** The revenue ledger: what a niche earned and whose share of it is whose. */ +registerRevenue(app); + /** * Last, because a niche's page is served from the site root: every other * route is registered before `/:slug` can be asked. A niche may not take a diff --git a/apps/web/src/routes/revenue.js b/apps/web/src/routes/revenue.js new file mode 100644 index 0000000..c09368e --- /dev/null +++ b/apps/web/src/routes/revenue.js @@ -0,0 +1,241 @@ +import * as k from '@nichedb/db/knowledge'; +import * as r from '@nichedb/db/revenue'; +import { REVENUE_SOURCE_TYPES } from '@nichedb/knowledge'; +import { verifyInternalRequest } from '../lib/chovy.js'; +import { render, requireUser, respond } from '../lib/http.js'; +import { Denied, isAdmin } from '../lib/service.js'; +import { NicheRevenue, PayoutsAdmin, PayoutsPage } from '../views/revenue.jsx'; + +/** + * The revenue ledger's surfaces. + * + * Money arrives through one signed endpoint, is divided at finalisation using + * the shares in force at that instant, and accrues to the people who earned + * it. Nothing here sends money: settlement is done out of band and recorded, + * which is the same shape the partner programme uses. + */ + +const requireAdmin = (c) => { + const user = requireUser(c); + if (!isAdmin(user)) throw new Denied('Admins only.', 403); + return user; +}; + +export function registerRevenue(app) { + /* -------------------------------------------------------------- internal -- */ + + /** + * An earning, from whatever produced it. + * + * Signed, because this decides who is owed money. Idempotent on the payment + * reference, because a webhook that retries must not pay twice. + */ + app.post('/api/v1/internal/revenue-events', async (c) => { + const auth = await verifyInternalRequest(c); + if (!auth.ok) return c.json({ error: auth.reason }, auth.status); + + const p = auth.body?.payload ?? auth.body ?? {}; + if (!REVENUE_SOURCE_TYPES.includes(p.sourceType)) + return c.json({ error: 'unknown source type', known: REVENUE_SOURCE_TYPES }, 400); + if (!Number.isFinite(Number(p.grossMinor)) || Number(p.grossMinor) < 0) + return c.json({ error: 'grossMinor must be a non-negative integer of minor units' }, 400); + + const niche = p.nicheSlug ? await k.getNiche(p.nicheSlug) : null; + if (p.nicheSlug && !niche) return c.json({ error: 'no such niche' }, 404); + + const out = await r.recordRevenueEvent({ + externalId: p.externalId ?? p.paymentRef ?? auth.body?.id ?? null, + nicheId: niche?.id ?? p.nicheId ?? null, + sourceType: p.sourceType, + sourceId: p.sourceId ?? null, + grossMinor: p.grossMinor, + processingMinor: p.processingMinor ?? 0, + networkMinor: p.networkMinor ?? 0, + infraMinor: p.infraMinor ?? 0, + refundMinor: p.refundMinor ?? 0, + currency: p.currency ?? 'USD', + occurredAt: p.occurredAt ?? null, + metadata: p.metadata ?? {}, + finalize: p.finalize !== false, + }); + + return c.json( + { + recorded: Boolean(out.event), + duplicate: out.duplicate, + eventId: out.event ? String(out.event.id) : null, + netMinor: out.event ? Number(out.event.net_amount_minor) : 0, + allocations: out.allocations.map((a) => ({ + type: a.allocation_type, + shareBps: Number(a.share_bps), + amountMinor: Number(a.amount_minor), + })), + }, + out.duplicate ? 200 : 201, + ); + }); + + /* ------------------------------------------------------------ influencer -- */ + + app.get('/api/v1/me/revenue', async (c) => { + const user = requireUser(c); + return c.json({ + balance: await r.balanceFor(user.id), + allocations: await r.allocationsForInfluencer(user.id, { limit: 100 }), + }); + }); + + app.get('/api/v1/me/payouts', async (c) => { + const user = requireUser(c); + return c.json({ payouts: await r.listPayouts({ influencerId: user.id }) }); + }); + + app.get('/dashboard/payouts', async (c) => { + const user = requireUser(c); + const [balance, allocations, payouts, account] = await Promise.all([ + r.balanceFor(user.id), + r.allocationsForInfluencer(user.id, { limit: 50 }), + r.listPayouts({ influencerId: user.id }), + r.getPayoutAccount(user.id), + ]); + return c.html( + await render( + , + ), + ); + }); + + app.post('/dashboard/payouts/address', async (c) => { + const user = requireUser(c); + const body = await c.req.parseBody(); + await r.setPayoutAddress({ + userId: user.id, + address: String(body.address ?? '') + .trim() + .slice(0, 200), + }); + return respond(c, { + json: { ok: true }, + redirectTo: '/dashboard/payouts', + notice: 'Saved. An admin has to confirm it before anything is sent.', + }); + }); + + /** A niche's own books, for the people who operate it. */ + app.get('/dashboard/niches/:slug/revenue', async (c) => { + const user = requireUser(c); + const niche = await k.getNiche(c.req.param('slug')); + if (!niche) return c.notFound(); + const member = await k.memberOf({ nicheId: niche.id, userId: user.id }); + if (member?.status !== 'active' && !isAdmin(user)) return c.notFound(); + + const [totals, events, members, mine] = await Promise.all([ + r.nicheRevenueTotals(niche.id), + r.revenueForNiche(niche.id, { limit: 50 }), + k.nicheMembers(niche.id), + r.allocationsForInfluencer(user.id, { limit: 50 }), + ]); + return c.html( + await render( + a.niche_slug === niche.slug)} + />, + ), + ); + }); + + /* ---------------------------------------------------------------- admin -- */ + + app.get('/admin/payouts', async (c) => { + const user = requireAdmin(c); + const [owed, payouts, events] = await Promise.all([ + r.outstandingBalances(), + r.listPayouts({ limit: 50 }), + r.listRevenueEvents({ limit: 50 }), + ]); + return c.html( + await render( + , + ), + ); + }); + + app.post('/admin/payouts/verify/:userId', async (c) => { + const user = requireAdmin(c); + const done = await r.verifyPayoutAddress({ userId: c.req.param('userId'), actorId: user.id }); + return respond(c, { + json: { verified: Boolean(done) }, + redirectTo: '/admin/payouts', + notice: done ? 'Address confirmed.' : null, + error: done ? null : 'There is no address on that account.', + }); + }); + + app.post('/admin/payouts/schedule/:userId', async (c) => { + const user = requireAdmin(c); + const out = await r.schedulePayout({ + influencerId: c.req.param('userId'), + actorId: user.id, + }); + return respond(c, { + json: out, + redirectTo: '/admin/payouts', + notice: out.ok ? `Payout ${out.payout.id} scheduled.` : null, + error: out.ok ? null : out.reason, + }); + }); + + app.post('/admin/payouts/:id/paid', async (c) => { + const user = requireAdmin(c); + const body = await c.req.parseBody(); + const done = await r.markPayoutPaid({ + payoutId: c.req.param('id'), + actorId: user.id, + externalRef: body.ref ? String(body.ref).slice(0, 200) : null, + }); + return respond(c, { + json: { paid: Boolean(done) }, + redirectTo: '/admin/payouts', + notice: done ? 'Marked paid.' : null, + error: done ? null : 'That payout is not awaiting settlement.', + }); + }); + + app.post('/admin/payouts/:id/failed', async (c) => { + const user = requireAdmin(c); + const body = await c.req.parseBody(); + const done = await r.markPayoutFailed({ + payoutId: c.req.param('id'), + actorId: user.id, + reason: body.reason ? String(body.reason).slice(0, 300) : 'no reason given', + }); + return respond(c, { + json: { failed: Boolean(done) }, + redirectTo: '/admin/payouts', + // The money goes back to owed rather than being stranded in a state + // nothing picks up again. + notice: done ? 'Marked failed; the allocations are owed again.' : null, + error: done ? null : 'That payout is not awaiting settlement.', + }); + }); +} diff --git a/apps/web/src/views/knowledge.jsx b/apps/web/src/views/knowledge.jsx index 4fb9749..65e59e1 100644 --- a/apps/web/src/views/knowledge.jsx +++ b/apps/web/src/views/knowledge.jsx @@ -348,6 +348,9 @@ export const InfluencerDashboard = ({

Your niches

+

+ Your payouts · Find a niche +

{niches.length === 0 ? (

You are not operating a niche yet. Find one. @@ -381,7 +384,8 @@ export const InfluencerDashboard = ({ Questions {questionCounts[String(n.id)] ? ` (${questionCounts[String(n.id)]})` : ''} - + {' '} + · Revenue

); diff --git a/apps/web/src/views/revenue.jsx b/apps/web/src/views/revenue.jsx new file mode 100644 index 0000000..9d195a1 --- /dev/null +++ b/apps/web/src/views/revenue.jsx @@ -0,0 +1,440 @@ +import { formatBps, formatMinor } from '@nichedb/knowledge'; +import { Notice, Num, Relative } from './components.jsx'; +import { Layout } from './Layout.jsx'; + +/** + * The money pages. + * + * Every amount on them is a stored integer of minor units, divided by a + * hundred at the last possible moment by `formatMinor`. Nothing here does + * arithmetic; if a number looks wrong the ledger is wrong, not the page. + */ + +const Money = ({ minor, currency = 'USD' }) => ( + {formatMinor(minor, currency)} +); + +const StatusPill = ({ status }) => ( + + {status} + +); + +/** What one person is owed, where it goes, and what has already gone. */ +export const PayoutsPage = ({ user, balance, allocations, payouts, account, notice, error }) => ( + + +

Payouts

+ +
+

+ Owed · scheduled {' '} + · paid + {balance.reversedMinor > 0 ? ( + <> + {' '} + · reversed + + ) : null} +

+
+ +
+

Where it goes

+
+ +

+ +

+

+ Changing the address clears its confirmation, on purpose: whoever confirmed the old one + did not confirm this one. +

+
+
+ +
+

Your share, event by event

+ {allocations.length === 0 ? ( +

Nothing yet. A niche earns, and your share of it appears here.

+ ) : ( + + + + + + + + + + + + + {allocations.map((a) => ( + + + + + {/* The share as it stood when this settled, not today's. */} + + + + + ))} + +
WhenNicheSourceYour shareAmountStatus
+ + {a.niche_name ?? '—'}{a.source_type.replace(/_/g, ' ')}{formatBps(a.share_bps)} + + + +
+ )} +
+ +
+

Payments

+ {payouts.length === 0 ? ( +

None yet.

+ ) : ( + + + + + + + + + + + {payouts.map((p) => ( + + + + + + + ))} + +
WhenAmountStatusReference
+ + + + + + {p.external_ref ?? ''}
+ )} +
+
+); + +/** A niche's books, for the people who operate it. */ +export const NicheRevenue = ({ user, niche, totals, events, members, mine }) => ( + +

{niche.name}: revenue

+

+ The niche ·{' '} + Questions ·{' '} + Your payouts +

+ +
+

+ Gross · shared · of + which machine · events +

+

+ Shared is gross less what it cost to take the money: payment fees, network fees and the + infrastructure a request actually used. Nothing else comes off. +

+
+ +
+

Who it splits between

+ + + + + + + + + + {members.map((m) => ( + + + + + + ))} + +
OperatorTierShare now
{m.display_name ?? m.handle}{m.tier_slug.replace(/-/g, ' ')}{formatBps(m.share_bps)}
+

+ This is today's split. An earning already settled keeps the share that was in force when it + settled, which is why the table below can disagree with this one. +

+
+ +
+

Your share here

+ {mine.length === 0 ? ( +

Nothing allocated to you from this niche yet.

+ ) : ( + + + + + + + + + + + {mine.map((a) => ( + + + + + + + ))} + +
WhenShare thenAmountStatus
+ + {formatBps(a.share_bps)} + + + +
+ )} +
+ +
+

Earnings

+ {events.length === 0 ? ( +

Nothing recorded yet.

+ ) : ( + + + + + + + + + + + + + {events.map((e) => ( + + + + + + + + + ))} + +
WhenSourceGrossCostSharedSettled
+ + {e.source_type.replace(/_/g, ' ')} + + + + + + {e.finalized_at ? : pending}
+ )} +
+
+); + +/** The admin's payout run. Every button here writes an audit row. */ +export const PayoutsAdmin = ({ user, owed, payouts, events, notice, error }) => ( + + +

Payouts

+ +
+

Owed

+ {owed.length === 0 ? ( +

Nobody is owed anything.

+ ) : ( + + + + + + + + + + {owed.map((o) => ( + + + + + + + ))} + +
WhoOwedAddress +
{o.display_name ?? o.handle ?? o.email} + + + {!o.has_address ? ( + none on file + ) : o.verified ? ( + + confirmed + + ) : ( +
+ +
+ )} +
+ {o.verified ? ( +
+ +
+ ) : null} +
+ )} +

+ Scheduling gathers what somebody is owed into one payout and marks those allocations spoken + for. Nothing is sent from here: the money moves out of band and the reference is recorded + below. +

+
+ +
+

Payouts

+ {payouts.length === 0 ? ( +

None yet.

+ ) : ( + + + + + + + + + + + {payouts.map((p) => ( + + + + + + + ))} + +
WhoAmountStatusSettle
{p.display_name ?? p.handle ?? p.email} + + + + {p.failure_reason ? {p.failure_reason} : null} + + {p.status === 'scheduled' || p.status === 'processing' ? ( + <> +
+ + +
+
+ +
+ + ) : ( + {p.external_ref ?? ''} + )} +
+ )} +
+ +
+

Earnings

+ {events.length === 0 ? ( +

Nothing recorded yet.

+ ) : ( + + + + + + + + + + + + + {events.map((e) => ( + + + + + + + + + ))} + +
WhenNicheSourceGrossSharedSplit
+ + {e.niche_name ?? unattributed}{e.source_type.replace(/_/g, ' ')} + + + + {e.finalized_at ? `${e.allocations} ways` : pending}
+ )} +
+
+); diff --git a/docs/architecture.md b/docs/architecture.md index 997a914..7a04390 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,5 +76,6 @@ index, and the Knowledge Influencer tables for people who operate a niche. See - [knowledge-influencers.md](./knowledge-influencers.md) - [agent-questions.md](./agent-questions.md) +- [revenue-ledger.md](./revenue-ledger.md) - [revenue-share.md](./revenue-share.md) - [x402-attribution.md](./x402-attribution.md) diff --git a/docs/knowledge-influencers.md b/docs/knowledge-influencers.md index 7e3ef9c..d017061 100644 --- a/docs/knowledge-influencers.md +++ b/docs/knowledge-influencers.md @@ -120,11 +120,12 @@ This is the part that makes a day's work a few minutes long instead of an open-ended obligation to go and find something to contribute. See [agent-questions.md](./agent-questions.md). -## Not yet built +## The revenue ledger + +What a niche earned and whose share of it is whose, including payouts. See +[revenue-ledger.md](./revenue-ledger.md). -- The revenue ledger and payouts. The arithmetic is written and tested - (`allocate`, `attributableNetMinor`); only the tables and the wiring are - missing. +## Not yet built - Promotion attribution, and the opportunity score's own inputs. - Notifications. A question arriving should reach the operator by email or push; today it waits on the dashboard until they look. diff --git a/docs/revenue-ledger.md b/docs/revenue-ledger.md new file mode 100644 index 0000000..147567b --- /dev/null +++ b/docs/revenue-ledger.md @@ -0,0 +1,116 @@ +# The revenue ledger + +**What a niche earned, whose share of it is whose, and what has been paid.** + +Until this existed, a Knowledge Influencer could climb to 80% and the number +was a label on a page. These tables are what stands behind it. + +For the ladder itself and how the two revenue-share programmes differ, see +[revenue-share.md](./revenue-share.md). + +## The shape + +``` +an earning arrives + | POST /api/v1/internal/revenue-events (signed, idempotent) + v +revenue_events gross, what it cost to take, the remainder + | + | finalised: shares read AT THIS INSTANT and stamped on + v +revenue_allocations one row per party, integer minor units + | + | accrued -> scheduled -> paid + v +payouts + payout_allocations +``` + +## The two rules everything obeys + +**Integer money, integer basis points.** Nothing divides until `formatMinor` +renders a number for a person. A share of a dollar computed in floating point +is a share that does not add up, and this pays real money. + +**An allocation records the share that was in force when the event was +finalised.** Reaching Expert tomorrow does not reach back and re-pay 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. + +That second rule is not a convention, it is what the `share_bps` column on +`revenue_allocations` is for. A tier change writes to `tier_history`; it never +touches an allocation. + +## 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 is not gross minus cost cannot be written at all | +| `revenue_events_non_negative` | no negative money | +| `external_id` unique | a settlement delivered twice books once | +| `revenue_allocations_once` | one allocation per party per event, including the platform row whose influencer is null | +| `payout_allocations` PK on `allocation_id` | **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 to finalise +the same earning produce one split rather than two. + +## Payouts + +Nothing here sends money, and that is deliberate rather than unfinished. +CoinPay's payout API pays a connected merchant account (us), not an arbitrary +third party's address, so there is no automated disbursement to call. The +partner programme has the same shape: accrue accurately, pay deliberately. + +So: an operator saves an address, an admin confirms it, an admin schedules a +payout, the money moves out of band, and the reference is recorded. + +Two safeguards worth knowing: + +- **Changing an address clears its confirmation.** Whoever confirmed the old + address did not confirm this one, and someone who reaches an account should + not inherit that trust. +- **A failed payout returns its allocations to owed** and detaches them, so the + money is not stranded in a state nothing picks up again. + +`schedulePayout` takes the owed rows `for update skip locked`, so two admins +clicking at once take disjoint sets instead of blocking or double-claiming. + +## Where earnings come from + +Today, only the signed internal endpoint. Wiring the x402 gateway's existing +`onSale` hook into it is Phase 4 and is now a small job: `machineRevenueEvent` +already normalises the sale object the gateway hands over, and it is tested. + +```sh +BODY='{"payload":{"nicheSlug":"games","externalId":"pay_1","sourceType":"x402", + "grossMinor":2000,"processingMinor":60}}' +T=$(date +%s) +MAC=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$CHOVY_SIGNING_SECRET" -hex | sed 's/.*= *//') +curl -X POST localhost:3000/api/v1/internal/revenue-events \ + -H 'content-type: application/json' -H "x-chovy-signature: t=$T,v1=$MAC" -d "$BODY" +``` + +## Pages + +``` +/dashboard/payouts owed, address, allocations, payments +/dashboard/niches//revenue one niche's books +/admin/payouts who is owed, confirm, schedule, settle +GET /api/v1/me/revenue balance and allocations +GET /api/v1/me/payouts +``` + +## Tests + +```sh +bun test test/schema.test.js # what the database refuses +bun test test/knowledge.test.js # allocation arithmetic and money rendering +``` + +The arithmetic (`allocate`, `attributableNetMinor`) is pure and tested without +a database, including that allocations always sum to exactly the net and that +rounding never over-pays. diff --git a/docs/revenue-share.md b/docs/revenue-share.md index 2cc3251..1470077 100644 --- a/docs/revenue-share.md +++ b/docs/revenue-share.md @@ -78,6 +78,8 @@ always sum to the net exactly and rounding never over-pays. ## Payouts -Not built yet. The states are defined (`PAYOUT_STATES`) and CoinPay is already -the rail this deployment uses for Pro memberships and crawl passes, so there is -no second payment integration to write — only the ledger tables and the wiring. +Built: see [revenue-ledger.md](./revenue-ledger.md). An operator saves an +address, an admin confirms it and schedules the payout, the money moves out of +band and the reference is recorded. Nothing sends money automatically, because +CoinPay's payout API pays a connected merchant account rather than a third +party's address. diff --git a/packages/db/migrations/0011_revenue_ledger.sql b/packages/db/migrations/0011_revenue_ledger.sql new file mode 100644 index 0000000..b1d9825 --- /dev/null +++ b/packages/db/migrations/0011_revenue_ledger.sql @@ -0,0 +1,129 @@ +-- The revenue ledger: what a niche earned, whose share of it is whose, and +-- what has been paid out. +-- +-- Until now a Knowledge Influencer could climb to 80% and the number was a +-- label on a page. These tables are what stands behind it. +-- +-- Two rules shape everything here. Money is integer minor units and shares are +-- integer basis points, because a share of a dollar computed in floating point +-- is a share that does not add up. And an allocation records the share that +-- was in force when the event was finalised: reaching Expert tomorrow does not +-- reach back and re-pay yesterday's sale at 40%. + +-- One earning. Gross in, what it cost to take it, and the remainder that is +-- actually shared. +create table revenue_events ( + id bigserial primary key, + -- The payment reference this came in on. Unique, so a settlement delivered + -- twice books once, which is the only thing standing between a retried + -- webhook and paying somebody twice. + external_id text unique, + -- Null when the money is not attributable to a niche. It is kept, and goes + -- entirely to the platform. Deleting a niche must never delete the record of + -- money that moved, so this detaches rather than cascades. + niche_id bigint references niches(id) on delete set null, + source_type text not null, + constraint revenue_events_source check (source_type in ( + 'software_subscription', 'software_one_time', 'api', 'x402', 'dataset_license', + 'lead', 'sponsorship', 'affiliate', 'referral', 'advertising', 'service', 'other')), + source_id text, + gross_amount_minor bigint not null, + direct_cost_minor bigint not null default 0, + net_amount_minor bigint not null, + -- The arithmetic is a constraint rather than a convention. A row whose parts + -- do not add up cannot be written at all, so no reader has to re-check it. + constraint revenue_events_adds_up + check (net_amount_minor = gross_amount_minor - direct_cost_minor), + constraint revenue_events_non_negative + check (gross_amount_minor >= 0 and direct_cost_minor >= 0 and net_amount_minor >= 0), + currency text not null default 'USD', + occurred_at timestamptz not null default now(), + -- Nothing is allocated until this is set, and it is set once. Allocation + -- happens AT finalisation and reads the shares as they are at that instant. + finalized_at timestamptz, + metadata jsonb not null default '{}', + created_at timestamptz not null default now() +); +create index revenue_events_niche_idx on revenue_events (niche_id, occurred_at desc); +create index revenue_events_pending_idx on revenue_events (occurred_at) where finalized_at is null; + +-- Who gets what out of one event. Written once, at finalisation, and never +-- updated when somebody's tier moves afterwards. +create table revenue_allocations ( + id bigserial primary key, + revenue_event_id bigint not null references revenue_events(id) on delete cascade, + -- Null for the platform's own share. + influencer_id uuid references users(id) on delete set null, + allocation_type text not null, + constraint revenue_allocations_type check (allocation_type in ( + 'knowledge_influencer', 'platform', 'specialist', 'partner')), + -- The share as it stood when this was written. This column is the whole + -- reason "your share was 40% when that settled" is checkable months later. + share_bps int not null, + constraint revenue_allocations_bps check (share_bps between 0 and 10000), + amount_minor bigint not null, + constraint revenue_allocations_non_negative check (amount_minor >= 0), + -- accrued -> eligible -> scheduled -> processing -> paid, or failed/reversed. + status text not null default 'accrued', + constraint revenue_allocations_status check (status in ( + 'accrued', 'eligible', 'scheduled', 'processing', 'paid', 'failed', 'reversed')), + created_at timestamptz not null default now() +); +create index revenue_allocations_event_idx on revenue_allocations (revenue_event_id); +create index revenue_allocations_owed_idx + on revenue_allocations (influencer_id, status) where influencer_id is not null; +-- One allocation per party per event. Re-running allocation on an event that +-- somehow escaped its finalisation guard still cannot pay anyone twice. +create unique index revenue_allocations_once + on revenue_allocations (revenue_event_id, allocation_type, + coalesce(influencer_id, '00000000-0000-0000-0000-000000000000'::uuid)); + +-- Where somebody's money goes. Separate from the user row because an address +-- is a thing an admin checks, and because most accounts never have one. +create table payout_accounts ( + id bigserial primary key, + user_id uuid not null unique references users(id) on delete cascade, + address text, + currency text not null default 'USD', + -- Payouts refuse an address nobody has confirmed. Paying the wrong address + -- is not recoverable, so this is a deliberate human step. + verified_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- One disbursement. +-- +-- Nothing here sends money. CoinPay's payout API pays a connected merchant +-- account (us), not an arbitrary third-party address, so settlement is done +-- out of band and recorded here with its reference. That is the same shape the +-- partner programme uses: accrue accurately, pay deliberately. +create table payouts ( + id bigserial primary key, + influencer_id uuid not null references users(id) on delete cascade, + amount_minor bigint not null, + constraint payouts_positive check (amount_minor > 0), + currency text not null default 'USD', + status text not null default 'scheduled', + constraint payouts_status check (status in ( + 'scheduled', 'processing', 'paid', 'failed', 'reversed')), + -- The reference from whatever actually moved the money. + external_ref text unique, + failure_reason text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + paid_at timestamptz +); +create index payouts_influencer_idx on payouts (influencer_id, created_at desc); + +-- Which allocations a payout covers. +-- +-- `allocation_id` is the primary key on its own, and that is the point: an +-- allocation can belong to at most one payout, so the database refuses to pay +-- the same earning twice however many times someone presses the button. +create table payout_allocations ( + payout_id bigint not null references payouts(id) on delete cascade, + allocation_id bigint not null references revenue_allocations(id) on delete restrict, + primary key (allocation_id) +); +create index payout_allocations_payout_idx on payout_allocations (payout_id); diff --git a/packages/db/package.json b/packages/db/package.json index bcf15b8..624a315 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -9,7 +9,8 @@ "./migrate": "./src/migrate.js", "./knowledge": "./src/knowledge.js", "./automotive": "./src/automotive.js", - "./agents": "./src/agents.js" + "./agents": "./src/agents.js", + "./revenue": "./src/revenue.js" }, "dependencies": { "@nichedb/config": "workspace:*", diff --git a/packages/db/src/revenue.js b/packages/db/src/revenue.js new file mode 100644 index 0000000..f3ad4c7 --- /dev/null +++ b/packages/db/src/revenue.js @@ -0,0 +1,452 @@ +import { allocate, attributableNetMinor, MAX_SHARE_BPS } from '@nichedb/knowledge'; +import { sql } from './index.js'; +import { audit } from './knowledge.js'; +import { pgArray } from './queries.js'; + +/** + * The revenue ledger. + * + * An earning arrives, is finalised, and at that instant is divided between the + * niche's influencers and the platform using the shares in force right then. + * Nothing afterwards rewrites it. A tier that moves tomorrow applies to + * tomorrow's money. + * + * The arithmetic lives in `@nichedb/knowledge` and is tested without a + * database. This module feeds it the current shares and stores what it says. + */ + +/* ----------------------------------------------------------------- events -- */ + +/** + * Record an earning. + * + * Idempotent on `externalId`: a settlement delivered twice books once. That is + * the only thing between a retried webhook and paying somebody twice, so a + * caller with a payment reference should always pass it. + * + * `finalize` allocates immediately. Leave it false for money that might still + * be refunded, and finalise when it has settled. + */ +export async function recordRevenueEvent({ + externalId = null, + nicheId = null, + sourceType, + sourceId = null, + grossMinor, + processingMinor = 0, + networkMinor = 0, + infraMinor = 0, + refundMinor = 0, + currency = 'USD', + occurredAt = null, + metadata = {}, + finalize = true, +}) { + const gross = Math.max(0, Math.round(Number(grossMinor) || 0)); + const net = attributableNetMinor({ + grossMinor: gross, + processingMinor, + networkMinor, + infraMinor, + refundMinor, + }); + const cost = gross - net; + + const [row] = await sql` + insert into revenue_events + (external_id, niche_id, source_type, source_id, gross_amount_minor, + direct_cost_minor, net_amount_minor, currency, occurred_at, metadata) + values (${externalId}, ${nicheId ?? null}, ${sourceType}, ${sourceId}, + ${gross}, ${cost}, ${net}, ${currency}, + ${occurredAt ? new Date(occurredAt) : new Date()}, + ${JSON.stringify(metadata)}::text::jsonb) + on conflict (external_id) do nothing + returning * + `; + if (!row) { + // Already had it. Hand back what we hold rather than an error: a webhook + // retrying is normal, and the right answer is "yes, booked". + const existing = externalId ? await revenueEventByExternalId(externalId) : null; + return { event: existing, duplicate: true, allocations: [] }; + } + + const allocations = finalize ? await finalizeRevenueEvent(row.id) : []; + return { + event: allocations.length ? await getRevenueEvent(row.id) : row, + duplicate: false, + allocations, + }; +} + +export async function getRevenueEvent(id) { + const [row] = await sql` + select e.*, n.slug as niche_slug, n.name as niche_name + from revenue_events e + left join niches n on n.id = e.niche_id + where e.id = ${Number(id)} + `; + return row ?? null; +} + +export async function revenueEventByExternalId(externalId) { + const [row] = await sql`select * from revenue_events where external_id = ${externalId}`; + return row ?? null; +} + +/** + * Divide a settled earning and write the allocations. + * + * The shares are read here, at finalisation, and stamped onto each row. A + * niche with nobody operating it allocates everything to the platform. + * + * `finalized_at` is set in the same statement that reads it, conditional on it + * being null, so two requests racing to finalise the same event produce one + * set of allocations rather than two. + */ +export async function finalizeRevenueEvent(id) { + const event = await getRevenueEvent(id); + if (!event || event.finalized_at) return []; + + const members = event.niche_id + ? await sql` + select m.user_id, m.share_cap_bps, + coalesce(s.score, 0) as score + from niche_members m + left join contribution_scores s + on s.niche_id = m.niche_id and s.influencer_id = m.user_id + where m.niche_id = ${event.niche_id} and m.status = 'active' + and m.role in ('operator', 'specialist') + ` + : []; + + const split = allocate({ + netMinor: Number(event.net_amount_minor), + members: members.map((m) => ({ + influencerId: m.user_id, + score: Number(m.score), + capBps: Number(m.share_cap_bps ?? MAX_SHARE_BPS), + })), + }); + + const written = []; + await sql.begin(async (tx) => { + // Claim the event first. If this updates nothing, somebody else finalised + // it between the read above and here, and we must not allocate again. + const claimed = await tx` + update revenue_events set finalized_at = now() + where id = ${Number(id)} and finalized_at is null + returning id + `; + if (!claimed.length) return; + + for (const a of split) { + if (a.amountMinor <= 0 && a.allocationType !== 'platform') continue; + const [row] = await tx` + insert into revenue_allocations + (revenue_event_id, influencer_id, allocation_type, share_bps, amount_minor) + values (${Number(id)}, ${a.influencerId ?? null}, ${a.allocationType}, + ${a.shareBps}, ${a.amountMinor}) + on conflict do nothing + returning * + `; + if (row) written.push(row); + } + }); + return written; +} + +/** Everything a niche has earned, newest first. */ +export async function revenueForNiche(nicheId, { limit = 100 } = {}) { + return sql` + select e.id, e.external_id, e.source_type, e.gross_amount_minor, e.direct_cost_minor, + e.net_amount_minor, e.currency, e.occurred_at, e.finalized_at + from revenue_events e + where e.niche_id = ${Number(nicheId)} + order by e.occurred_at desc + limit ${Math.min(Number(limit) || 100, 500)} + `; +} + +/** The totals a niche dashboard shows. */ +export async function nicheRevenueTotals(nicheId) { + const [row] = await sql` + select coalesce(sum(gross_amount_minor), 0)::bigint as gross, + coalesce(sum(net_amount_minor), 0)::bigint as net, + coalesce(sum(net_amount_minor) filter (where source_type = 'x402'), 0)::bigint as machine, + count(*)::int as events + from revenue_events where niche_id = ${Number(nicheId)} + `; + return { + grossMinor: Number(row?.gross ?? 0), + netMinor: Number(row?.net ?? 0), + machineMinor: Number(row?.machine ?? 0), + events: Number(row?.events ?? 0), + }; +} + +/* ------------------------------------------------------------ influencers -- */ + +/** What one person has been allocated, and out of what. */ +export async function allocationsForInfluencer(userId, { limit = 100, status = null } = {}) { + return sql` + select a.id, a.share_bps, a.amount_minor, a.status, a.created_at, + e.source_type, e.currency, e.occurred_at, + n.slug as niche_slug, n.name as niche_name + from revenue_allocations a + join revenue_events e on e.id = a.revenue_event_id + left join niches n on n.id = e.niche_id + where a.influencer_id = ${userId}::uuid + and (${status}::text is null or a.status = ${status}) + order by a.created_at desc + limit ${Math.min(Number(limit) || 100, 500)} + `; +} + +/** + * What somebody is owed and what they have been paid. + * + * "Owed" is everything not yet in a payout and not reversed. A reversal is a + * status, not a deletion, so a refunded sale stops being owed without the row + * that recorded it disappearing. + */ +export async function balanceFor(userId) { + const [row] = await sql` + select + coalesce(sum(amount_minor) filter ( + where status in ('accrued', 'eligible')), 0)::bigint as owed, + coalesce(sum(amount_minor) filter ( + where status in ('scheduled', 'processing')), 0)::bigint as in_flight, + coalesce(sum(amount_minor) filter (where status = 'paid'), 0)::bigint as paid, + coalesce(sum(amount_minor) filter (where status = 'reversed'), 0)::bigint as reversed + from revenue_allocations + where influencer_id = ${userId}::uuid + `; + return { + owedMinor: Number(row?.owed ?? 0), + inFlightMinor: Number(row?.in_flight ?? 0), + paidMinor: Number(row?.paid ?? 0), + reversedMinor: Number(row?.reversed ?? 0), + }; +} + +/* --------------------------------------------------------------- payouts -- */ + +export async function getPayoutAccount(userId) { + const [row] = await sql`select * from payout_accounts where user_id = ${userId}::uuid`; + return row ?? null; +} + +/** + * Set where somebody's money goes. + * + * Changing the address clears its verification. An attacker who reaches an + * account should not inherit the confirmation given to the previous address. + */ +export async function setPayoutAddress({ userId, address, currency = 'USD' }) { + const [row] = await sql` + insert into payout_accounts (user_id, address, currency) + values (${userId}::uuid, ${address || null}, ${currency}) + on conflict (user_id) do update + set address = excluded.address, currency = excluded.currency, + verified_at = null, updated_at = now() + returning * + `; + return row; +} + +export async function verifyPayoutAddress({ userId, actorId }) { + const [row] = await sql` + update payout_accounts set verified_at = now(), updated_at = now() + where user_id = ${userId}::uuid and address is not null + returning * + `; + if (row) + await audit({ + actorId, + action: 'payout_account.verified', + subjectType: 'payout_account', + subjectId: String(row.id), + detail: { userId }, + }); + return row ?? null; +} + +/** + * Gather what somebody is owed into one payout. + * + * The allocations are attached by their own primary key in `payout_allocations`, + * so an allocation can belong to at most one payout. Pressing the button twice + * produces one payout and then an empty one, not two payments. + * + * Refuses an unverified address. Paying the wrong address is not recoverable. + */ +export async function schedulePayout({ influencerId, actorId, minimumMinor = 0 }) { + const account = await getPayoutAccount(influencerId); + if (!account?.address) return { ok: false, reason: 'no payout address on file' }; + if (!account.verified_at) return { ok: false, reason: 'that payout address is not verified' }; + + let payout = null; + await sql.begin(async (tx) => { + // `for update skip locked` so two admins clicking at once take disjoint + // sets rather than blocking or double-claiming. + const owed = await tx` + select id, amount_minor from revenue_allocations + where influencer_id = ${influencerId}::uuid + and status in ('accrued', 'eligible') + and not exists (select 1 from payout_allocations p where p.allocation_id = revenue_allocations.id) + order by created_at + for update skip locked + `; + const total = owed.reduce((n, a) => n + Number(a.amount_minor), 0); + if (total <= 0 || total < Number(minimumMinor)) return; + + const [created] = await tx` + insert into payouts (influencer_id, amount_minor, currency, status) + values (${influencerId}::uuid, ${total}, ${account.currency}, 'scheduled') + returning * + `; + for (const a of owed) { + await tx` + insert into payout_allocations (payout_id, allocation_id) + values (${created.id}, ${a.id}) + on conflict (allocation_id) do nothing + `; + } + await tx` + update revenue_allocations set status = 'scheduled' + where id = any(${pgArray(owed.map((a) => a.id))}::bigint[]) + `; + payout = created; + }); + + if (!payout) return { ok: false, reason: 'nothing is owed' }; + await audit({ + actorId, + action: 'payout.scheduled', + subjectType: 'payout', + subjectId: String(payout.id), + detail: { influencerId, amountMinor: Number(payout.amount_minor) }, + }); + return { ok: true, payout }; +} + +/** Record that a scheduled payout actually moved, with whatever reference did it. */ +export async function markPayoutPaid({ payoutId, actorId, externalRef }) { + let payout = null; + await sql.begin(async (tx) => { + const [row] = await tx` + update payouts + set status = 'paid', paid_at = now(), updated_at = now(), external_ref = ${externalRef ?? null} + where id = ${Number(payoutId)} and status in ('scheduled', 'processing') + returning * + `; + if (!row) return; + await tx` + update revenue_allocations set status = 'paid' + where id in (select allocation_id from payout_allocations where payout_id = ${row.id}) + `; + payout = row; + }); + if (!payout) return null; + await audit({ + actorId, + action: 'payout.paid', + subjectType: 'payout', + subjectId: String(payout.id), + detail: { externalRef, amountMinor: Number(payout.amount_minor) }, + }); + return payout; +} + +/** + * A payout that did not happen. The allocations go back to being owed, so the + * money is not stranded in a state nothing will pick up again. + */ +export async function markPayoutFailed({ payoutId, actorId, reason }) { + let payout = null; + await sql.begin(async (tx) => { + const [row] = await tx` + update payouts set status = 'failed', failure_reason = ${reason ?? null}, updated_at = now() + where id = ${Number(payoutId)} and status in ('scheduled', 'processing') + returning * + `; + if (!row) return; + await tx` + update revenue_allocations set status = 'accrued' + where id in (select allocation_id from payout_allocations where payout_id = ${row.id}) + `; + await tx`delete from payout_allocations where payout_id = ${row.id}`; + payout = row; + }); + if (!payout) return null; + await audit({ + actorId, + action: 'payout.failed', + subjectType: 'payout', + subjectId: String(payout.id), + detail: { reason }, + }); + return payout; +} + +export async function listPayouts({ influencerId = null, limit = 50 } = {}) { + return sql` + select p.*, u.handle::text as handle, u.display_name, u.email::text as email + from payouts p + join users u on u.id = p.influencer_id + where (${influencerId}::uuid is null or p.influencer_id = ${influencerId}::uuid) + order by p.created_at desc + limit ${Math.min(Number(limit) || 50, 200)} + `; +} + +/** Everyone with money waiting, for the admin's payout run. */ +export async function outstandingBalances({ limit = 100 } = {}) { + return sql` + select a.influencer_id, u.handle::text as handle, u.display_name, u.email::text as email, + sum(a.amount_minor)::bigint as owed, + pa.address is not null as has_address, + pa.verified_at is not null as verified + from revenue_allocations a + join users u on u.id = a.influencer_id + left join payout_accounts pa on pa.user_id = a.influencer_id + where a.status in ('accrued', 'eligible') and a.influencer_id is not null + group by a.influencer_id, u.handle, u.display_name, u.email, pa.address, pa.verified_at + having sum(a.amount_minor) > 0 + order by sum(a.amount_minor) desc + limit ${Math.min(Number(limit) || 100, 500)} + `; +} + +/** + * Take back an allocation whose earning turned out not to be real: a refund, a + * chargeback, a reversed contribution. Marked, never deleted, so the history + * of what was believed at payout time survives. + */ +export async function reverseAllocation({ allocationId, actorId, reason }) { + const [row] = await sql` + update revenue_allocations set status = 'reversed' + where id = ${Number(allocationId)} and status in ('accrued', 'eligible') + returning * + `; + if (!row) return null; + await audit({ + actorId, + action: 'allocation.reversed', + subjectType: 'revenue_allocation', + subjectId: String(allocationId), + detail: { reason, amountMinor: Number(row.amount_minor) }, + }); + return row; +} + +export async function listRevenueEvents({ limit = 100 } = {}) { + return sql` + select e.*, n.slug as niche_slug, n.name as niche_name, + (select count(*)::int from revenue_allocations a where a.revenue_event_id = e.id) as allocations + from revenue_events e + left join niches n on n.id = e.niche_id + order by e.occurred_at desc + limit ${Math.min(Number(limit) || 100, 500)} + `; +} diff --git a/packages/knowledge/src/index.js b/packages/knowledge/src/index.js index 7bdca0d..5e26689 100644 --- a/packages/knowledge/src/index.js +++ b/packages/knowledge/src/index.js @@ -27,6 +27,7 @@ export { BASE_SHARE_BPS, CONTRIBUTION_TIERS, formatBps, + formatMinor, MAX_SHARE_BPS, nextTierFor, shareBpsFor, diff --git a/packages/knowledge/src/tiers.js b/packages/knowledge/src/tiers.js index 75292af..877452b 100644 --- a/packages/knowledge/src/tiers.js +++ b/packages/knowledge/src/tiers.js @@ -105,3 +105,20 @@ export function formatBps(bps) { const pct = (Number(bps) || 0) / 100; return `${Number.isInteger(pct) ? pct : pct.toFixed(1)}%`; } + +/** + * "$12.34" from 1234. For display only. + * + * The division happens here and nowhere else. Money is integer minor units + * everywhere it is stored, compared or divided; the one place it becomes a + * fraction is the moment it is about to be read by a person. + */ +export function formatMinor(minor, currency = 'USD') { + const n = Number(minor) || 0; + try { + return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(n / 100); + } catch { + // An unknown currency code should not take a dashboard down. + return `${(n / 100).toFixed(2)} ${currency}`; + } +} diff --git a/test/knowledge.test.js b/test/knowledge.test.js index c139d66..128e0e7 100644 --- a/test/knowledge.test.js +++ b/test/knowledge.test.js @@ -9,6 +9,7 @@ import { diminishFactor, domainEvent, formatBps, + formatMinor, isReservedNicheSlug, MAX_SHARE_BPS, machineRevenueEvent, @@ -350,3 +351,27 @@ describe('reading a jsonb column', () => { expect(asJsonArray(null)).toEqual([]); }); }); + +describe('rendering money', () => { + test('minor units become a currency string only at the last moment', () => { + expect(formatMinor(0)).toBe('$0.00'); + expect(formatMinor(1)).toBe('$0.01'); + expect(formatMinor(100)).toBe('$1.00'); + expect(formatMinor(123_456)).toBe('$1,234.56'); + }); + + test('an odd cent is not lost to rounding on the way to the page', () => { + expect(formatMinor(999)).toBe('$9.99'); + expect(formatMinor(1001)).toBe('$10.01'); + }); + + test('rubbish renders as zero rather than NaN', () => { + expect(formatMinor(null)).toBe('$0.00'); + expect(formatMinor(undefined)).toBe('$0.00'); + expect(formatMinor('nonsense')).toBe('$0.00'); + }); + + test('an unknown currency code does not take the page down', () => { + expect(formatMinor(500, 'NOTACURRENCY')).toContain('5.00'); + }); +}); diff --git a/test/schema.test.js b/test/schema.test.js index 9208bba..3f77067 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -64,6 +64,11 @@ describe('migrations', () => { 'knowledge_audit_logs', 'agent_questions', 'agent_answers', + 'revenue_events', + 'revenue_allocations', + 'payout_accounts', + 'payouts', + 'payout_allocations', ]) { expect(names).toContain(t); } @@ -617,3 +622,222 @@ describe('jsonb columns hold structure, not a string of one', () => { expect(after.v).toBe('70'); }); }); + +/** + * The revenue ledger. These tests are about what the database refuses, because + * every one of them is a way somebody gets paid twice or paid wrong. + */ +describe('revenue ledger', () => { + const niche = async () => + one(`insert into niches (slug, name) values ($1,'N') returning id`, [ + `v${Math.random()}`.replace('.', ''), + ]); + const person = async () => + one(`insert into users (email) values ($1) returning id`, [`v${Math.random()}@e.com`]); + const event = (nicheId, gross, cost = 0, externalId = null) => + rows( + `insert into revenue_events + (external_id, niche_id, source_type, gross_amount_minor, direct_cost_minor, net_amount_minor) + values ($1, $2, 'x402', $3, $4, $5) + on conflict (external_id) do nothing + returning id, net_amount_minor`, + [externalId, nicheId, gross, cost, gross - cost], + ); + + test('a row whose parts do not add up cannot be written', async () => { + const n = await niche(); + let threw = null; + try { + await db.query( + `insert into revenue_events (niche_id, source_type, gross_amount_minor, direct_cost_minor, net_amount_minor) + values ($1, 'x402', 1000, 30, 999)`, + [n.id], + ); + } catch (e) { + threw = e.message; + } + expect(threw).toMatch(/revenue_events_adds_up/); + }); + + test('negative money is refused', async () => { + const n = await niche(); + let threw = null; + try { + await db.query( + `insert into revenue_events (niche_id, source_type, gross_amount_minor, direct_cost_minor, net_amount_minor) + values ($1, 'x402', -100, 0, -100)`, + [n.id], + ); + } catch (e) { + threw = e.message; + } + expect(threw).toMatch(/revenue_events_non_negative/); + }); + + test('a source type nobody defined is refused', async () => { + const n = await niche(); + let threw = null; + try { + await db.query( + `insert into revenue_events (niche_id, source_type, gross_amount_minor, net_amount_minor) + values ($1, 'vibes', 100, 100)`, + [n.id], + ); + } catch (e) { + threw = e.message; + } + expect(threw).toMatch(/revenue_events_source/); + }); + + test('a settlement delivered twice books once', async () => { + const n = await niche(); + expect((await event(n.id, 100, 0, 'pay_1')).length).toBe(1); + expect((await event(n.id, 100, 0, 'pay_1')).length).toBe(0); + // A different payment is a different event. + expect((await event(n.id, 100, 0, 'pay_2')).length).toBe(1); + }); + + test('one allocation per party per event, however many times it is run', async () => { + const n = await niche(); + const u = await person(); + const [e] = await event(n.id, 1000); + const alloc = () => + rows( + `insert into revenue_allocations (revenue_event_id, influencer_id, allocation_type, share_bps, amount_minor) + values ($1, $2, 'knowledge_influencer', 2000, 200) + on conflict do nothing returning id`, + [e.id, u.id], + ); + expect((await alloc()).length).toBe(1); + expect((await alloc()).length).toBe(0); + }); + + test('the platform row is unique too, despite having no influencer', async () => { + const n = await niche(); + const [e] = await event(n.id, 1000); + const platform = () => + rows( + `insert into revenue_allocations (revenue_event_id, influencer_id, allocation_type, share_bps, amount_minor) + values ($1, null, 'platform', 10000, 1000) + on conflict do nothing returning id`, + [e.id], + ); + expect((await platform()).length).toBe(1); + // A null influencer must not defeat the uniqueness, which a plain unique + // index over a nullable column would. + expect((await platform()).length).toBe(0); + }); + + test('an allocation can belong to at most one payout', async () => { + const n = await niche(); + const u = await person(); + const [e] = await event(n.id, 1000); + const a = await one( + `insert into revenue_allocations (revenue_event_id, influencer_id, allocation_type, share_bps, amount_minor) + values ($1, $2, 'knowledge_influencer', 2000, 200) returning id`, + [e.id, u.id], + ); + const p1 = await one( + `insert into payouts (influencer_id, amount_minor) values ($1, 200) returning id`, + [u.id], + ); + const p2 = await one( + `insert into payouts (influencer_id, amount_minor) values ($1, 200) returning id`, + [u.id], + ); + await db.query(`insert into payout_allocations (payout_id, allocation_id) values ($1, $2)`, [ + p1.id, + a.id, + ]); + let threw = null; + try { + await db.query(`insert into payout_allocations (payout_id, allocation_id) values ($1, $2)`, [ + p2.id, + a.id, + ]); + } catch (e2) { + threw = e2.message; + } + // This is the one that matters: the same earning cannot be paid twice. + expect(threw).toMatch(/duplicate key|payout_allocations_pkey/i); + }); + + test('a payout of nothing is refused', async () => { + const u = await person(); + let threw = null; + try { + await db.query(`insert into payouts (influencer_id, amount_minor) values ($1, 0)`, [u.id]); + } catch (e) { + threw = e.message; + } + expect(threw).toMatch(/payouts_positive/); + }); + + test('deleting a niche keeps the record of money that moved', async () => { + const n = await niche(); + const u = await person(); + const [e] = await event(n.id, 500); + await db.query( + `insert into revenue_allocations (revenue_event_id, influencer_id, allocation_type, share_bps, amount_minor) + values ($1, $2, 'knowledge_influencer', 2000, 100)`, + [e.id, u.id], + ); + await db.query(`delete from niches where id = $1`, [n.id]); + + const kept = await one(`select niche_id, net_amount_minor from revenue_events where id = $1`, [ + e.id, + ]); + expect(kept).toBeDefined(); + // Detached, not deleted. Money that moved is not erased by tidying a niche. + expect(kept.niche_id).toBeNull(); + expect( + (await rows(`select id from revenue_allocations where revenue_event_id = $1`, [e.id])).length, + ).toBe(1); + }); + + test('what is owed excludes reversed and already-paid allocations', async () => { + const n = await niche(); + const u = await person(); + // One allocation per person per event, so five statuses need five events. + // That constraint is doing its job; it caught an earlier version of this + // very test trying to pay the same person five times out of one earning. + for (const [amount, status] of [ + [100, 'accrued'], + [50, 'eligible'], + [700, 'paid'], + [300, 'reversed'], + [40, 'scheduled'], + ]) { + const [e] = await event(n.id, amount * 5); + await db.query( + `insert into revenue_allocations (revenue_event_id, influencer_id, allocation_type, share_bps, amount_minor, status) + values ($1, $2, 'knowledge_influencer', 2000, $3, $4)`, + [e.id, u.id, amount, status], + ); + } + const owed = await one( + `select coalesce(sum(amount_minor) filter (where status in ('accrued','eligible')),0)::bigint as owed, + coalesce(sum(amount_minor) filter (where status = 'paid'),0)::bigint as paid + from revenue_allocations where influencer_id = $1`, + [u.id], + ); + expect(Number(owed.owed)).toBe(150); + expect(Number(owed.paid)).toBe(700); + }); + + test('an allocation cannot claim more than the whole', async () => { + const n = await niche(); + const [e] = await event(n.id, 100); + let threw = null; + try { + await db.query( + `insert into revenue_allocations (revenue_event_id, influencer_id, allocation_type, share_bps, amount_minor) + values ($1, null, 'platform', 10001, 100)`, + [e.id], + ); + } catch (err) { + threw = err.message; + } + expect(threw).toMatch(/revenue_allocations_bps/); + }); +});