@bongos/core 1.24.23 → 1.24.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +212 -57
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +42 -0
- package/clients/bongos-client/index.cjs +42 -0
- package/clients/bongos-client/index.d.ts +55 -1
- package/clients/bongos-client/index.mjs +42 -0
- package/docs/api/openapi.json +1181 -207
- package/docs/api-reference.md +30 -5
- package/docs/architecture.md +42 -0
- package/docs/copy-inventory.md +18 -8
- package/docs/copy-registry.json +99 -9
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +64 -25
- package/migrations/core_291_payout_nda_tax.sql +75 -0
- package/migrations/core_292_module_sale_payouts.sql +170 -0
- package/migrations/core_293_cash_outs.sql +184 -0
- package/migrations/core_294_cash_out_attempts_sent_by_time.sql +21 -0
- package/modules/economy/ledger-check.js +360 -0
- package/modules/economy/migrations/economy_003_user_balances.sql +116 -0
- package/modules/economy/migrations/economy_004_module_share_payouts.sql +22 -0
- package/modules/economy/migrations/economy_005_project_account_holds_index.sql +20 -0
- package/modules/economy/migrations/economy_006_ledger_checks.sql +68 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/outflow.js +209 -0
- package/modules/economy/pollers/ledger-check.js +87 -0
- package/modules/economy/project-account.js +92 -7
- package/modules/economy/routes/balances.js +79 -0
- package/modules/economy/routes/project-account.js +40 -2
- package/modules/economy/user-balances.js +308 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/hall-ui/public/project-settings.css +18 -0
- package/modules/hall-ui/public/project-settings.html +15 -2
- package/modules/hall-ui/public/project-settings.js +142 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/blockers.js +26 -0
- package/modules/lifecycle/db-claims.js +22 -0
- package/modules/lifecycle/db-ship.js +5 -5
- package/modules/lifecycle/funding-wall.js +253 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +9 -0
- package/modules/lifecycle/routes/pay-type-gates.js +7 -0
- package/modules/lifecycle/routes/project-settings.js +59 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +44 -0
- package/scripts/bongos/government-walk.js +1 -0
- package/scripts/bongos/plain-cards.js +18 -0
- package/scripts/bongos/run-unit-tests.js +28 -0
- package/src/bongos/builder-payouts.js +6 -1
- package/src/bongos/cash-outs.js +399 -0
- package/src/bongos/module-payouts.js +432 -0
- package/src/bongos/package-registry.js +3 -2
- package/src/bongos/payout-approvals.js +5 -2
- package/src/bongos/payout-gates.js +44 -7
- package/src/bongos/project-settings.js +2 -0
- package/src/bongos/routes/core-update.js +11 -3
- package/src/bongos/routes/module-payouts.js +114 -0
- package/src/bongos/routes/my-cash-outs.js +103 -0
- package/src/bongos/routes/payout-gates.js +42 -1
- package/src/bongos/routes.js +6 -0
- package/src/bongos/software-update.js +36 -16
- package/src/module-api.js +1 -1
- package/tests/builder_payouts_db.mjs +1 -0
- package/tests/cash_outs.mjs +96 -0
- package/tests/cash_outs_db.mjs +205 -0
- package/tests/funded_project_pays_proof_db.mjs +181 -0
- package/tests/funding_wall.mjs +205 -0
- package/tests/funding_wall_db.mjs +174 -0
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/ledger_check.mjs +189 -0
- package/tests/ledger_check_db.mjs +119 -0
- package/tests/module_payouts.mjs +295 -0
- package/tests/module_payouts_db.mjs +140 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/payout_gates.mjs +67 -6
- package/tests/payout_gates_db.mjs +23 -1
- package/tests/project_account_db.mjs +5 -2
- package/tests/project_outflow.mjs +103 -0
- package/tests/project_outflow_db.mjs +100 -0
- package/tests/software_update.mjs +61 -3
- package/tests/user_balances.mjs +241 -0
- package/tests/user_balances_db.mjs +137 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// tests/module_payouts.mjs — a module sale is split by the project's rule and each share
|
|
2
|
+
// is paid exactly once (task 1004910, goal 1000092 N6; ADR 0388). Behaviour of
|
|
3
|
+
// src/bongos/module-payouts.js:
|
|
4
|
+
// - the split adds up to the price exactly, the seller rounds down, the pool takes the
|
|
5
|
+
// rest by weight with largest remainders, and a pool nobody weighs stays unassigned;
|
|
6
|
+
// - a rule is refused until module_share's gate (licence, takedown, onboarding) is
|
|
7
|
+
// cleared, and while the seller fails the payout gates;
|
|
8
|
+
// - a sale's price is read from the processor, its shares are owed in the same step, the
|
|
9
|
+
// price is booked as funding, and a repeated report records nothing new;
|
|
10
|
+
// - a share is paid only under an approval for exactly that person and amount, an
|
|
11
|
+
// approval binds to one share, a gate or a missing processor holds it, and paying
|
|
12
|
+
// again sends once.
|
|
13
|
+
// The migration's own rules are pinned from its text at the end.
|
|
14
|
+
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const M = require('../src/bongos/module-payouts.js');
|
|
22
|
+
const ACCOUNT = require('../modules/economy/project-account.js');
|
|
23
|
+
|
|
24
|
+
const RULE = { seller_id: '7', seller_bps: 3000, contributors_bps: 7000, credit_row_kinds: ['session.token_reward'] };
|
|
25
|
+
const sum = (shares) => shares.reduce((s, x) => s + x.amount_cents, 0);
|
|
26
|
+
|
|
27
|
+
test('splitSale: the shares add up to the price, seller rounds down, pool by largest remainder', () => {
|
|
28
|
+
const out = M.splitSale({ priceCents: 1001, rule: RULE, weights: [
|
|
29
|
+
{ builder_id: '3', weight: 1000 }, { builder_id: '2', weight: 1000 }, { builder_id: '1', weight: 1000 },
|
|
30
|
+
] });
|
|
31
|
+
assert.equal(out.pool_unassigned_cents, 0);
|
|
32
|
+
assert.equal(sum(out.shares), 1001);
|
|
33
|
+
const seller = out.shares.find((s) => s.role === 'seller');
|
|
34
|
+
assert.equal(seller.amount_cents, 300); // floor(1001 * 0.3)
|
|
35
|
+
const c = out.shares.filter((s) => s.role === 'contributor');
|
|
36
|
+
// 701 over three equal weights: 234, 234, 233 — the extra cents go to the lower ids
|
|
37
|
+
assert.deepEqual(c.map((s) => [s.recipient_id, s.amount_cents]), [['1', 234], ['2', 234], ['3', 233]]);
|
|
38
|
+
assert.ok(c.every((s) => s.weight === 1000 && s.weight_total === 3000));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('splitSale: weight decides the pool, and a zero-cent share is dropped', () => {
|
|
42
|
+
const out = M.splitSale({ priceCents: 100, rule: { ...RULE, seller_bps: 0, contributors_bps: 10000 }, weights: [
|
|
43
|
+
{ builder_id: '1', weight: 999000 }, { builder_id: '2', weight: 1 }, { builder_id: '3', weight: 0 },
|
|
44
|
+
] });
|
|
45
|
+
assert.equal(sum(out.shares), 100);
|
|
46
|
+
assert.deepEqual(out.shares.map((s) => [s.recipient_id, s.amount_cents]), [['1', 100]]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('splitSale: a pool with no weighted contributor stays unassigned, never handed out', () => {
|
|
50
|
+
const out = M.splitSale({ priceCents: 500, rule: RULE, weights: [] });
|
|
51
|
+
assert.deepEqual(out.shares.map((s) => [s.role, s.amount_cents]), [['seller', 150]]);
|
|
52
|
+
assert.equal(out.pool_unassigned_cents, 350);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('validateSplitRule: shares must add to 10000, nothing unknown, kinds default to session rows', () => {
|
|
56
|
+
assert.deepEqual(M.validateSplitRule({ seller_id: 7, seller_bps: 4000, contributors_bps: 6000 }).rule.credit_row_kinds, ['session.token_reward']);
|
|
57
|
+
assert.ok(M.validateSplitRule({ seller_id: 7, seller_bps: 4000, contributors_bps: 5000 }).errors[0].includes('10000'));
|
|
58
|
+
assert.ok(M.validateSplitRule({ seller_id: 7, seller_bps: 4000, contributors_bps: 6000, fee_bps: 100 }).errors.some((e) => e.includes('fee_bps')));
|
|
59
|
+
assert.ok(M.validateSplitRule({ seller_id: 'x', seller_bps: 1.5, contributors_bps: 6000 }).errors.length >= 2);
|
|
60
|
+
assert.ok(M.validateSplitRule({ seller_id: 7, seller_bps: 0, contributors_bps: 10000, credit_row_kinds: [] }).errors.length === 1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── a stand-in pool that understands the statements module-payouts.js sends ─────────
|
|
64
|
+
|
|
65
|
+
function fakeDb({ approvals = [], weights = [], rule = { id: '1', module_key: 'chess', ...RULE } } = {}) {
|
|
66
|
+
const s = { sales: [], shares: [], bindings: [], attempts: [], movements: [], rules: rule ? [rule] : [], approvals, taskApprovals: [] };
|
|
67
|
+
async function query(sql, p = []) {
|
|
68
|
+
const q = sql.replace(/\s+/g, ' ').trim();
|
|
69
|
+
if (/pg_advisory_xact_lock/.test(q)) return { rows: [] };
|
|
70
|
+
if (/^SELECT module_key, status FROM store_modules/.test(q)) {
|
|
71
|
+
return { rows: p[0] === 'chess' ? [{ module_key: 'chess', status: 'listed' }] : p[0] === 'old' ? [{ module_key: 'old', status: 'delisted' }] : [] };
|
|
72
|
+
}
|
|
73
|
+
if (/FROM module_sale_shares_owed o JOIN module_sales s/.test(q)) {
|
|
74
|
+
s.listParams = p;
|
|
75
|
+
return { rows: s.shares.filter((x) => p[0] === null || x.recipient_id === String(p[0])).map((x) => {
|
|
76
|
+
const sent = s.attempts.find((a) => a.share_id === x.id && a.outcome === 'sent');
|
|
77
|
+
const last = [...s.attempts].reverse().find((a) => a.share_id === x.id);
|
|
78
|
+
const bound = s.bindings.find((b) => b.share_id === x.id);
|
|
79
|
+
return { ...x, module_key: 'chess', processor_sale_ref: 'ch_1', price_cents: '1000', recipient_name: null, created_at: null,
|
|
80
|
+
approval_id: bound ? bound.approval_id : null, sent_ref: sent ? sent.processor_ref : null, paid_at: null,
|
|
81
|
+
last_outcome: last ? last.outcome : null, last_reason: last ? last.reason : null, last_try_at: null };
|
|
82
|
+
}) };
|
|
83
|
+
}
|
|
84
|
+
if (/FROM module_split_rules WHERE module_key/.test(q)) return { rows: s.rules.filter((r) => r.module_key === p[0]).slice(-1) };
|
|
85
|
+
if (/^INSERT INTO module_split_rules/.test(q)) {
|
|
86
|
+
const row = { id: String(s.rules.length + 1), module_key: p[0], seller_id: p[1], seller_bps: p[2], contributors_bps: p[3], credit_row_kinds: p[4], set_by: p[5] };
|
|
87
|
+
s.rules.push(row);
|
|
88
|
+
return { rows: [row] };
|
|
89
|
+
}
|
|
90
|
+
if (/WITH mt AS/.test(q)) return { rows: weights };
|
|
91
|
+
if (/FROM module_sales WHERE processor_sale_ref/.test(q)) return { rows: s.sales.filter((x) => x.processor_sale_ref === p[0]) };
|
|
92
|
+
if (/FROM module_sale_shares_owed WHERE sale_id/.test(q)) return { rows: s.shares.filter((x) => x.sale_id === p[0]) };
|
|
93
|
+
if (/^INSERT INTO module_sales/.test(q)) {
|
|
94
|
+
const row = { id: String(s.sales.length + 1), module_key: p[0], processor_sale_ref: p[1], price_cents: p[2], platform_fee_cents: p[3], currency: p[4], rule_id: p[5], pool_unassigned_cents: p[6] };
|
|
95
|
+
s.sales.push(row);
|
|
96
|
+
return { rows: [row] };
|
|
97
|
+
}
|
|
98
|
+
if (/^INSERT INTO module_sale_shares_owed/.test(q)) {
|
|
99
|
+
const row = { id: String(s.shares.length + 1), sale_id: p[0], recipient_id: p[1], role: p[2], amount_cents: p[3], currency: p[4], weight: p[5], weight_total: p[6] };
|
|
100
|
+
s.shares.push(row);
|
|
101
|
+
return { rows: [row] };
|
|
102
|
+
}
|
|
103
|
+
if (/FROM module_sale_shares_owed WHERE id/.test(q)) return { rows: s.shares.filter((x) => x.id === String(p[0])) };
|
|
104
|
+
if (/FROM payout_approvals WHERE id/.test(q)) return { rows: s.approvals.filter((a) => String(a.id) === String(p[0])) };
|
|
105
|
+
if (/^INSERT INTO module_share_approvals/.test(q)) {
|
|
106
|
+
if (s.taskApprovals.includes(String(p[1]))) throw Object.assign(new Error('already pays a task'), { code: 'P0001' });
|
|
107
|
+
const clash = s.bindings.some((b) => b.share_id === String(p[0]) || b.approval_id === String(p[1]));
|
|
108
|
+
if (!clash) s.bindings.push({ share_id: String(p[0]), approval_id: String(p[1]) });
|
|
109
|
+
return { rows: [] };
|
|
110
|
+
}
|
|
111
|
+
if (/FROM module_share_approvals WHERE share_id/.test(q)) return { rows: s.bindings.filter((b) => b.share_id === String(p[0])) };
|
|
112
|
+
if (/^INSERT INTO module_share_attempts/.test(q)) {
|
|
113
|
+
const [share_id, outcome, processor_ref, reason] = p;
|
|
114
|
+
if (outcome === 'sent' && s.attempts.some((a) => a.share_id === share_id && a.outcome === 'sent')) throw Object.assign(new Error('one sent'), { code: '23505' });
|
|
115
|
+
const row = { id: s.attempts.length + 1, share_id, outcome, processor_ref, reason };
|
|
116
|
+
s.attempts.push(row);
|
|
117
|
+
return { rows: [row] };
|
|
118
|
+
}
|
|
119
|
+
if (/FROM module_share_attempts WHERE share_id = \$1 AND outcome = 'sent'/.test(q)) return { rows: s.attempts.filter((a) => a.share_id === String(p[0]) && a.outcome === 'sent') };
|
|
120
|
+
if (/^INSERT INTO economy_project_account_movements/.test(q)) {
|
|
121
|
+
const [kind, , amount_micro, builder_id, , , processor_ref, , , module_share_id] = p;
|
|
122
|
+
if (s.movements.some((m) => m.kind === kind && m.processor_ref === processor_ref)) return { rows: [] };
|
|
123
|
+
const row = { kind, amount_micro, builder_id, processor_ref, module_share_id };
|
|
124
|
+
s.movements.push(row);
|
|
125
|
+
return { rows: [row] };
|
|
126
|
+
}
|
|
127
|
+
if (/FROM economy_project_account_movements WHERE kind/.test(q)) return { rows: s.movements.filter((m) => m.kind === p[0] && m.processor_ref === p[1]) };
|
|
128
|
+
throw new Error(`fakeDb: unexpected SQL: ${q}`);
|
|
129
|
+
}
|
|
130
|
+
return { s, query };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const cleared = { refuseUnclearedGates: async () => null };
|
|
134
|
+
const uncleared = { refuseUnclearedGates: async () => ({ code: 'pay_type_gate_not_cleared', missing: ['standard_module_licence'], message: 'not cleared' }) };
|
|
135
|
+
const okGates = { gateRecipient: async () => ({ ok: true }) };
|
|
136
|
+
const account = { recordMovement: ACCOUNT.recordMovement };
|
|
137
|
+
const saleProcessor = (sale) => ({ readSale: async () => sale });
|
|
138
|
+
|
|
139
|
+
async function refusal(p) {
|
|
140
|
+
try { await p; } catch (e) { return e; }
|
|
141
|
+
assert.fail('expected a refusal');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
test('setSplitRule: refused until the licence and takedown gate is cleared, or while the seller is gated', async () => {
|
|
145
|
+
const pool = fakeDb({ rule: null });
|
|
146
|
+
const body = { seller_id: 7, seller_bps: 3000, contributors_bps: 7000 };
|
|
147
|
+
assert.equal((await refusal(M.setSplitRule({ moduleKey: 'chess', body, actorId: 1 }, { pool, gates: okGates, payTypeGates: uncleared }))).code, 'module_share_gate');
|
|
148
|
+
assert.equal((await refusal(M.setSplitRule({ moduleKey: 'chess', body, actorId: 1 }, { pool, gates: okGates, payTypeGates: null }))).code, 'module_share_gate');
|
|
149
|
+
assert.equal((await refusal(M.setSplitRule({ moduleKey: 'chess', body, actorId: 1 }, { pool, gates: { gateRecipient: async () => ({ ok: false }) }, payTypeGates: cleared }))).code, 'seller_gated');
|
|
150
|
+
assert.equal((await refusal(M.setSplitRule({ moduleKey: 'nope', body, actorId: 1 }, { pool, gates: okGates, payTypeGates: cleared }))).code, 'no_such_module');
|
|
151
|
+
assert.equal((await refusal(M.setSplitRule({ moduleKey: 'old', body, actorId: 1 }, { pool, gates: okGates, payTypeGates: cleared }))).code, 'module_delisted');
|
|
152
|
+
assert.equal(pool.s.rules.length, 0);
|
|
153
|
+
const rule = await M.setSplitRule({ moduleKey: 'chess', body, actorId: 1 }, { pool, gates: okGates, payTypeGates: cleared });
|
|
154
|
+
assert.equal(rule.seller_bps, 3000);
|
|
155
|
+
assert.equal(pool.s.rules.length, 1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('recordSale: reads the price from the processor, owes every share, books the price once', async () => {
|
|
159
|
+
const pool = fakeDb({ weights: [{ builder_id: '1', weight: 3000 }, { builder_id: '2', weight: 1000 }] });
|
|
160
|
+
const processor = saleProcessor({ moduleKey: 'chess', priceCents: 1000, platformFeeCents: 40, currency: 'USD' });
|
|
161
|
+
const out = await M.recordSale({ processorSaleRef: 'ch_1', actorId: 9 }, { pool, processor, account, payTypeGates: cleared });
|
|
162
|
+
assert.equal(out.already, false);
|
|
163
|
+
assert.deepEqual(pool.s.shares.map((x) => [x.recipient_id, x.role, x.amount_cents]), [['7', 'seller', 300], ['1', 'contributor', 525], ['2', 'contributor', 175]]);
|
|
164
|
+
assert.deepEqual(pool.s.movements.map((m) => [m.kind, m.amount_micro]), [['funding', 1000 * 10_000]]);
|
|
165
|
+
assert.equal(pool.s.sales[0].platform_fee_cents, 40); // kept apart, never split
|
|
166
|
+
|
|
167
|
+
const again = await M.recordSale({ processorSaleRef: 'ch_1', actorId: 9 }, { pool, processor, account, payTypeGates: cleared });
|
|
168
|
+
assert.equal(again.already, true);
|
|
169
|
+
assert.equal(pool.s.sales.length, 1);
|
|
170
|
+
assert.equal(pool.s.shares.length, 3);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('recordSale: refused with no processor, an uncleared gate, no rule, or a foreign currency', async () => {
|
|
174
|
+
const sale = { moduleKey: 'chess', priceCents: 1000, currency: 'USD' };
|
|
175
|
+
assert.equal((await refusal(M.recordSale({ processorSaleRef: 'ch_1', actorId: 9 }, { pool: fakeDb(), processor: null, account, payTypeGates: cleared }))).code, 'no_processor');
|
|
176
|
+
assert.equal((await refusal(M.recordSale({ processorSaleRef: 'ch_1', actorId: 9 }, { pool: fakeDb(), processor: saleProcessor(sale), account, payTypeGates: uncleared }))).code, 'module_share_gate');
|
|
177
|
+
assert.equal((await refusal(M.recordSale({ processorSaleRef: 'ch_1', actorId: 9 }, { pool: fakeDb({ rule: null }), processor: saleProcessor(sale), account, payTypeGates: cleared }))).code, 'no_split_rule');
|
|
178
|
+
assert.equal((await refusal(M.recordSale({ processorSaleRef: 'ch_1', actorId: 9 }, { pool: fakeDb(), processor: saleProcessor({ ...sale, currency: 'EUR' }), account, payTypeGates: cleared }))).code, 'currency_not_supported');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
async function soldPool(extra = {}) {
|
|
182
|
+
const pool = fakeDb({ weights: [{ builder_id: '1', weight: 1 }], ...extra });
|
|
183
|
+
await M.recordSale({ processorSaleRef: 'ch_1', actorId: 9 },
|
|
184
|
+
{ pool, processor: saleProcessor({ moduleKey: 'chess', priceCents: 1000, currency: 'USD' }), account, payTypeGates: cleared });
|
|
185
|
+
return pool; // share 1: seller 7, 300c; share 2: contributor 1, 700c
|
|
186
|
+
}
|
|
187
|
+
const approval = (id, recipient_id, amount_cents) => ({ id, kind: 'payout_approved', recipient_id, amount_cents, currency: 'USD' });
|
|
188
|
+
const yes = { checkApproval: async () => ({ ok: true, pay_to: 'self' }) };
|
|
189
|
+
|
|
190
|
+
test('payShare: pays under a matching approval, once, and books one payout naming the share', async () => {
|
|
191
|
+
const pool = await soldPool({ approvals: [approval(50, '1', 700)] });
|
|
192
|
+
const calls = [];
|
|
193
|
+
const processor = { transfer: async (req) => { calls.push(req); return { ref: 'tr_1' }; } };
|
|
194
|
+
const first = await M.payShare({ shareId: 2, approvalId: 50, actorId: 9 }, { pool, authority: yes, account, payTypeGates: cleared, processor });
|
|
195
|
+
assert.equal(first.status, 'paid');
|
|
196
|
+
assert.equal(calls[0].idempotencyKey, 'bongos-module-share-2');
|
|
197
|
+
assert.equal(calls[0].amountCents, 700);
|
|
198
|
+
const again = await M.payShare({ shareId: 2, approvalId: 50, actorId: 9 }, { pool, authority: yes, account, payTypeGates: cleared, processor });
|
|
199
|
+
assert.equal(again.already, true);
|
|
200
|
+
assert.equal(calls.length, 1);
|
|
201
|
+
const payouts = pool.s.movements.filter((m) => m.kind === 'payout');
|
|
202
|
+
assert.deepEqual(payouts.map((m) => [m.builder_id, m.module_share_id, m.amount_micro]), [['1', '2', 700 * 10_000]]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('payShare: an approval for another person or amount is refused and never binds the share', async () => {
|
|
206
|
+
const pool = await soldPool({ approvals: [approval(50, '1', 699), approval(51, '8', 700), approval(52, '1', 700)] });
|
|
207
|
+
const deps = { pool, authority: yes, account, payTypeGates: cleared, processor: null };
|
|
208
|
+
assert.equal((await refusal(M.payShare({ shareId: 2, approvalId: 50, actorId: 9 }, deps))).code, 'different_amount');
|
|
209
|
+
assert.equal((await refusal(M.payShare({ shareId: 2, approvalId: 51, actorId: 9 }, deps))).code, 'different_recipient');
|
|
210
|
+
assert.equal(pool.s.bindings.length, 0);
|
|
211
|
+
const held = await M.payShare({ shareId: 2, approvalId: 52, actorId: 9 }, deps);
|
|
212
|
+
assert.equal(held.status, 'held');
|
|
213
|
+
assert.equal(held.reason, 'no_processor');
|
|
214
|
+
assert.deepEqual(pool.s.bindings, [{ share_id: '2', approval_id: '52' }]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('payShare: one approval pays one thing — not two shares, not a task and a share', async () => {
|
|
218
|
+
const pool = await soldPool({ approvals: [approval(60, '7', 300), approval(61, '7', 300)] });
|
|
219
|
+
pool.s.shares.push({ id: '3', sale_id: '1', recipient_id: '7', role: 'seller', amount_cents: 300, currency: 'USD' });
|
|
220
|
+
const deps = { pool, authority: yes, account, payTypeGates: cleared, processor: null };
|
|
221
|
+
await M.payShare({ shareId: 1, approvalId: 60, actorId: 9 }, deps);
|
|
222
|
+
assert.equal((await refusal(M.payShare({ shareId: 3, approvalId: 60, actorId: 9 }, deps))).code, 'approval_already_used');
|
|
223
|
+
assert.equal((await refusal(M.payShare({ shareId: 1, approvalId: 61, actorId: 9 }, deps))).code, 'share_already_approved');
|
|
224
|
+
pool.s.taskApprovals.push('61');
|
|
225
|
+
assert.equal((await refusal(M.payShare({ shareId: 3, approvalId: 61, actorId: 9 }, deps))).code, 'approval_already_used');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('payShare: the same approval raced onto two shares — the loser gets a refusal, not a fault', async () => {
|
|
229
|
+
const pool = await soldPool({ approvals: [approval(80, '1', 700)] });
|
|
230
|
+
// the other press won: its binding is there, so this insert does nothing (ON CONFLICT)
|
|
231
|
+
pool.s.bindings.push({ share_id: '9', approval_id: '80' });
|
|
232
|
+
const e = await refusal(M.payShare({ shareId: 2, approvalId: 80, actorId: 9 }, { pool, authority: yes, account, payTypeGates: cleared, processor: null }));
|
|
233
|
+
assert.equal(e.code, 'approval_already_used');
|
|
234
|
+
assert.equal(e.status, 409);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('listShares: each share with where it stands, and a person sees only their own', async () => {
|
|
238
|
+
const pool = await soldPool({ approvals: [approval(90, '1', 700)] });
|
|
239
|
+
await M.payShare({ shareId: 2, approvalId: 90, actorId: 9 }, { pool, authority: yes, account, payTypeGates: cleared, processor: { transfer: async () => ({ ref: 'tr_l' }) } });
|
|
240
|
+
await M.payShare({ shareId: 1, approvalId: 90, actorId: 9 }, { pool, authority: yes, account, payTypeGates: cleared, processor: null }).catch(() => {});
|
|
241
|
+
const all = await M.listShares(pool, {});
|
|
242
|
+
assert.equal(pool.s.listParams[0], null);
|
|
243
|
+
const byId = Object.fromEntries(all.map((x) => [x.id, x]));
|
|
244
|
+
assert.equal(byId['2'].status, 'paid');
|
|
245
|
+
assert.equal(byId['2'].processor_ref, 'tr_l');
|
|
246
|
+
assert.equal(byId['2'].approval_id, '90');
|
|
247
|
+
assert.equal(byId['2'].weight, 1);
|
|
248
|
+
assert.equal(byId['1'].status, 'owed');
|
|
249
|
+
assert.equal(byId['1'].weight, null);
|
|
250
|
+
const mine = await M.listShares(pool, { recipientId: '1', limit: 9999 });
|
|
251
|
+
assert.equal(pool.s.listParams[1], 200); // the page is capped
|
|
252
|
+
assert.deepEqual(mine.map((x) => x.id), ['2']);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('payShare: the module gate, the payout gates and a failed transfer each leave the share owed', async () => {
|
|
256
|
+
const pool = await soldPool({ approvals: [approval(70, '1', 700)] });
|
|
257
|
+
const send = { transfer: async () => ({ ref: 'tr_9' }) };
|
|
258
|
+
const gated = await M.payShare({ shareId: 2, approvalId: 70, actorId: 9 }, { pool, authority: yes, account, payTypeGates: uncleared, processor: send });
|
|
259
|
+
assert.equal(gated.reason, 'module_share_gate');
|
|
260
|
+
assert.ok(gated.words.includes('licence'));
|
|
261
|
+
const no = { checkApproval: async () => ({ ok: false, reason: 'recipient_gated' }) };
|
|
262
|
+
assert.equal((await M.payShare({ shareId: 2, approvalId: 70, actorId: 9 }, { pool, authority: no, account, payTypeGates: cleared, processor: send })).reason, 'recipient_gated');
|
|
263
|
+
const boom = { transfer: async () => { throw Object.assign(new Error('card declined'), { code: 'declined' }); } };
|
|
264
|
+
const failed = await M.payShare({ shareId: 2, approvalId: 70, actorId: 9 }, { pool, authority: yes, account, payTypeGates: cleared, processor: boom });
|
|
265
|
+
assert.equal(failed.status, 'failed');
|
|
266
|
+
assert.match(failed.reason, /declined/);
|
|
267
|
+
assert.equal(pool.s.movements.filter((m) => m.kind === 'payout').length, 0);
|
|
268
|
+
assert.equal((await M.payShare({ shareId: 2, approvalId: 70, actorId: 9 }, { pool, authority: yes, account, payTypeGates: cleared, processor: send })).status, 'paid');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('the project account takes a payout that names a module share instead of a task', () => {
|
|
272
|
+
const row = ACCOUNT.checkMovement({ kind: 'payout', amountMicro: 1, builderId: 1, moduleShareId: 2, processorRef: 'tr_1' });
|
|
273
|
+
assert.equal(row.module_share_id, '2');
|
|
274
|
+
assert.throws(() => ACCOUNT.checkMovement({ kind: 'payout', amountMicro: 1, builderId: 1, processorRef: 'tr_1' }), /task or module-sale share/);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('the migrations hold the rules the code relies on', () => {
|
|
278
|
+
const core = readFileSync(new URL('../migrations/core_292_module_sale_payouts.sql', import.meta.url), 'utf8');
|
|
279
|
+
assert.match(core, /seller_bps \+ contributors_bps = 10000/);
|
|
280
|
+
assert.match(core, /module_share_attempts_one_sent ON module_share_attempts \(share_id\) WHERE outcome = 'sent'/);
|
|
281
|
+
assert.match(core, /module_share_approvals_one_per_approval ON module_share_approvals \(approval_id\)/);
|
|
282
|
+
assert.match(core, /module_sales_one_per_ref ON module_sales \(processor_sale_ref\)/);
|
|
283
|
+
assert.match(core, /BEFORE INSERT ON builder_payouts_owed/);
|
|
284
|
+
// the cross-table check is serialised per approval, or two inserts at once both pass it
|
|
285
|
+
assert.match(core, /PERFORM pg_advisory_xact_lock\(1004910, hashtext\(NEW\.approval_id::text\)\);\s+IF TG_TABLE_NAME/);
|
|
286
|
+
// the weight read is index-served end to end (the grader's efficiency finding)
|
|
287
|
+
assert.match(core, /idx_tasks_shipped_module ON tasks \(module_key\)\s+WHERE module_key IS NOT NULL AND status = 'shipped'/);
|
|
288
|
+
assert.match(core, /idx_session_records_task_ids ON session_records USING gin \(task_ids\)/);
|
|
289
|
+
assert.match(core, /idx_credit_log_session ON credit_log \(session_id\)/);
|
|
290
|
+
for (const t of ['module_split_rules', 'module_sales', 'module_sale_shares_owed', 'module_share_approvals', 'module_share_attempts']) {
|
|
291
|
+
assert.match(core, new RegExp(`BEFORE UPDATE OR DELETE ON ${t}\\b`), `${t} is append-only`);
|
|
292
|
+
}
|
|
293
|
+
const econ = readFileSync(new URL('../modules/economy/migrations/economy_004_module_share_payouts.sql', import.meta.url), 'utf8');
|
|
294
|
+
assert.match(econ, /task_id IS NOT NULL OR module_share_id IS NOT NULL/);
|
|
295
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// tests/module_payouts_db.mjs — task 1004910 (goal 1000092 N6). Real DB.
|
|
2
|
+
//
|
|
3
|
+
// Applies the REAL economy_002/003 and core_290/291 migrations and proves what Postgres
|
|
4
|
+
// itself refuses: a split rule whose shares do not add to the whole price, a second sale
|
|
5
|
+
// for one processor reference, a second `sent` attempt for one share, one approval paying
|
|
6
|
+
// two shares or a task and a share, and any rewrite. Then drives the real previewSplit,
|
|
7
|
+
// recordSale and payShare over the real tables: the weights query (task rows and session
|
|
8
|
+
// rows apportioned by the session's tasks on the module), the shares owed, the funding
|
|
9
|
+
// and payout movements.
|
|
10
|
+
//
|
|
11
|
+
// store_modules, credit_log, session_records, tasks and payout_approvals are minimal
|
|
12
|
+
// stand-ins with the columns these statements read. The gates and authority are stubbed.
|
|
13
|
+
//
|
|
14
|
+
// Self-skips without a reachable Postgres.
|
|
15
|
+
// Run: DATABASE_URL=... node tests/module_payouts_db.mjs
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const { Pool } = require('pg');
|
|
23
|
+
const M = require('../src/bongos/module-payouts.js');
|
|
24
|
+
const ACCOUNT = require('../modules/economy/project-account.js');
|
|
25
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const mig = (...p) => readFileSync(join(here, '..', ...p), 'utf8');
|
|
27
|
+
|
|
28
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
29
|
+
let client;
|
|
30
|
+
try { client = await pgPool.connect(); } catch (e) {
|
|
31
|
+
console.log(`module_payouts_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
32
|
+
await pgPool.end().catch(() => {});
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
const SCHEMA = `t1004910_${process.pid}`;
|
|
36
|
+
let pass = 0;
|
|
37
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
38
|
+
const rejects = async (sql, params) => {
|
|
39
|
+
await client.query('SAVEPOINT s');
|
|
40
|
+
let err = null;
|
|
41
|
+
try { await client.query(sql, params); } catch (e) { err = e; }
|
|
42
|
+
await client.query('ROLLBACK TO SAVEPOINT s');
|
|
43
|
+
return err;
|
|
44
|
+
};
|
|
45
|
+
const code = (e) => e && e.code;
|
|
46
|
+
const RULE = 'INSERT INTO module_split_rules (module_key, seller_id, seller_bps, contributors_bps, credit_row_kinds, set_by) VALUES ($1,$2,$3,$4,$5,$6)';
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
50
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
51
|
+
await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, display_name text)');
|
|
52
|
+
await client.query("CREATE TABLE tasks (id bigserial PRIMARY KEY, title text, module_key text, status text NOT NULL DEFAULT 'shipped')");
|
|
53
|
+
await client.query('CREATE TABLE schema_migrations (version text PRIMARY KEY)');
|
|
54
|
+
await client.query("CREATE TABLE store_modules (module_key text PRIMARY KEY, status text NOT NULL DEFAULT 'listed')");
|
|
55
|
+
await client.query('CREATE TABLE credit_log (id bigserial PRIMARY KEY, builder_id bigint, task_id bigint, delta integer, reason text, session_id text)');
|
|
56
|
+
await client.query("CREATE TABLE session_records (session_id text PRIMARY KEY, task_ids bigint[] NOT NULL DEFAULT '{}')");
|
|
57
|
+
await client.query(`CREATE TABLE payout_approvals (id bigserial PRIMARY KEY, kind text NOT NULL, recipient_id bigint,
|
|
58
|
+
amount_cents bigint, currency text)`);
|
|
59
|
+
await client.query("INSERT INTO builders (display_name) VALUES ('Ada'), ('Bo'), ('Seller'), ('Owner')");
|
|
60
|
+
// tasks 1,2 on chess and shipped; 3 on chess but not shipped; 4 on another module
|
|
61
|
+
await client.query(`INSERT INTO tasks (title, module_key, status) VALUES
|
|
62
|
+
('a', 'chess', 'shipped'), ('b', 'chess', 'shipped'), ('c', 'chess', 'active'), ('d', 'go', 'shipped')`);
|
|
63
|
+
await client.query("INSERT INTO store_modules (module_key) VALUES ('chess'), ('go')");
|
|
64
|
+
// Ada: 30 on task 1 (task row). Bo: a session that shipped tasks 2 and 4 → half of 20 = 10.
|
|
65
|
+
// Bo's estimate row on task 2 is a kind the rule does not count; Ada's row on task 3 is unshipped work.
|
|
66
|
+
await client.query(`INSERT INTO credit_log (builder_id, task_id, delta, reason, session_id) VALUES
|
|
67
|
+
(1, 1, 30, 'session.token_reward', NULL), (2, NULL, 20, 'session.token_reward', 's-bo'),
|
|
68
|
+
(2, 2, 500, 'task.shipped', NULL), (1, 3, 99, 'session.token_reward', NULL)`);
|
|
69
|
+
await client.query("INSERT INTO session_records (session_id, task_ids) VALUES ('s-bo', '{2,4}')");
|
|
70
|
+
await client.query(`INSERT INTO payout_approvals (kind, recipient_id, amount_cents, currency) VALUES
|
|
71
|
+
('payout_approved', 1, 525, 'USD'), ('payout_approved', 3, 300, 'USD'), ('payout_approved', 2, 175, 'USD')`);
|
|
72
|
+
await client.query(mig('modules', 'economy', 'migrations', 'economy_002_project_account.sql'));
|
|
73
|
+
await client.query(mig('modules', 'economy', 'migrations', 'economy_004_module_share_payouts.sql'));
|
|
74
|
+
await client.query(mig('migrations', 'core_290_builder_payouts.sql'));
|
|
75
|
+
await client.query(mig('migrations', 'core_292_module_sale_payouts.sql'));
|
|
76
|
+
await client.query('BEGIN');
|
|
77
|
+
|
|
78
|
+
// ── what the database refuses ──
|
|
79
|
+
ok(code(await rejects(RULE, ['chess', 3, 3000, 6000, ['session.token_reward'], 4])) === '23514', 'a rule whose shares do not add to the whole price is refused');
|
|
80
|
+
ok(code(await rejects(RULE, ['chess', 3, 3000, 7000, [], 4])) === '23514', 'a rule that counts no credit rows is refused');
|
|
81
|
+
|
|
82
|
+
// ── the real code over the real tables ──
|
|
83
|
+
const db = { query: (s, p) => client.query(s, p) };
|
|
84
|
+
const cleared = { refuseUnclearedGates: async () => null };
|
|
85
|
+
const okGates = { gateRecipient: async () => ({ ok: true }) };
|
|
86
|
+
await M.setSplitRule({ moduleKey: 'chess', body: { seller_id: 3, seller_bps: 3000, contributors_bps: 7000 }, actorId: 4 },
|
|
87
|
+
{ pool: db, gates: okGates, payTypeGates: cleared });
|
|
88
|
+
ok(code(await rejects('UPDATE module_split_rules SET seller_bps = 0, contributors_bps = 10000')) === 'P0001', 'a split rule is never edited');
|
|
89
|
+
|
|
90
|
+
const preview = await M.previewSplit('chess', { pool: db });
|
|
91
|
+
const w = Object.fromEntries(preview.contributors.map((c) => [c.builder_id, c.weight]));
|
|
92
|
+
ok(w['1'] === 30_000 && w['2'] === 10_000 && Object.keys(w).length === 2,
|
|
93
|
+
'weight counts the rule\'s row kinds on shipped module tasks, a session row in its share of the session\'s module tasks');
|
|
94
|
+
|
|
95
|
+
const processor = {
|
|
96
|
+
async readSale() { return { moduleKey: 'chess', priceCents: 1000, platformFeeCents: 40, currency: 'USD' }; },
|
|
97
|
+
async transfer(req) { return { ref: `tr_${req.moduleShareId}` }; },
|
|
98
|
+
};
|
|
99
|
+
const sale = await M.recordSale({ processorSaleRef: 'ch_1', actorId: 4 }, { pool: db, processor, account: ACCOUNT, payTypeGates: cleared });
|
|
100
|
+
const byWho = Object.fromEntries(sale.shares.map((s) => [String(s.recipient_id), Number(s.amount_cents)]));
|
|
101
|
+
ok(byWho['3'] === 300 && byWho['1'] === 525 && byWho['2'] === 175, 'the sale owes the seller 300 and the pool 525 / 175 by weight');
|
|
102
|
+
const again = await M.recordSale({ processorSaleRef: 'ch_1', actorId: 4 }, { pool: db, processor, account: ACCOUNT, payTypeGates: cleared });
|
|
103
|
+
ok(again.already === true && (await client.query('SELECT count(*)::int AS n FROM module_sales')).rows[0].n === 1, 'a repeated report records nothing new');
|
|
104
|
+
ok(code(await rejects(`INSERT INTO module_sales (module_key, processor_sale_ref, price_cents, currency, rule_id, recorded_by)
|
|
105
|
+
VALUES ('chess', 'ch_1', 5, 'USD', 1, 4)`)) === '23505', 'a second sale for one processor reference is refused');
|
|
106
|
+
ok(code(await rejects('DELETE FROM module_sale_shares_owed')) === 'P0001', 'a share is never deleted');
|
|
107
|
+
|
|
108
|
+
const shareOf = (r) => String(sale.shares.find((s) => String(s.recipient_id) === r).id);
|
|
109
|
+
const allow = { checkApproval: async () => ({ ok: true, pay_to: 'self' }) };
|
|
110
|
+
const deps = { pool: db, authority: allow, account: ACCOUNT, payTypeGates: cleared, processor };
|
|
111
|
+
const paid = await M.payShare({ shareId: shareOf('1'), approvalId: 1, actorId: 4 }, deps);
|
|
112
|
+
ok(paid.status === 'paid', 'a share is paid under its approval');
|
|
113
|
+
const twice = await M.payShare({ shareId: shareOf('1'), approvalId: 1, actorId: 4 }, deps);
|
|
114
|
+
ok(twice.already === true, 'pressing again sends nothing');
|
|
115
|
+
ok(code(await rejects("INSERT INTO module_share_attempts (share_id, outcome, processor_ref) VALUES ($1, 'sent', 'tr_x')", [shareOf('1')])) === '23505',
|
|
116
|
+
'a second sent attempt for one share is refused');
|
|
117
|
+
ok(code(await rejects('INSERT INTO module_share_approvals (share_id, approval_id, bound_by) VALUES ($1, 1, 4)', [shareOf('2')])) === '23505',
|
|
118
|
+
'one approval cannot bind two shares');
|
|
119
|
+
ok(code(await rejects('INSERT INTO builder_payouts_owed (builder_id, task_id, approval_id, amount_cents, currency, recorded_by) VALUES (1, 1, 1, 525, \'USD\', 4)')) === 'P0001',
|
|
120
|
+
'an approval spent on a share cannot also pay a task');
|
|
121
|
+
|
|
122
|
+
const mv = await client.query("SELECT kind, amount_micro, module_share_id, task_id FROM economy_project_account_movements ORDER BY id");
|
|
123
|
+
ok(mv.rows.length === 2 && mv.rows[0].kind === 'funding' && Number(mv.rows[0].amount_micro) === 10_000_000
|
|
124
|
+
&& mv.rows[1].kind === 'payout' && String(mv.rows[1].module_share_id) === shareOf('1') && mv.rows[1].task_id === null,
|
|
125
|
+
'the price is booked as funding and the share as a payout naming the share, not a task');
|
|
126
|
+
ok(code(await rejects("INSERT INTO economy_project_account_movements (kind, direction, amount_micro, builder_id, processor_ref) VALUES ('payout', 'out', 1, 1, 'tr_z')")) === '23514',
|
|
127
|
+
'a payout naming neither a task nor a share is still refused');
|
|
128
|
+
|
|
129
|
+
const mine = await M.listShares(db, { recipientId: '1' });
|
|
130
|
+
ok(mine.length === 1 && mine[0].status === 'paid' && mine[0].weight === 30_000 && mine[0].weight_total === 40_000,
|
|
131
|
+
'a person sees their own share, paid, with the weight that produced it');
|
|
132
|
+
|
|
133
|
+
await client.query('ROLLBACK');
|
|
134
|
+
console.log(`module_payouts_db.mjs: ${pass} passed`);
|
|
135
|
+
} finally {
|
|
136
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
137
|
+
await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
138
|
+
client.release();
|
|
139
|
+
await pgPool.end();
|
|
140
|
+
}
|
|
@@ -94,6 +94,11 @@ const OWN_SCOPED_SURFACES = [
|
|
|
94
94
|
why: "the CALLER's own side of the payout gates (task 1004914, ADR 0385 D8): their payout standing and holds, declaring their own age tier, appealing a hold on their own pay (anyone else's answers 404, and the database refuses an appeal by anyone but the frozen person), and co-signing as themselves where the database checks they are the current guardian. Every route acts on req.builder.id. The approver's routes live apart in routes/payout-gates.js for exactly that reason.",
|
|
95
95
|
crossOwner: [],
|
|
96
96
|
},
|
|
97
|
+
{
|
|
98
|
+
file: 'src/bongos/routes/my-cash-outs.js',
|
|
99
|
+
why: "the CALLER's own cash-outs (task 1004911, N7): list, request, retry and cancel a withdrawal of their OWN balance to their bank. Every call passes req.builder.id as the owner; a cash-out id belonging to somebody else answers 404, and the approval it records names the caller as both approver and recipient (core_293's CHECK).",
|
|
100
|
+
crossOwner: [],
|
|
101
|
+
},
|
|
97
102
|
{
|
|
98
103
|
file: 'modules/specialities/routes/specialities.js',
|
|
99
104
|
why: "role sub-specialities (task 1003855, ADR 0296). The own-scoped route here is GET /me/specialities — the CALLER's own adoptions, taken from req.builder.id with nothing read off the request. The rest of the router is /specialities/* rather than /me/*, and those are ownership-checked per row against req.builder.id after re-reading the speciality server-side, never from a client-supplied owner id.",
|
package/tests/payout_gates.mjs
CHANGED
|
@@ -26,7 +26,10 @@ const NOW = new Date('2026-10-29T12:00:00Z');
|
|
|
26
26
|
const LATER = '2027-01-01T00:00:00Z';
|
|
27
27
|
const adult = [{ id: '1', tier: 'adult' }];
|
|
28
28
|
const clear = [{ id: '1', result: 'clear', screened_by: 'person:owner', valid_until: LATER }];
|
|
29
|
-
const
|
|
29
|
+
const nda = [{ id: '1', status: 'on_file', nda_version: 'beta-v1' }];
|
|
30
|
+
const tax = [{ id: '1', status: 'collected', collected_by: 'person:owner' }];
|
|
31
|
+
const papers = { ndas: nda, taxDetails: tax };
|
|
32
|
+
const d = (f) => gates.decide({ ageRecords: adult, screenings: clear, ...papers, now: NOW, ...f });
|
|
30
33
|
|
|
31
34
|
// ── 1. the decision ──────────────────────────────────────────────────────
|
|
32
35
|
{
|
|
@@ -49,7 +52,7 @@ const d = (f) => gates.decide({ ageRecords: adult, screenings: clear, now: NOW,
|
|
|
49
52
|
assert.equal(f.reason, 'frozen'); assert.equal(f.freeze_id, '4'); ok('an open hold stops pay');
|
|
50
53
|
assert.equal(d({ freezes: [{ id: '4', reason: 'manual' }], freezeEvents: [{ freeze_id: '4', kind: 'lifted' }] }).ok, true); ok('a lifted hold does not');
|
|
51
54
|
assert.equal(d({ freezes: [{ id: '4' }], freezeEvents: [{ freeze_id: '4', kind: 'appealed' }] }).reason, 'frozen'); ok('an appeal alone does not lift a hold');
|
|
52
|
-
for (const r of ['sanctions_match', 'sanctions_unscreened', 'age_no_money', 'age_needs_guardian', 'age_unknown']) {
|
|
55
|
+
for (const r of ['sanctions_match', 'sanctions_unscreened', 'age_no_money', 'age_needs_guardian', 'age_unknown', 'nda_missing', 'tax_details_missing']) {
|
|
53
56
|
assert.ok(gates.NOTICES[r] && gates.NOTICES[r].length >= 20 && gates.NOTICES[r].length <= 1000, r);
|
|
54
57
|
assert.match(gates.NOTICES[r], /appeal/i, r);
|
|
55
58
|
}
|
|
@@ -72,6 +75,8 @@ function fakeDb({ facts = {}, open = null } = {}) {
|
|
|
72
75
|
if (/FROM payout_age_records/.test(sql)) return { rows: facts.ageRecords || [] };
|
|
73
76
|
if (/FROM payout_guardians/.test(sql)) return { rows: facts.guardians || [] };
|
|
74
77
|
if (/FROM payout_screenings/.test(sql)) return { rows: facts.screenings || [] };
|
|
78
|
+
if (/FROM payout_ndas/.test(sql)) return { rows: facts.ndas || [] };
|
|
79
|
+
if (/FROM payout_tax_details/.test(sql)) return { rows: facts.taxDetails || [] };
|
|
75
80
|
if (/FROM payout_freezes WHERE builder_id/.test(sql)) return { rows: facts.freezes || [] };
|
|
76
81
|
if (/FROM payout_freeze_events/.test(sql)) return { rows: [] };
|
|
77
82
|
throw new Error('unexpected sql ' + sql);
|
|
@@ -87,10 +92,10 @@ function fakeDb({ facts = {}, open = null } = {}) {
|
|
|
87
92
|
db = fakeDb({ facts: { ageRecords: adult, screenings: [] }, open: 55 });
|
|
88
93
|
g = await gates.gateOwedPay('8', { owedCents: 500, now: NOW, pool: db.pool });
|
|
89
94
|
assert.equal(g.freeze_id, '55'); ok('a second refusal for the same reason reuses the open hold');
|
|
90
|
-
db = fakeDb({ facts: { ageRecords: adult, screenings: clear } });
|
|
95
|
+
db = fakeDb({ facts: { ageRecords: adult, screenings: clear, ...papers } });
|
|
91
96
|
g = await gates.gateOwedPay('8', { owedCents: 500, now: NOW, pool: db.pool });
|
|
92
97
|
assert.equal(g.ok, true); assert.ok(!db.calls.some((s) => /INSERT/.test(s))); ok('payable pay files nothing');
|
|
93
|
-
db = fakeDb({ facts: { ageRecords: adult, screenings: clear, freezes: [{ id: 3, reason: 'manual' }] } });
|
|
98
|
+
db = fakeDb({ facts: { ageRecords: adult, screenings: clear, ...papers, freezes: [{ id: 3, reason: 'manual' }] } });
|
|
94
99
|
g = await gates.gateOwedPay('8', { now: NOW, pool: db.pool });
|
|
95
100
|
assert.equal(g.reason, 'frozen'); assert.ok(!db.calls.some((s) => /INSERT/.test(s))); ok('an existing hold is reported, not filed twice');
|
|
96
101
|
}
|
|
@@ -256,7 +261,8 @@ try {
|
|
|
256
261
|
assert.match(sql, /BEFORE UPDATE OR DELETE ON %I/); assert.match(sql, /BEFORE TRUNCATE ON %I/); ok('every gate table is append-only by trigger');
|
|
257
262
|
assert.match(sql, /only the person whose pay is frozen may appeal it/); assert.match(sql, /may not decide the appeal on their own freeze/); assert.match(sql, /only the builder''s current guardian may co-sign/); ok('the database repeats who may appeal, decide and co-sign');
|
|
258
263
|
for (const t of gates.AGE_TIERS) assert.ok(sql.includes(`'${t}'`), t);
|
|
259
|
-
|
|
264
|
+
const latestReasons = readFileSync(new URL('../migrations/core_291_payout_nda_tax.sql', import.meta.url), 'utf8');
|
|
265
|
+
for (const r of gates.FREEZE_REASONS) assert.ok(latestReasons.includes(`'${r}'`), r);
|
|
260
266
|
ok('every tier and hold reason the code writes is one the table accepts');
|
|
261
267
|
}
|
|
262
268
|
|
|
@@ -310,13 +316,68 @@ try {
|
|
|
310
316
|
const rows = (t) => params[0].map((b, i) => ({ id: String(i + 1), builder_id: b, ...t }));
|
|
311
317
|
if (/FROM payout_age_records/.test(q)) return { rows: rows({ tier: 'adult' }) };
|
|
312
318
|
if (/FROM payout_screenings/.test(q)) return { rows: rows({ result: 'clear', screened_by: 'person:o', valid_until: LATER }) };
|
|
319
|
+
if (/FROM payout_ndas/.test(q)) return { rows: rows({ status: 'on_file', nda_version: 'beta-v1' }) };
|
|
320
|
+
if (/FROM payout_tax_details/.test(q)) return { rows: rows({ status: 'collected', collected_by: 'person:o' }) };
|
|
313
321
|
return { rows: [] };
|
|
314
322
|
} };
|
|
315
323
|
const owed = new Map(Array.from({ length: 40 }, (_, i) => [String(100 + i), 10]));
|
|
316
324
|
const out = await gates.gateOwedPayMany(owed, { now: NOW, pool });
|
|
317
325
|
assert.equal(out.size, 40); assert.ok([...out.values()].every((v) => v.ok));
|
|
318
|
-
assert.equal(queries.length,
|
|
326
|
+
assert.equal(queries.length, 6); ok('forty recipients are checked with six reads, not six per person');
|
|
319
327
|
assert.equal((await gates.readFacts('7', { pool })).ageRecords.length, 1); ok('a single recipient reads through the same batched path');
|
|
320
328
|
}
|
|
321
329
|
|
|
330
|
+
// ── 9. contractor papers: an NDA on file and tax details collected (task 1004908) ──
|
|
331
|
+
{
|
|
332
|
+
assert.equal(d({ ndas: [] }).reason, 'nda_missing'); ok('no NDA on file: not paid');
|
|
333
|
+
assert.equal(d({ ndas: [...nda, { status: 'withdrawn', nda_version: 'beta-v1' }] }).reason, 'nda_missing'); ok('a withdrawn NDA stops pay again: the latest record wins');
|
|
334
|
+
assert.equal(d({ taxDetails: [] }).reason, 'tax_details_missing'); ok('tax details never collected: not paid');
|
|
335
|
+
assert.equal(d({ taxDetails: [{ status: 'incomplete', collected_by: 'person:o' }] }).reason, 'tax_details_missing'); ok('incomplete onboarding: not paid');
|
|
336
|
+
assert.equal(d({ ndas: [], taxDetails: [] }).reason, 'nda_missing'); ok('with both missing, the NDA is named first');
|
|
337
|
+
assert.equal(d({ screenings: [], ndas: [] }).reason, 'sanctions_unscreened'); ok('the papers come after age and sanctions');
|
|
338
|
+
let db = fakeDb({ facts: { ageRecords: adult, screenings: clear, ndas: nda } });
|
|
339
|
+
const g = await gates.gateOwedPay('8', { owedCents: 900, now: NOW, pool: db.pool });
|
|
340
|
+
assert.equal(g.reason, 'tax_details_missing'); assert.equal(g.freeze_id, '77'); assert.match(g.notice, /payment processor, never to us/);
|
|
341
|
+
ok('owed pay missing tax details is held with a notice, never silently');
|
|
342
|
+
const own = gates.presentOwn({ ok: false, reason: 'nda_missing', facts: { ndas: nda, taxDetails: [] } });
|
|
343
|
+
assert.equal(own.nda_on_file, true); assert.equal(own.tax_details_collected, false); ok('the person sees which paper is missing');
|
|
344
|
+
|
|
345
|
+
for (const t of ['123-45-6789', '12-3456789', 'ssn 123 45 6789', '123456789']) assert.equal(gates.looksLikeTaxId(t), true, t);
|
|
346
|
+
for (const t of ['acct_1Nv0FGQ9RKHg', 'onboarding-2026-11-02', 'env_ab12cd34']) assert.equal(gates.looksLikeTaxId(t), false, t);
|
|
347
|
+
assert.throws(() => gates.recordTaxDetails({ builderId: '8', status: 'collected', collectedBy: 'person:o', reference: '123-45-6789', recordedBy: '7' }, { pool: { query: async () => { throw new Error('must not reach the database'); } } }), /tax id/);
|
|
348
|
+
ok('a reference shaped like a tax id is refused before it reaches the database');
|
|
349
|
+
|
|
350
|
+
const wrote = [];
|
|
351
|
+
const g2 = { ...gates, recordNda: async (r) => { wrote.push(['nda', r]); return { id: '1' }; }, recordTaxDetails: async (r) => { wrote.push(['tax', r]); return { id: '2' }; } };
|
|
352
|
+
let as = { builder: { id: '7', github_login: 'owner' }, gdsSession: { source: 'web', session_id: '1' } };
|
|
353
|
+
const perms = [];
|
|
354
|
+
const a = express(); a.use(express.json());
|
|
355
|
+
a.use((req, res, next) => { res.fail = (code, o = {}) => res.status(o.status || 400).json({ error: code, message: o.message }); req.builder = as.builder; req.gdsSession = as.gdsSession; next(); });
|
|
356
|
+
a.use('/', buildGates({ gates: g2, requireBuilder: (q, r, nx) => nx(), requirePermission: (...p) => { perms.push(p.join(',')); return (q, r, nx) => nx(); } }));
|
|
357
|
+
const srv = http.createServer(a).listen(0);
|
|
358
|
+
const post = (path, body) => new Promise((resolve) => { const r = http.request({ port: srv.address().port, path, method: 'POST', headers: { 'content-type': 'application/json' } }, (res) => { let t = ''; res.on('data', (c) => (t += c)); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(t) })); }); r.write(JSON.stringify(body)); r.end(); });
|
|
359
|
+
let r = await post('/payouts/ndas', { builder_id: 8, status: 'on_file', nda_version: 'beta-v1', reference: 'envelope-test-1' });
|
|
360
|
+
assert.equal(r.status, 201); assert.deepEqual(wrote.pop(), ['nda', { builderId: 8, status: 'on_file', ndaVersion: 'beta-v1', reference: 'envelope-test-1', recordedBy: '7' }]); ok('an approver records an NDA on file, with where the signed copy lives');
|
|
361
|
+
assert.equal((await post('/payouts/ndas', { builder_id: 8, status: 'signed', nda_version: 'beta-v1' })).body.error, 'bad_status'); ok('an unknown NDA status is refused');
|
|
362
|
+
assert.equal((await post('/payouts/ndas', { builder_id: 8, status: 'on_file', nda_version: 'beta v1; drop' })).body.error, 'bad_nda_version'); ok('an NDA version is a short label, not free text');
|
|
363
|
+
assert.equal((await post('/payouts/ndas', { builder_id: 7, status: 'on_file', nda_version: 'beta-v1' })).body.error, 'own_papers'); ok('nobody records their own NDA');
|
|
364
|
+
r = await post('/payouts/tax-details', { builder_id: 8, status: 'collected', reference: 'acct_test_onboarding' });
|
|
365
|
+
assert.equal(r.status, 201); assert.deepEqual(wrote.pop(), ['tax', { builderId: 8, status: 'collected', collectedBy: 'person:owner', reference: 'acct_test_onboarding', recordedBy: '7' }]); ok('an approver records that the processor collected the tax details');
|
|
366
|
+
r = await post('/payouts/tax-details', { builder_id: 8, status: 'collected', reference: '123-45-6789' });
|
|
367
|
+
assert.equal(r.body.error, 'tax_id_refused'); assert.equal(wrote.length, 0); ok('a tax id sent as the reference is refused and nothing is written');
|
|
368
|
+
assert.equal((await post('/payouts/tax-details', { builder_id: 7, status: 'collected' })).body.error, 'own_papers'); ok('nobody records their own tax details');
|
|
369
|
+
as = { builder: { id: '7', github_login: 'owner' }, gdsSession: { source: 'cli', session_id: '1' } };
|
|
370
|
+
assert.equal((await post('/payouts/ndas', { builder_id: 8, status: 'on_file', nda_version: 'beta-v1' })).status, 403);
|
|
371
|
+
assert.equal((await post('/payouts/tax-details', { builder_id: 8, status: 'collected' })).status, 403); ok('a command-line or AI session records neither paper');
|
|
372
|
+
srv.close();
|
|
373
|
+
assert.ok(perms.length >= 2 && perms.every((p) => p === 'payout.approve')); ok('both routes need payout.approve');
|
|
374
|
+
|
|
375
|
+
const sql = readFileSync(new URL('../migrations/core_291_payout_nda_tax.sql', import.meta.url), 'utf8');
|
|
376
|
+
for (const t of ['payout_ndas', 'payout_tax_details']) assert.ok(sql.includes(`'${t}'`), t);
|
|
377
|
+
assert.match(sql, /BEFORE UPDATE OR DELETE ON %I/); ok('both paper tables are append-only by trigger');
|
|
378
|
+
assert.match(sql, /payout_tax_details_no_tax_id CHECK/); assert.doesNotMatch(sql, /(tin|ssn|ein|tax_id)s+text/i); ok('the database has no column for a tax id and refuses one in the reference');
|
|
379
|
+
for (const st of [...gates.NDA_STATUSES, ...gates.TAX_STATUSES]) assert.ok(sql.includes(`'${st}'`), st);
|
|
380
|
+
ok('every paper status the code writes is one the tables accept');
|
|
381
|
+
}
|
|
382
|
+
|
|
322
383
|
console.log(`\n${n} passed`);
|