@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.
Files changed (78) hide show
  1. package/.bongos-core.json +208 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +42 -0
  4. package/clients/bongos-client/index.cjs +42 -0
  5. package/clients/bongos-client/index.d.ts +55 -1
  6. package/clients/bongos-client/index.mjs +42 -0
  7. package/docs/api/openapi.json +1181 -207
  8. package/docs/api-reference.md +30 -5
  9. package/docs/architecture.md +42 -0
  10. package/docs/copy-inventory.md +18 -8
  11. package/docs/copy-registry.json +99 -9
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-readings.json +64 -25
  14. package/migrations/core_291_payout_nda_tax.sql +75 -0
  15. package/migrations/core_292_module_sale_payouts.sql +170 -0
  16. package/migrations/core_293_cash_outs.sql +184 -0
  17. package/migrations/core_294_cash_out_attempts_sent_by_time.sql +21 -0
  18. package/modules/economy/ledger-check.js +360 -0
  19. package/modules/economy/migrations/economy_003_user_balances.sql +116 -0
  20. package/modules/economy/migrations/economy_004_module_share_payouts.sql +22 -0
  21. package/modules/economy/migrations/economy_005_project_account_holds_index.sql +20 -0
  22. package/modules/economy/migrations/economy_006_ledger_checks.sql +68 -0
  23. package/modules/economy/module.json +1 -1
  24. package/modules/economy/outflow.js +209 -0
  25. package/modules/economy/pollers/ledger-check.js +87 -0
  26. package/modules/economy/project-account.js +92 -7
  27. package/modules/economy/routes/balances.js +79 -0
  28. package/modules/economy/routes/project-account.js +40 -2
  29. package/modules/economy/user-balances.js +308 -0
  30. package/modules/government/decision-rules.js +1 -0
  31. package/modules/hall-ui/public/project-settings.css +18 -0
  32. package/modules/hall-ui/public/project-settings.html +15 -2
  33. package/modules/hall-ui/public/project-settings.js +142 -0
  34. package/modules/ideas/module.json +2 -1
  35. package/modules/ideas/routes/blockers.js +26 -0
  36. package/modules/lifecycle/db-claims.js +22 -0
  37. package/modules/lifecycle/db-ship.js +5 -5
  38. package/modules/lifecycle/funding-wall.js +253 -0
  39. package/modules/lifecycle/module.json +2 -1
  40. package/modules/lifecycle/routes/claims.js +9 -0
  41. package/modules/lifecycle/routes/pay-type-gates.js +7 -0
  42. package/modules/lifecycle/routes/project-settings.js +59 -2
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +38 -0
  46. package/scripts/bongos/government-walk.js +1 -0
  47. package/scripts/bongos/plain-cards.js +18 -0
  48. package/scripts/bongos/run-unit-tests.js +28 -0
  49. package/src/bongos/builder-payouts.js +6 -1
  50. package/src/bongos/cash-outs.js +399 -0
  51. package/src/bongos/module-payouts.js +432 -0
  52. package/src/bongos/payout-approvals.js +5 -2
  53. package/src/bongos/payout-gates.js +44 -7
  54. package/src/bongos/project-settings.js +2 -0
  55. package/src/bongos/routes/module-payouts.js +114 -0
  56. package/src/bongos/routes/my-cash-outs.js +103 -0
  57. package/src/bongos/routes/payout-gates.js +42 -1
  58. package/src/bongos/routes.js +6 -0
  59. package/src/module-api.js +1 -1
  60. package/tests/builder_payouts_db.mjs +1 -0
  61. package/tests/cash_outs.mjs +96 -0
  62. package/tests/cash_outs_db.mjs +205 -0
  63. package/tests/funded_project_pays_proof_db.mjs +181 -0
  64. package/tests/funding_wall.mjs +205 -0
  65. package/tests/funding_wall_db.mjs +174 -0
  66. package/tests/government_act_alone_buildings.mjs +1 -0
  67. package/tests/ledger_check.mjs +189 -0
  68. package/tests/ledger_check_db.mjs +119 -0
  69. package/tests/module_payouts.mjs +295 -0
  70. package/tests/module_payouts_db.mjs +140 -0
  71. package/tests/own_scope_guard.mjs +5 -0
  72. package/tests/payout_gates.mjs +67 -6
  73. package/tests/payout_gates_db.mjs +23 -1
  74. package/tests/project_account_db.mjs +5 -2
  75. package/tests/project_outflow.mjs +103 -0
  76. package/tests/project_outflow_db.mjs +100 -0
  77. package/tests/user_balances.mjs +241 -0
  78. package/tests/user_balances_db.mjs +137 -0
@@ -0,0 +1,241 @@
1
+ // tests/user_balances.mjs — user balances and fee-free transfers, isolated per project
2
+ // (task 1004907, goal 1000092 N3; ADR 0385, ADR 0397). Behaviour of
3
+ // modules/economy/user-balances.js and its routes:
4
+ // - a balance is the processor's figure, never a sum of our record;
5
+ // - an instruction is carried out once, with no platform fee, and funding books the
6
+ // project-account movement;
7
+ // - with no processor nothing moves and the instruction is held, not lost;
8
+ // - only a person's own web session may pay;
9
+ // - TWO PROJECTS: neither can read, link or spend the other's accounts.
10
+ // The SQL itself is proven against real Postgres in tests/user_balances_db.mjs.
11
+
12
+ import { test } from 'node:test';
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const U = require('../modules/economy/user-balances.js');
18
+
19
+ // A stand-in for one project's database: the three tables and the project-account record,
20
+ // answering the statements user-balances.js and project-account.js send.
21
+ function fakeProjectDb() {
22
+ const t = { accounts: [], transfers: [], attempts: [], movements: [] };
23
+ let id = 0;
24
+ const db = {
25
+ t,
26
+ async query(sql, p = []) {
27
+ const s = sql.replace(/\s+/g, ' ').trim();
28
+ if (s.startsWith('SELECT pg_advisory_xact_lock')) return { rows: [] };
29
+ if (s.startsWith('SELECT processor_account FROM economy_member_accounts')) {
30
+ const r = t.accounts.filter((a) => a.builder_id === String(p[0])).at(-1);
31
+ return { rows: r ? [r] : [] };
32
+ }
33
+ if (s.startsWith('SELECT builder_id FROM economy_member_accounts')) return { rows: t.accounts.filter((a) => a.processor_account === p[0]).slice(0, 1) };
34
+ if (s.startsWith('INSERT INTO economy_member_accounts')) {
35
+ t.accounts.push({ id: ++id, builder_id: String(p[0]), processor_account: p[1] }); return { rows: [] };
36
+ }
37
+ if (s.startsWith('INSERT INTO economy_user_transfers')) {
38
+ if (!t.transfers.some((x) => x.from_builder_id === p[1] && x.instruction_id === p[7])) {
39
+ t.transfers.push({ id: ++id, kind: p[0], from_builder_id: p[1], to_builder_id: p[2], from_account: p[3], to_account: p[4],
40
+ amount_cents: p[5], currency: p[6], instruction_id: p[7], note: p[8], platform_fee_cents: 0 });
41
+ }
42
+ return { rows: [] };
43
+ }
44
+ if (s.startsWith('SELECT * FROM economy_user_transfers')) return { rows: t.transfers.filter((x) => x.from_builder_id === String(p[0]) && x.instruction_id === p[1]) };
45
+ if (s.startsWith('SELECT processor_ref FROM economy_user_transfer_attempts')) return { rows: t.attempts.filter((a) => a.transfer_id === String(p[0]) && a.outcome === 'sent') };
46
+ if (s.startsWith('INSERT INTO economy_user_transfer_attempts')) {
47
+ t.attempts.push({ id: ++id, transfer_id: String(p[0]), outcome: p[1], processor_ref: p[2], reason: p[3] }); return { rows: [] };
48
+ }
49
+ if (s.startsWith('INSERT INTO economy_project_account_movements')) {
50
+ const row = { id: ++id, kind: p[0], amount_micro: p[2], builder_id: p[3], processor_ref: p[6] };
51
+ t.movements.push(row); return { rows: [row] };
52
+ }
53
+ if (s.startsWith('SELECT t.id, t.kind')) {
54
+ const who = String(p[0]);
55
+ const rows = t.transfers.filter((x) => x.from_builder_id === who || x.to_builder_id === who).reverse().map((x) => {
56
+ const mine = t.attempts.filter((a) => a.transfer_id === String(x.id));
57
+ const sent = mine.find((a) => a.outcome === 'sent');
58
+ const last = mine.at(-1);
59
+ return { ...x, sent_ref: sent ? sent.processor_ref : null, last_outcome: last && last.outcome, last_reason: last && last.reason };
60
+ });
61
+ return { rows };
62
+ }
63
+ throw new Error(`unexpected SQL: ${s.slice(0, 80)}`);
64
+ },
65
+ };
66
+ return db;
67
+ }
68
+
69
+ // A stand-in for one project's processor connection: it knows only the accounts it opened.
70
+ function fakeProcessor(name, { opened = {}, balances = {}, projectMicro = 0 } = {}) {
71
+ const calls = [];
72
+ let n = 0;
73
+ const byKey = new Map();
74
+ return {
75
+ calls,
76
+ async balanceMicro() { return projectMicro; },
77
+ async memberBalanceMicro(ref) {
78
+ if (!(ref in balances)) throw new Error(`${name} has no account ${ref}`);
79
+ return balances[ref];
80
+ },
81
+ async confirmMemberAccount({ accountRef, builderId }) { return opened[accountRef] === builderId; },
82
+ async memberTransfer(req) {
83
+ calls.push(req);
84
+ for (const acc of [req.fromAccount, req.toAccount].filter(Boolean)) {
85
+ if (!(acc in opened)) throw Object.assign(new Error(`${name} does not hold ${acc}`), { code: 'unknown_account' });
86
+ }
87
+ if (!byKey.has(req.idempotencyKey)) byKey.set(req.idempotencyKey, `tr_${name}_${++n}`);
88
+ return { ref: byKey.get(req.idempotencyKey) };
89
+ },
90
+ };
91
+ }
92
+
93
+ async function project(name, members) {
94
+ const db = fakeProjectDb();
95
+ const opened = {}; const balances = {};
96
+ for (const [builderId, ref, micro] of members) { opened[ref] = builderId; balances[ref] = micro; }
97
+ const processor = fakeProcessor(name, { opened, balances, projectMicro: 9_000_000 });
98
+ for (const [builderId, ref] of members) await U.linkMemberAccount(db, { builderId, accountRef: ref }, { processor });
99
+ return { db, processor };
100
+ }
101
+
102
+ const code = async (p) => { try { await p; } catch (e) { return e.code; } return null; };
103
+
104
+ test('a balance is the processor figure; our record is never summed into one', async () => {
105
+ const a = await project('A', [['1', 'acct_a1', 4_200_000]]);
106
+ const me = await U.readMyBalance(a.db, '1', { processor: a.processor });
107
+ assert.equal(me.balance_micro, 4_200_000);
108
+ assert.equal(me.holds_money, false);
109
+ assert.equal(me.source, 'processor');
110
+ assert.equal((await U.readProjectBalance({ processor: a.processor })).balance_micro, 9_000_000);
111
+ const none = await U.readMyBalance(a.db, '2', { processor: a.processor });
112
+ assert.equal(none.connected, false);
113
+ assert.equal(none.balance_micro, null);
114
+ const broken = { memberBalanceMicro: async () => { throw new Error('acct_secret down'); } };
115
+ const r = await U.readMyBalance(a.db, '1', { processor: broken });
116
+ assert.equal(r.balance_micro, null);
117
+ assert.ok(r.processor_error && !r.processor_error.includes('acct_secret'), 'the processor error text never reaches the person');
118
+ });
119
+
120
+ test('a person pays another member once, with no platform fee', async () => {
121
+ const a = await project('A', [['1', 'acct_a1', 0], ['2', 'acct_a2', 0]]);
122
+ const pay = { kind: 'pay_user', fromBuilderId: '1', toBuilderId: '2', amountCents: 2500, instructionId: 'pay-0001-abc' };
123
+ const out = await U.transfer(pay, { pool: a.db, processor: a.processor });
124
+ assert.equal(out.status, 'sent');
125
+ assert.equal(out.platform_fee_cents, 0);
126
+ assert.deepEqual(a.processor.calls[0], {
127
+ idempotencyKey: `bongos-user-transfer-${out.transfer_id}`, amountCents: 2500, currency: 'USD',
128
+ fromAccount: 'acct_a1', toAccount: 'acct_a2', toProject: false,
129
+ });
130
+ const again = await U.transfer(pay, { pool: a.db, processor: a.processor });
131
+ assert.equal(again.status, 'sent');
132
+ assert.equal(again.already, true);
133
+ assert.equal(a.processor.calls.length, 1, 'a second press sends nothing');
134
+ assert.equal(a.db.t.movements.length, 0, 'a payment between people never touches the project account');
135
+ assert.equal(await code(U.transfer({ ...pay, amountCents: 9999 }, { pool: a.db, processor: a.processor })), 'instruction_reused');
136
+ const seen = await U.readMyBalance(a.db, '2', { processor: a.processor });
137
+ assert.equal(seen.transfers[0].direction, 'in');
138
+ assert.equal(seen.transfers[0].status, 'sent');
139
+ });
140
+
141
+ test('funding the project books one funding movement naming the funder', async () => {
142
+ const a = await project('A', [['1', 'acct_a1', 0]]);
143
+ const out = await U.transfer({ kind: 'fund_project', fromBuilderId: '1', amountCents: 1000, instructionId: 'fund-0001-xy' },
144
+ { pool: a.db, processor: a.processor });
145
+ assert.equal(out.status, 'sent');
146
+ assert.equal(a.processor.calls[0].toProject, true);
147
+ assert.equal(a.processor.calls[0].toAccount, null);
148
+ assert.equal(a.db.t.movements.length, 1);
149
+ assert.equal(a.db.t.movements[0].kind, 'funding');
150
+ assert.equal(a.db.t.movements[0].amount_micro, 10_000_000);
151
+ assert.equal(a.db.t.movements[0].builder_id, '1');
152
+ });
153
+
154
+ test('with no processor connected the instruction is recorded and held, nothing moves', async () => {
155
+ const a = await project('A', [['1', 'acct_a1', 0], ['2', 'acct_a2', 0]]);
156
+ const out = await U.transfer({ kind: 'pay_user', fromBuilderId: '1', toBuilderId: '2', amountCents: 5, instructionId: 'held-0001-q' },
157
+ { pool: a.db, processor: null });
158
+ assert.equal(out.status, 'held');
159
+ assert.equal(out.reason, 'no_processor');
160
+ assert.equal(a.db.t.transfers.length, 1);
161
+ assert.equal(a.db.t.attempts[0].outcome, 'held');
162
+ });
163
+
164
+ test('a failed transfer is recorded with its reason and not re-sent on its own', async () => {
165
+ const a = await project('A', [['1', 'acct_a1', 0], ['2', 'acct_a2', 0]]);
166
+ const flaky = { memberTransfer: async () => { throw Object.assign(new Error('insufficient funds'), { code: 'balance_insufficient' }); } };
167
+ const out = await U.transfer({ kind: 'pay_user', fromBuilderId: '1', toBuilderId: '2', amountCents: 5, instructionId: 'fail-0001-q' },
168
+ { pool: a.db, processor: flaky });
169
+ assert.equal(out.status, 'failed');
170
+ assert.match(out.reason, /insufficient funds/);
171
+ assert.equal(a.db.t.attempts.filter((x) => x.outcome === 'sent').length, 0);
172
+ });
173
+
174
+ test('malformed instructions are refused before anything is recorded', async () => {
175
+ const a = await project('A', [['1', 'acct_a1', 0], ['2', 'acct_a2', 0]]);
176
+ const base = { kind: 'pay_user', fromBuilderId: '1', toBuilderId: '2', amountCents: 5, instructionId: 'okok-0001-q' };
177
+ const opts = { pool: a.db, processor: a.processor };
178
+ assert.equal(await code(U.transfer({ ...base, amountCents: 0 }, opts)), 'bad_amount');
179
+ assert.equal(await code(U.transfer({ ...base, amountCents: 1.5 }, opts)), 'bad_amount');
180
+ assert.equal(await code(U.transfer({ ...base, toBuilderId: '1' }, opts)), 'self_payment');
181
+ assert.equal(await code(U.transfer({ ...base, instructionId: 'x' }, opts)), 'bad_instruction_id');
182
+ assert.equal(await code(U.transfer({ ...base, kind: 'withdraw' }, opts)), 'bad_kind');
183
+ assert.equal(await code(U.transfer({ ...base, fromBuilderId: '7' }, opts)), 'no_account');
184
+ assert.equal(await code(U.transfer({ ...base, toBuilderId: '7' }, opts)), 'recipient_has_no_account');
185
+ assert.equal(a.db.t.transfers.length, 0);
186
+ });
187
+
188
+ test('TWO PROJECTS: neither can read, link or spend the other balance', async () => {
189
+ const a = await project('A', [['1', 'acct_a1', 100], ['2', 'acct_a2', 200]]);
190
+ // project B has its own builders 1 and 3 (ids are per project database)
191
+ const b = await project('B', [['1', 'acct_b1', 700], ['3', 'acct_b3', 300]]);
192
+
193
+ // A cannot link B's account to one of its people: A's processor did not open it
194
+ assert.equal(await code(U.linkMemberAccount(a.db, { builderId: '1', accountRef: 'acct_b1' }, { processor: a.processor })), 'not_this_projects_account');
195
+ assert.equal(await code(U.linkMemberAccount(a.db, { builderId: '2', accountRef: 'acct_a1' }, { processor: a.processor })), 'not_this_projects_account');
196
+
197
+ // the same builder id reads its OWN project's balance in each project
198
+ assert.equal((await U.readMyBalance(a.db, '1', { processor: a.processor })).balance_micro, 100);
199
+ assert.equal((await U.readMyBalance(b.db, '1', { processor: b.processor })).balance_micro, 700);
200
+ assert.equal((await U.readMyBalance(a.db, '3', { processor: a.processor })).connected, false, "B's member is not a member of A");
201
+
202
+ // A's member cannot pay B's member: B's people have no account in A
203
+ assert.equal(await code(U.transfer({ kind: 'pay_user', fromBuilderId: '1', toBuilderId: '3', amountCents: 50, instructionId: 'cross-0001' },
204
+ { pool: a.db, processor: a.processor })), 'recipient_has_no_account');
205
+
206
+ // a payment in A only ever names A's accounts and only ever reaches A's processor
207
+ await U.transfer({ kind: 'pay_user', fromBuilderId: '1', toBuilderId: '2', amountCents: 50, instructionId: 'insideA-001' }, { pool: a.db, processor: a.processor });
208
+ await U.transfer({ kind: 'fund_project', fromBuilderId: '1', amountCents: 50, instructionId: 'fundB-0001' }, { pool: b.db, processor: b.processor });
209
+ assert.deepEqual(a.processor.calls.map((c) => [c.fromAccount, c.toAccount]), [['acct_a1', 'acct_a2']]);
210
+ assert.deepEqual(b.processor.calls.map((c) => [c.fromAccount, c.toAccount]), [['acct_b1', null]]);
211
+ assert.equal(a.db.t.movements.length, 0, "B's funding is not on A's account");
212
+ assert.equal(b.db.t.movements.length, 1);
213
+ });
214
+
215
+ // ── the routes ──────────────────────────────────────────────────────────────
216
+
217
+ test('the payment routes need the person on their own web session', async () => {
218
+ const api = require('../src/module-api');
219
+ const build = require('../modules/economy/routes/balances.js');
220
+ const router = build({ processor: null });
221
+ const layer = router.stack.find((l) => l.route && l.route.path === '/balances/pay');
222
+ const handler = layer.route.stack.at(-1).handle;
223
+ const run = async (req) => {
224
+ const res = { code: 200, body: null, status(c) { this.code = c; return this; }, json(b) { this.body = b; return this; },
225
+ fail(c, o) { this.code = o.status; this.body = { error: c }; return this; } };
226
+ await handler(req, res);
227
+ return res;
228
+ };
229
+ const body = { to_builder_id: '2', amount_cents: 5, instruction_id: 'route-0001' };
230
+ for (const [what, req] of [
231
+ ['a CLI session', { builder: { id: '1' }, gdsSession: { source: 'cli', session_id: 1 }, body }],
232
+ ['a machine principal', { builder: { id: '1', system_role: 'bot' }, gdsSession: { source: 'web', session_id: 1 }, body }],
233
+ ['no session', { builder: { id: '1' }, body }],
234
+ ]) {
235
+ const r = await run(req);
236
+ assert.equal(r.code, 403, `${what} is refused`);
237
+ assert.equal(r.body.error, 'personal_session_required');
238
+ }
239
+ assert.ok(api.requireBuilder, 'the routes mount requireBuilder from the doorway');
240
+ assert.ok(layer.route.stack.some((s) => s.handle === api.requireBuilder), 'POST /balances/pay is behind requireBuilder');
241
+ });
@@ -0,0 +1,137 @@
1
+ // tests/user_balances_db.mjs — task 1004907 (goal 1000092 N3). Real DB, TWO PROJECTS.
2
+ //
3
+ // Applies the REAL economy_002 and economy_003 migrations into two schemas, one per project
4
+ // (each project runs its own database, ADR 0100; a schema is that database's stand-in), and
5
+ // proves what Postgres itself refuses: a platform fee, a payment to yourself, one account
6
+ // owned by two people, a second instruction under one id, a second `sent` attempt, and any
7
+ // UPDATE or DELETE. Then the real transfer() moves money inside each project, and neither
8
+ // project's tables, accounts or processor ever see the other's.
9
+ //
10
+ // Self-skips without a reachable Postgres.
11
+ // Run: DATABASE_URL=... node tests/user_balances_db.mjs
12
+ import { createRequire } from 'node:module';
13
+ import { readFileSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, join } from 'node:path';
16
+ import { strict as assert } from 'node:assert';
17
+ const require = createRequire(import.meta.url);
18
+ const { Pool } = require('pg');
19
+ const U = require('../modules/economy/user-balances.js');
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ const mig = (f) => readFileSync(join(here, '..', 'modules', 'economy', 'migrations', f), 'utf8');
22
+ const MIGRATIONS = [mig('economy_002_project_account.sql'), mig('economy_003_user_balances.sql'), mig('economy_004_module_share_payouts.sql')];
23
+
24
+ const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 2 });
25
+ let probe;
26
+ try { probe = await pgPool.connect(); } catch (e) {
27
+ console.log(`user_balances_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
28
+ await pgPool.end().catch(() => {});
29
+ process.exit(0);
30
+ }
31
+ probe.release();
32
+
33
+ let pass = 0;
34
+ const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
35
+ const SCHEMAS = { A: `t1004907a_${process.pid}`, B: `t1004907b_${process.pid}` };
36
+ const clients = {};
37
+
38
+ // one connection per project, pinned to that project's schema
39
+ async function openProject(name) {
40
+ const c = await pgPool.connect();
41
+ const schema = SCHEMAS[name];
42
+ await c.query(`CREATE SCHEMA ${schema}`);
43
+ await c.query(`SET search_path TO ${schema}`);
44
+ await c.query('CREATE TABLE builders (id bigserial PRIMARY KEY, display_name text)');
45
+ await c.query('CREATE TABLE tasks (id bigserial PRIMARY KEY, title text)');
46
+ await c.query('CREATE TABLE schema_migrations (version text PRIMARY KEY)');
47
+ await c.query(`INSERT INTO builders (display_name) VALUES ('${name}-one'), ('${name}-two'), ('${name}-three')`);
48
+ for (const m of MIGRATIONS) await c.query(m);
49
+ clients[name] = c;
50
+ return c;
51
+ }
52
+
53
+ function processorFor(name, opened) {
54
+ const calls = [];
55
+ let n = 0;
56
+ return {
57
+ calls,
58
+ async confirmMemberAccount({ accountRef, builderId }) { return opened[accountRef] === builderId; },
59
+ async memberTransfer(req) {
60
+ calls.push(req);
61
+ for (const acc of [req.fromAccount, req.toAccount].filter(Boolean)) if (!(acc in opened)) throw new Error(`${name} does not hold ${acc}`);
62
+ return { ref: `tr_${name}_${++n}` };
63
+ },
64
+ };
65
+ }
66
+
67
+ const rejects = async (c, sql, params) => {
68
+ await c.query('BEGIN');
69
+ let err = null;
70
+ try { await c.query(sql, params); } catch (e) { err = e; }
71
+ await c.query('ROLLBACK');
72
+ return err;
73
+ };
74
+ const codeOf = async (p) => { try { await p; } catch (e) { return e.code; } return null; };
75
+
76
+ try {
77
+ const a = await openProject('A');
78
+ const b = await openProject('B');
79
+ const pa = processorFor('A', { acct_a1: '1', acct_a2: '2' });
80
+ const pb = processorFor('B', { acct_b1: '1', acct_b3: '3' });
81
+ await U.linkMemberAccount(a, { builderId: '1', accountRef: 'acct_a1' }, { processor: pa });
82
+ await U.linkMemberAccount(a, { builderId: '2', accountRef: 'acct_a2' }, { processor: pa });
83
+ await U.linkMemberAccount(b, { builderId: '1', accountRef: 'acct_b1' }, { processor: pb });
84
+ await U.linkMemberAccount(b, { builderId: '3', accountRef: 'acct_b3' }, { processor: pb });
85
+
86
+ const TI = `INSERT INTO economy_user_transfers (kind, from_builder_id, to_builder_id, from_account, to_account, amount_cents, instruction_id, platform_fee_cents)
87
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`;
88
+ ok((await rejects(a, TI, ['pay_user', 1, 2, 'acct_a1', 'acct_a2', 100, 'fee-00001', 1]))?.code === '23514', 'a platform fee is refused by the database');
89
+ ok((await rejects(a, TI, ['pay_user', 1, 1, 'acct_a1', 'acct_a1', 100, 'self-0001', 0]))?.code === '23514', 'a payment to yourself is refused');
90
+ ok((await rejects(a, TI, ['fund_project', 1, 2, 'acct_a1', 'acct_a2', 100, 'fund-0001', 0]))?.code === '23514', 'funding that names a person is refused');
91
+ ok((await rejects(a, TI, ['pay_user', 1, 2, 'acct_a1', 'acct_a2', 0, 'zero-0001', 0]))?.code === '23514', 'a zero amount is refused');
92
+ ok((await rejects(a, 'INSERT INTO economy_member_accounts (builder_id, processor_account) VALUES (3, $1)', ['acct_a1']))?.code === '23505',
93
+ 'one account cannot belong to two people');
94
+ ok(await codeOf(U.linkMemberAccount(a, { builderId: '3', accountRef: 'acct_b3' }, { processor: pa })) === 'not_this_projects_account',
95
+ "project A cannot link an account project B opened");
96
+
97
+ const paid = await U.transfer({ kind: 'pay_user', fromBuilderId: '1', toBuilderId: '2', amountCents: 2500, instructionId: 'pay-a-0001' }, { pool: a, processor: pa });
98
+ ok(paid.status === 'sent' && paid.platform_fee_cents === 0, 'a member pays another member in project A, fee-free');
99
+ const again = await U.transfer({ kind: 'pay_user', fromBuilderId: '1', toBuilderId: '2', amountCents: 2500, instructionId: 'pay-a-0001' }, { pool: a, processor: pa });
100
+ ok(again.already === true && pa.calls.length === 1, 'the same instruction is carried out once');
101
+ ok((await rejects(a, TI, ['pay_user', 1, 2, 'acct_a1', 'acct_a2', 9, 'pay-a-0001', 0]))?.code === '23505', 'a second instruction under one id is refused');
102
+ ok((await rejects(a, "INSERT INTO economy_user_transfer_attempts (transfer_id, outcome, processor_ref) VALUES ($1, 'sent', 'tr_dup')", [paid.transfer_id]))?.code === '23505',
103
+ 'a second sent attempt is refused');
104
+
105
+ const funded = await U.transfer({ kind: 'fund_project', fromBuilderId: '1', amountCents: 1000, instructionId: 'fund-b-0001' }, { pool: b, processor: pb });
106
+ ok(funded.status === 'sent', 'a member funds project B');
107
+
108
+ for (const table of ['economy_member_accounts', 'economy_user_transfers', 'economy_user_transfer_attempts']) {
109
+ ok(/append-only/.test((await rejects(a, `UPDATE ${table} SET created_at = now()`))?.message || ''), `${table} refuses an UPDATE`);
110
+ ok(/append-only/.test((await rejects(a, `DELETE FROM ${table}`))?.message || ''), `${table} refuses a DELETE`);
111
+ }
112
+
113
+ // ISOLATION — each project's tables hold only its own movements
114
+ const count = async (c, sql, params) => Number((await c.query(sql, params)).rows[0].n);
115
+ ok(await count(a, 'SELECT count(*) AS n FROM economy_user_transfers') === 1 && await count(b, 'SELECT count(*) AS n FROM economy_user_transfers') === 1,
116
+ 'each project records only its own instruction');
117
+ ok(await count(a, 'SELECT count(*) AS n FROM economy_project_account_movements') === 0, "project B's funding is not on project A's account");
118
+ ok(await count(b, "SELECT count(*) AS n FROM economy_project_account_movements WHERE kind = 'funding' AND builder_id = $1", ['1']) === 1,
119
+ "project B's funding is on project B's account, naming the funder");
120
+ ok(await codeOf(U.transfer({ kind: 'pay_user', fromBuilderId: '1', toBuilderId: '3', amountCents: 5, instructionId: 'cross-0001' }, { pool: a, processor: pa })) === 'recipient_has_no_account',
121
+ "project A cannot pay project B's member");
122
+ ok(pa.calls.every((c) => c.fromAccount.startsWith('acct_a') && (!c.toAccount || c.toAccount.startsWith('acct_a'))) &&
123
+ pb.calls.every((c) => c.fromAccount.startsWith('acct_b') && (!c.toAccount || c.toAccount.startsWith('acct_b'))),
124
+ "each project's processor is only ever asked to move that project's accounts");
125
+ const meA = await U.readMyBalance(a, '1', {});
126
+ const meB = await U.readMyBalance(b, '1', {});
127
+ ok(meA.transfers.length === 1 && meA.transfers[0].kind === 'pay_user' && meB.transfers.length === 1 && meB.transfers[0].kind === 'fund_project',
128
+ "the same person sees only that project's payments in each project");
129
+
130
+ console.log(`user_balances_db.mjs: ${pass} passed`);
131
+ } finally {
132
+ for (const [name, c] of Object.entries(clients)) {
133
+ await c.query(`DROP SCHEMA IF EXISTS ${SCHEMAS[name]} CASCADE`).catch(() => {});
134
+ c.release();
135
+ }
136
+ await pgPool.end();
137
+ }