@bongos/core 1.24.23 → 1.24.24
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 +208 -53
- 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 +2 -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 +38 -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/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/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/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/user_balances.mjs +241 -0
- package/tests/user_balances_db.mjs +137 -0
|
@@ -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`);
|
|
@@ -37,7 +37,7 @@ try {
|
|
|
37
37
|
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
38
38
|
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
39
39
|
await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, system_role text)');
|
|
40
|
-
for (const m of ['core_283_payout_approvals.sql', 'core_284_payout_gates.sql']) {
|
|
40
|
+
for (const m of ['core_283_payout_approvals.sql', 'core_284_payout_gates.sql', 'core_291_payout_nda_tax.sql']) {
|
|
41
41
|
await client.query(readFileSync(join(here, '..', 'migrations', m), 'utf8'));
|
|
42
42
|
}
|
|
43
43
|
// 1 owner, 2 a 16-17 builder, 3 their guardian, 4 an adult, 5 someone else
|
|
@@ -69,6 +69,20 @@ try {
|
|
|
69
69
|
await refused("INSERT INTO payout_freeze_events (freeze_id, kind, actor_id, statement) VALUES ($1, 'lifted', 4, 'x')", [v.freeze_id], 'the frozen person cannot lift their own hold');
|
|
70
70
|
|
|
71
71
|
await g.recordScreening({ builderId: 4, result: 'clear', screenedBy: 'person:owner', validUntil: new Date(Date.now() + 86_400_000).toISOString(), recordedBy: 1 }, o);
|
|
72
|
+
|
|
73
|
+
// contractor papers (task 1004908): an NDA on file and tax details collected
|
|
74
|
+
await refused("INSERT INTO payout_ndas (builder_id, status, nda_version, recorded_by) VALUES (4, 'on_file', 'beta-v1', 4)", [], 'nobody records their own NDA');
|
|
75
|
+
await refused("INSERT INTO payout_ndas (builder_id, status, nda_version, recorded_by) VALUES (4, 'signed', 'beta-v1', 1)", [], 'an unknown NDA status is refused');
|
|
76
|
+
await refused("INSERT INTO payout_tax_details (builder_id, status, collected_by, reference, recorded_by) VALUES (4, 'collected', 'person:owner', 'tin 123-45-6789', 1)", [], 'a tax id in the reference is refused by the database');
|
|
77
|
+
await refused("INSERT INTO payout_tax_details (builder_id, status, collected_by, recorded_by) VALUES (4, 'collected', 'person:owner', 4)", [], 'nobody records their own tax details');
|
|
78
|
+
const nda = await g.recordNda({ builderId: 4, status: 'on_file', ndaVersion: 'beta-v1', reference: 'envelope-test-1', recordedBy: 1 }, o);
|
|
79
|
+
await refused('UPDATE payout_ndas SET status = $1 WHERE id = $2', ['withdrawn', nda.id], 'an NDA record cannot be edited');
|
|
80
|
+
const p = await g.gateOwedPay(4, { owedCents: 300, pool: client });
|
|
81
|
+
ok(p.reason === 'frozen', 'papers do not lift an open hold either');
|
|
82
|
+
await g.recordTaxDetails({ builderId: 4, status: 'incomplete', collectedBy: 'person:owner', reference: 'acct_test_onboarding', recordedBy: 1 }, o);
|
|
83
|
+
await g.recordTaxDetails({ builderId: 4, status: 'collected', collectedBy: 'person:owner', reference: 'acct_test_onboarding', recordedBy: 1 }, o);
|
|
84
|
+
// after rows exist: the refuse-change trigger is per-row, so a DELETE on an empty table has nothing to refuse (task 1005039)
|
|
85
|
+
await refused('DELETE FROM payout_tax_details', [], 'tax-detail records cannot be deleted');
|
|
72
86
|
ok((await g.gateOwedPay(4, { pool: client })).reason === 'frozen', 'a clear screening does not lift a hold by itself: a person does');
|
|
73
87
|
await g.addFreezeEvent({ freezeId: v.freeze_id, kind: 'lifted', actorId: 1, statement: 'Screening came back clear.' }, o);
|
|
74
88
|
await refused("INSERT INTO payout_freeze_events (freeze_id, kind, actor_id, statement) VALUES ($1, 'upheld', 1, 'x')", [v.freeze_id], 'nothing follows a lift');
|
|
@@ -82,6 +96,14 @@ try {
|
|
|
82
96
|
await refused("INSERT INTO payout_guardians (builder_id, guardian_id, active, recorded_by) VALUES (2, 3, true, 1)", [], 'an active guardian link must name the guardian-controlled account');
|
|
83
97
|
await refused("INSERT INTO payout_guardians (builder_id, guardian_id, guardian_account, active, recorded_by) VALUES (2, 2, 'acct', true, 1)", [], 'nobody is their own guardian');
|
|
84
98
|
await g.recordGuardian({ builderId: 2, guardianId: 3, guardianAccount: 'acct_guardian', active: true, recordedBy: 1 }, o);
|
|
99
|
+
ok((await g.gateRecipient(2, o)).reason === 'nda_missing', 'with no NDA on file, not payable');
|
|
100
|
+
await g.recordNda({ builderId: 2, status: 'on_file', ndaVersion: 'beta-v1', reference: null, recordedBy: 1 }, o);
|
|
101
|
+
v = await g.gateOwedPay(2, { owedCents: 400, pool: client });
|
|
102
|
+
ok(v.reason === 'tax_details_missing' && v.freeze_id, 'with no tax details collected, owed pay is held and told why');
|
|
103
|
+
const paperHold = await g.readFreeze(v.freeze_id, o);
|
|
104
|
+
ok(paperHold.reason === 'tax_details_missing' && paperHold.notice_text === g.NOTICES.tax_details_missing, 'the hold table accepts the new reason, with its notice');
|
|
105
|
+
await g.addFreezeEvent({ freezeId: v.freeze_id, kind: 'lifted', actorId: 1, statement: 'Onboarding finished.' }, o);
|
|
106
|
+
await g.recordTaxDetails({ builderId: 2, status: 'collected', collectedBy: 'person:owner', reference: 'acct_test_ward', recordedBy: 1 }, o);
|
|
85
107
|
v = await g.gateRecipient(2, o);
|
|
86
108
|
ok(v.ok && v.needs_guardian_cosign && v.pay_to.guardian_account === 'acct_guardian', '16-17 with a guardian is paid only into the guardian\'s account, co-signed');
|
|
87
109
|
|
|
@@ -18,7 +18,9 @@ const require = createRequire(import.meta.url);
|
|
|
18
18
|
const { Pool } = require('pg');
|
|
19
19
|
const A = require('../modules/economy/project-account.js');
|
|
20
20
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
-
|
|
21
|
+
// economy_004 (task 1004910) adds module_share_id, which recordMovement writes.
|
|
22
|
+
const MIGRATION = ['economy_002_project_account.sql', 'economy_004_module_share_payouts.sql']
|
|
23
|
+
.map((f) => readFileSync(join(here, '..', 'modules', 'economy', 'migrations', f), 'utf8')).join('\n');
|
|
22
24
|
|
|
23
25
|
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
24
26
|
let client;
|
|
@@ -45,7 +47,8 @@ try {
|
|
|
45
47
|
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
46
48
|
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
47
49
|
await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, display_name text)');
|
|
48
|
-
|
|
50
|
+
// status: a hold counts only while its task is being worked (task 1003185)
|
|
51
|
+
await client.query("CREATE TABLE tasks (id bigserial PRIMARY KEY, title text, status text NOT NULL DEFAULT 'active')");
|
|
49
52
|
await client.query('CREATE TABLE schema_migrations (version text PRIMARY KEY)');
|
|
50
53
|
await client.query("INSERT INTO builders (display_name) VALUES ('Ada'), ('Owner')");
|
|
51
54
|
await client.query("INSERT INTO tasks (title) VALUES ('Build the thing')");
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// tests/project_outflow.mjs — where the money went (task 1003184, goal 1000092 M05;
|
|
2
|
+
// criterion money-outflow-visible). Behaviour of modules/economy/outflow.js's shapeOutflow:
|
|
3
|
+
// - a debt with no sent attempt is committed, and so is an approval nothing was booked against;
|
|
4
|
+
// - the balance left after commitments subtracts both, and goes negative when it must;
|
|
5
|
+
// - holds are shown but never subtracted twice (available already excludes them);
|
|
6
|
+
// - a commitment in another currency is counted, never added to a dollar figure;
|
|
7
|
+
// - per-builder totals sum spend, paid, owed and approved, biggest first;
|
|
8
|
+
// - each month's burn is its spend plus money out of the account.
|
|
9
|
+
// The SQL is proven against real Postgres in tests/project_outflow_db.mjs.
|
|
10
|
+
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const { shapeOutflow } = require('../modules/economy/outflow.js');
|
|
17
|
+
|
|
18
|
+
const account = (recorded, held = 0) => ({ recorded_balance_micro: String(recorded), held_micro: String(held) });
|
|
19
|
+
const base = {
|
|
20
|
+
burn: [], payouts: [], approved: [], spendByBuilder: [], spendTotal: '0',
|
|
21
|
+
account: account(0), names: [{ id: '1', display_name: 'Ada' }, { id: '2', display_name: 'Bo' }],
|
|
22
|
+
};
|
|
23
|
+
const owed = (id, builder, cents, paid = false, extra = {}) => ({
|
|
24
|
+
id, builder_id: builder, task_id: 90 + id, task_title: `task ${id}`, amount_cents: String(cents),
|
|
25
|
+
currency: 'USD', created_at: `2026-10-0${id}T00:00:00Z`, paid, ...extra,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a balance that owes three payouts shows what is left after them', () => {
|
|
29
|
+
const out = shapeOutflow({
|
|
30
|
+
...base,
|
|
31
|
+
account: account(100_000_000),
|
|
32
|
+
payouts: [owed(1, '1', 3000), owed(2, '1', 3000), owed(3, '2', 3000), owed(4, '2', 5000, true)],
|
|
33
|
+
});
|
|
34
|
+
assert.equal(out.committed.owed_count, 3);
|
|
35
|
+
assert.equal(out.committed.owed_micro, 90_000_000);
|
|
36
|
+
assert.equal(out.balance.available_micro, 100_000_000);
|
|
37
|
+
assert.equal(out.balance.after_commitments_micro, 10_000_000);
|
|
38
|
+
assert.equal(out.totals.paid_micro, 50_000_000, 'a sent payout is paid, not owed');
|
|
39
|
+
assert.deepEqual(out.committed.owed.map((r) => r.owed_id), ['1', '2', '3'], 'oldest debt first');
|
|
40
|
+
assert.equal(out.committed.owed[0].builder_name, 'Ada');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('an approval with no payout booked is committed too, and can push the balance below zero', () => {
|
|
44
|
+
const out = shapeOutflow({
|
|
45
|
+
...base,
|
|
46
|
+
account: account(10_000_000),
|
|
47
|
+
approved: [{ id: 7, builder_id: '2', amount_cents: '2500', currency: 'USD' }],
|
|
48
|
+
});
|
|
49
|
+
assert.equal(out.committed.approved_count, 1);
|
|
50
|
+
assert.equal(out.committed.approved_micro, 25_000_000);
|
|
51
|
+
assert.equal(out.committed.total_micro, 25_000_000);
|
|
52
|
+
assert.equal(out.balance.after_commitments_micro, -15_000_000);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('holds are shown and already out of available, so they are not subtracted again', () => {
|
|
56
|
+
const out = shapeOutflow({ ...base, account: account(50_000_000, 20_000_000), payouts: [owed(1, '1', 1000)] });
|
|
57
|
+
assert.equal(out.committed.held_micro, 20_000_000);
|
|
58
|
+
assert.equal(out.balance.available_micro, 30_000_000);
|
|
59
|
+
assert.equal(out.balance.after_commitments_micro, 20_000_000);
|
|
60
|
+
assert.equal(out.committed.total_micro, 10_000_000, 'the committed total is owed + approved only');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('another currency is counted, never added to a dollar figure', () => {
|
|
64
|
+
const out = shapeOutflow({
|
|
65
|
+
...base,
|
|
66
|
+
payouts: [owed(1, '1', 1000, false, { currency: 'EUR' }), owed(2, '1', 1000, true, { currency: 'EUR' })],
|
|
67
|
+
approved: [{ id: 8, builder_id: '1', amount_cents: '900', currency: 'GBP' }],
|
|
68
|
+
});
|
|
69
|
+
assert.equal(out.committed.owed_micro, 0);
|
|
70
|
+
assert.equal(out.committed.approved_micro, 0);
|
|
71
|
+
assert.equal(out.committed.other_currency_count, 2, 'unpaid EUR debt and GBP approval; a paid one is not a commitment');
|
|
72
|
+
assert.deepEqual(out.by_builder, []);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('per-builder totals add spend, paid, owed and approved, biggest first', () => {
|
|
76
|
+
const out = shapeOutflow({
|
|
77
|
+
...base,
|
|
78
|
+
spendByBuilder: [{ builder_id: '1', spend_micro: '4000000' }, { builder_id: '3', spend_micro: '0' }],
|
|
79
|
+
payouts: [owed(1, '2', 1000, true), owed(2, '2', 500)],
|
|
80
|
+
approved: [{ id: 9, builder_id: '1', amount_cents: '100', currency: 'USD' }],
|
|
81
|
+
});
|
|
82
|
+
assert.deepEqual(out.by_builder.map((b) => [b.builder_id, b.total_micro]), [['2', 15_000_000], ['1', 5_000_000]]);
|
|
83
|
+
const bo = out.by_builder[0];
|
|
84
|
+
assert.equal(bo.builder_name, 'Bo');
|
|
85
|
+
assert.equal(bo.paid_micro, 10_000_000);
|
|
86
|
+
assert.equal(bo.owed_micro, 5_000_000);
|
|
87
|
+
assert.ok(!out.by_builder.some((b) => b.builder_id === '3'), 'a builder with nothing is left out');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('each month burns its spend plus money out of the account', () => {
|
|
91
|
+
const out = shapeOutflow({
|
|
92
|
+
...base,
|
|
93
|
+
burn: [
|
|
94
|
+
{ month: '2026-10', spend_micro: '1500000', payouts_micro: '3000000', other_out_micro: '200000' },
|
|
95
|
+
{ month: '2026-11', spend_micro: '0', payouts_micro: '0', other_out_micro: '0' },
|
|
96
|
+
],
|
|
97
|
+
spendTotal: '9000000',
|
|
98
|
+
});
|
|
99
|
+
assert.deepEqual(out.burn.months.map((m) => m.total_micro), [4_700_000, 0]);
|
|
100
|
+
assert.equal(out.burn.window_total_micro, 4_700_000);
|
|
101
|
+
assert.equal(out.totals.spend_micro, 9_000_000);
|
|
102
|
+
assert.equal(out.holds_money, false);
|
|
103
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// tests/project_outflow_db.mjs — task 1003184 (goal 1000092 M05). Real DB.
|
|
2
|
+
//
|
|
3
|
+
// Applies the REAL project-account and builder-payout migrations (economy_002, core_290) over
|
|
4
|
+
// minimal builders / tasks / cost_log / payout_approvals tables, books a small history through
|
|
5
|
+
// the real recordMovement, and proves the real readOutflow's SQL: a sent payout is paid, an
|
|
6
|
+
// owed one with only a failed try is committed, an approval with no payout booked is committed,
|
|
7
|
+
// the months run gap-free to this month, spend lands on its month and its builder, and the
|
|
8
|
+
// balance after commitments is the account less every commitment.
|
|
9
|
+
//
|
|
10
|
+
// Self-skips without a reachable Postgres. `seed` and `check` are exported so the same scenario
|
|
11
|
+
// can be driven through any { query } client.
|
|
12
|
+
// Run: DATABASE_URL=... node tests/project_outflow_db.mjs
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const A = require('../modules/economy/project-account.js');
|
|
20
|
+
const { readOutflow } = require('../modules/economy/outflow.js');
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const read = (...p) => readFileSync(join(here, '..', ...p), 'utf8');
|
|
23
|
+
const ACCOUNT_SQL = read('modules', 'economy', 'migrations', 'economy_002_project_account.sql')
|
|
24
|
+
+ read('modules', 'economy', 'migrations', 'economy_004_module_share_payouts.sql');
|
|
25
|
+
const PAYOUTS_SQL = read('migrations', 'core_290_builder_payouts.sql');
|
|
26
|
+
|
|
27
|
+
export async function seed(q) {
|
|
28
|
+
await q.query('CREATE TABLE builders (id bigserial PRIMARY KEY, display_name text, github_login text)');
|
|
29
|
+
// status: a hold counts only while its task is being worked (task 1003185)
|
|
30
|
+
await q.query("CREATE TABLE tasks (id bigserial PRIMARY KEY, title text, status text NOT NULL DEFAULT 'active')");
|
|
31
|
+
await q.query('CREATE TABLE schema_migrations (version text PRIMARY KEY)');
|
|
32
|
+
await q.query(`CREATE TABLE cost_log (id bigserial PRIMARY KEY, amount_usd numeric(10,4) NOT NULL,
|
|
33
|
+
builder_id bigint, recorded_at timestamptz NOT NULL DEFAULT now())`);
|
|
34
|
+
await q.query(`CREATE TABLE payout_approvals (id bigserial PRIMARY KEY, kind text NOT NULL,
|
|
35
|
+
recipient_id bigint, amount_cents bigint, currency text, created_at timestamptz NOT NULL DEFAULT now())`);
|
|
36
|
+
await q.query(ACCOUNT_SQL);
|
|
37
|
+
// core_292's binding table, which the approved read excludes (task 1004910)
|
|
38
|
+
await q.query('CREATE TABLE module_share_approvals (share_id bigint PRIMARY KEY, approval_id bigint NOT NULL UNIQUE)');
|
|
39
|
+
await q.query(PAYOUTS_SQL);
|
|
40
|
+
await q.query("INSERT INTO builders (display_name) VALUES ('Ada'), ('Bo'), ('Owner')");
|
|
41
|
+
await q.query("INSERT INTO tasks (title) VALUES ('Paid task'), ('Owed task'), ('Approved task')");
|
|
42
|
+
// approvals: 1 paid, 2 owed (failed once), 3 approved and never booked, 4 approved in EUR
|
|
43
|
+
await q.query(`INSERT INTO payout_approvals (kind, recipient_id, amount_cents, currency) VALUES
|
|
44
|
+
('payout_approved', 1, 4000, 'USD'), ('payout_approved', 2, 3000, 'USD'),
|
|
45
|
+
('payout_approved', 2, 2000, 'USD'), ('payout_approved', 1, 900, 'EUR'),
|
|
46
|
+
('rule_signed', NULL, NULL, NULL)`);
|
|
47
|
+
await q.query(`INSERT INTO builder_payouts_owed (builder_id, task_id, approval_id, amount_cents, currency, recorded_by)
|
|
48
|
+
VALUES (1, 1, 1, 4000, 'USD', 3), (2, 2, 2, 3000, 'USD', 3)`);
|
|
49
|
+
await q.query(`INSERT INTO builder_payout_attempts (owed_id, outcome, processor_ref, reason) VALUES
|
|
50
|
+
(1, 'failed', NULL, 'timeout'), (1, 'sent', 'tr_1', NULL), (2, 'failed', NULL, 'card declined')`);
|
|
51
|
+
await A.recordMovement(q, { kind: 'funding', amountMicro: 100_000_000, processorRef: 'ch_1' });
|
|
52
|
+
await A.recordMovement(q, { kind: 'payout', amountMicro: 40_000_000, builderId: 1, taskId: 1, processorRef: 'tr_1' });
|
|
53
|
+
await A.recordMovement(q, { kind: 'fee', amountMicro: 1_000_000, processorRef: 'fee_1' });
|
|
54
|
+
await q.query(`INSERT INTO cost_log (amount_usd, builder_id, recorded_at) VALUES
|
|
55
|
+
(2.5, 1, now()), (1.25, NULL, now()), (7, 2, now() - interval '400 days')`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function check(q, ok) {
|
|
59
|
+
const out = await readOutflow(q, { months: 3 });
|
|
60
|
+
ok(out.burn.months.length === 3, 'the window holds every month, quiet ones included');
|
|
61
|
+
const thisMonth = out.burn.months[2];
|
|
62
|
+
ok(thisMonth.spend_micro === 3_750_000, `this month's spend is summed from cost_log (${thisMonth.spend_micro})`);
|
|
63
|
+
ok(thisMonth.payouts_micro === 40_000_000 && thisMonth.other_out_micro === 1_000_000, 'payouts and fees out of the account land on their month');
|
|
64
|
+
ok(out.totals.spend_micro === 10_750_000, 'total spend covers all time, not just the window');
|
|
65
|
+
ok(out.totals.paid_micro === 40_000_000, 'only the debt with a sent attempt is paid');
|
|
66
|
+
ok(out.committed.owed_count === 1 && out.committed.owed[0].task_title === 'Owed task', 'a debt whose last try failed is still owed');
|
|
67
|
+
ok(out.committed.owed[0].last_reason === 'card declined', 'the owed row carries its latest try');
|
|
68
|
+
ok(out.committed.approved_count === 1 && out.committed.approved_micro === 20_000_000, 'an approval nothing was booked against is committed; a rule is not');
|
|
69
|
+
ok(out.committed.other_currency_count === 1, 'the EUR approval is counted, not added');
|
|
70
|
+
ok(out.balance.available_micro === 59_000_000, 'available is the account record');
|
|
71
|
+
ok(out.balance.after_commitments_micro === 9_000_000, 'after commitments = available − owed − approved');
|
|
72
|
+
const bo = out.by_builder.find((b) => b.builder_name === 'Bo');
|
|
73
|
+
ok(bo && bo.spend_micro === 7_000_000 && bo.owed_micro === 30_000_000 && bo.approved_micro === 20_000_000, 'per-builder totals read every record');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
77
|
+
const { Pool } = require('pg');
|
|
78
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
79
|
+
let client;
|
|
80
|
+
try { client = await pgPool.connect(); } catch (e) {
|
|
81
|
+
console.log(`project_outflow_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
82
|
+
await pgPool.end().catch(() => {});
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
const SCHEMA = `t1003184_${process.pid}`;
|
|
86
|
+
let pass = 0;
|
|
87
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
88
|
+
try {
|
|
89
|
+
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
90
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
91
|
+
const q = { query: (s, p) => client.query(s, p) };
|
|
92
|
+
await seed(q);
|
|
93
|
+
await check(q, ok);
|
|
94
|
+
console.log(`project_outflow_db.mjs: ${pass} passed`);
|
|
95
|
+
} finally {
|
|
96
|
+
await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
97
|
+
client.release();
|
|
98
|
+
await pgPool.end();
|
|
99
|
+
}
|
|
100
|
+
}
|