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/); + }); +});