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}
+
+
+
+
+
+
+ Your share, event by event
+ {allocations.length === 0 ? (
+ Nothing yet. A niche earns, and your share of it appears here.
+ ) : (
+
+
+
+ | When |
+ Niche |
+ Source |
+ Your share |
+ Amount |
+ Status |
+
+
+
+ {allocations.map((a) => (
+
+ |
+
+ |
+ {a.niche_name ?? '—'} |
+ {a.source_type.replace(/_/g, ' ')} |
+ {/* The share as it stood when this settled, not today's. */}
+ {formatBps(a.share_bps)} |
+
+
+ |
+
+
+ |
+
+ ))}
+
+
+ )}
+
+
+
+ Payments
+ {payouts.length === 0 ? (
+ None yet.
+ ) : (
+
+
+
+ | When |
+ Amount |
+ Status |
+ Reference |
+
+
+
+ {payouts.map((p) => (
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+ {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
+
+
+
+ | Operator |
+ Tier |
+ Share now |
+
+
+
+ {members.map((m) => (
+
+ | {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.
+ ) : (
+
+
+
+ | When |
+ Share then |
+ Amount |
+ Status |
+
+
+
+ {mine.map((a) => (
+
+ |
+
+ |
+ {formatBps(a.share_bps)} |
+
+
+ |
+
+
+ |
+
+ ))}
+
+
+ )}
+
+
+
+ Earnings
+ {events.length === 0 ? (
+ Nothing recorded yet.
+ ) : (
+
+
+
+ | When |
+ Source |
+ Gross |
+ Cost |
+ Shared |
+ Settled |
+
+
+
+ {events.map((e) => (
+
+ |
+
+ |
+ {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.
+ ) : (
+
+
+
+ | Who |
+ Owed |
+ Address |
+ |
+
+
+
+ {owed.map((o) => (
+
+ | {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.
+ ) : (
+
+
+
+ | Who |
+ Amount |
+ Status |
+ Settle |
+
+
+
+ {payouts.map((p) => (
+
+ | {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.
+ ) : (
+
+
+
+ | When |
+ Niche |
+ Source |
+ Gross |
+ Shared |
+ Split |
+
+
+
+ {events.map((e) => (
+
+ |
+
+ |
+ {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/);
+ });
+});