@bongos/core 1.24.22 → 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 (88) hide show
  1. package/.bongos-core.json +218 -63
  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 +4 -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/modules/onboarding/founding-birth.js +5 -2
  44. package/modules/onboarding/founding-mode.js +10 -0
  45. package/modules/onboarding/genesis-home.js +3 -0
  46. package/modules/provisioning/birth.js +11 -2
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +44 -0
  50. package/scripts/bongos/government-walk.js +1 -0
  51. package/scripts/bongos/plain-cards.js +18 -0
  52. package/scripts/bongos/run-unit-tests.js +28 -0
  53. package/src/bongos/builder-payouts.js +6 -1
  54. package/src/bongos/cash-outs.js +399 -0
  55. package/src/bongos/module-payouts.js +432 -0
  56. package/src/bongos/payout-approvals.js +5 -2
  57. package/src/bongos/payout-gates.js +44 -7
  58. package/src/bongos/project-settings.js +2 -0
  59. package/src/bongos/routes/module-payouts.js +114 -0
  60. package/src/bongos/routes/my-cash-outs.js +103 -0
  61. package/src/bongos/routes/payout-gates.js +42 -1
  62. package/src/bongos/routes.js +6 -0
  63. package/src/branding.js +3 -0
  64. package/src/module-api.js +1 -1
  65. package/tests/builder_payouts_db.mjs +1 -0
  66. package/tests/cash_outs.mjs +96 -0
  67. package/tests/cash_outs_db.mjs +205 -0
  68. package/tests/founding_birth.mjs +52 -12
  69. package/tests/founding_mode.mjs +5 -5
  70. package/tests/funded_project_pays_proof_db.mjs +181 -0
  71. package/tests/funding_wall.mjs +205 -0
  72. package/tests/funding_wall_db.mjs +174 -0
  73. package/tests/genesis_home.mjs +1 -1
  74. package/tests/government_act_alone_buildings.mjs +1 -0
  75. package/tests/ledger_check.mjs +189 -0
  76. package/tests/ledger_check_db.mjs +119 -0
  77. package/tests/module_payouts.mjs +295 -0
  78. package/tests/module_payouts_db.mjs +140 -0
  79. package/tests/own_scope_guard.mjs +5 -0
  80. package/tests/payout_gates.mjs +67 -6
  81. package/tests/payout_gates_db.mjs +23 -1
  82. package/tests/project_account_db.mjs +5 -2
  83. package/tests/project_outflow.mjs +103 -0
  84. package/tests/project_outflow_db.mjs +100 -0
  85. package/tests/provision_settings_apply.mjs +2 -1
  86. package/tests/provisioning_settings_env.mjs +7 -3
  87. package/tests/user_balances.mjs +241 -0
  88. package/tests/user_balances_db.mjs +137 -0
@@ -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
- const MIGRATION = readFileSync(join(here, '..', 'modules', 'economy', 'migrations', 'economy_002_project_account.sql'), 'utf8');
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
- await client.query('CREATE TABLE tasks (id bigserial PRIMARY KEY, title text)');
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
+ }
@@ -76,6 +76,7 @@ ${NEUTRAL_PREFIX}_ARTIST_GATE_SINCE=
76
76
  ${NEUTRAL_PREFIX}_LOOK_ACCENT=
77
77
  ${NEUTRAL_PREFIX}_LOGO_URL=
78
78
  ${NEUTRAL_PREFIX}_FOUNDING_BIRTH=
79
+ ${NEUTRAL_PREFIX}_FOUNDING_GENESIS=
79
80
  ${NEUTRAL_PREFIX}_DEMO_PEOPLE=
80
81
  ${NEUTRAL_PREFIX}_DEMO_HOURS=
81
82
  ${NEUTRAL_PREFIX}_SHIP_MODE=
@@ -148,7 +149,7 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
148
149
  [`${NEUTRAL_PREFIX}_LOOK`]: 'own', [`${NEUTRAL_PREFIX}_NIGHT_TINT`]: 'on',
149
150
  [`${NEUTRAL_PREFIX}_LOOK_ACCENT`]: '', [`${NEUTRAL_PREFIX}_LOGO_URL`]: '',
150
151
  // the birth marker and a demo's box (task 1004506), empty on an un-stamped row
151
- [`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
152
+ [`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_FOUNDING_GENESIS`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
152
153
  // the wizard's ship mode (task 1004629), empty when none was picked
153
154
  [`${NEUTRAL_PREFIX}_SHIP_MODE`]: '',
154
155
  // where the hall's Update button goes (task 1004608), empty when no hub origin resolves
@@ -126,7 +126,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
126
126
  CLOUDBONGOS_LOOK: 'own', CLOUDBONGOS_NIGHT_TINT: 'on', CLOUDBONGOS_LOOK_ACCENT: '', CLOUDBONGOS_LOGO_URL: '',
127
127
  // The birth marker and a demo's box (task 1004506): empty on a row the create
128
128
  // route never stamped, which is every row made before it shipped (spec D8).
129
- CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
129
+ CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_FOUNDING_GENESIS: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
130
130
  // The wizard's ship mode (task 1004629): empty when none was picked.
131
131
  CLOUDBONGOS_SHIP_MODE: '',
132
132
  });
@@ -137,7 +137,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
137
137
  X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
138
138
  X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
139
139
  X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
140
- X_FOUNDING_BIRTH: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '',
140
+ X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '',
141
141
  }, 'defaults, explicitly');
142
142
  });
143
143
 
@@ -155,6 +155,10 @@ t('a stamped row carries its birth; only a stamped DEMO carries the box', () =>
155
155
  assert.deepEqual([oldDemo.X_FOUNDING_BIRTH, oldDemo.X_DEMO_PEOPLE, oldDemo.X_DEMO_HOURS], ['', '', ''],
156
156
  'a demo made before the birth shipped is not born into anything, box and all');
157
157
  assert.equal(provisioning.settingsEnvVars({ settings: { founding_birth: 'yesterday' } }, 'X').X_FOUNDING_BIRTH, '', 'a mangled stamp reads as none');
158
+ // Genesis is paused: only a birth stamped with the genesis key carries the companion (owner, 2026-11-01).
159
+ assert.equal(provisioning.settingsEnvVars({ settings: { founding_birth: at, founding_genesis: 'on' } }, 'X').X_FOUNDING_GENESIS, 'on');
160
+ assert.equal(real.X_FOUNDING_GENESIS, '', 'a birth alone, the shape of every project before the pause, does not found');
161
+ assert.equal(provisioning.settingsEnvVars({ settings: { founding_genesis: 'on' } }, 'X').X_FOUNDING_GENESIS, '', 'the genesis key without a birth is nothing');
158
162
  });
159
163
 
160
164
  t('the stamp can neither leak as a setting nor be written by a client patch', () => {
@@ -172,7 +176,7 @@ t('stampBirth writes the row it is given, once: an existing stamp is kept', () =
172
176
  assert.equal(seen.length, 1);
173
177
  assert.match(seen[0].sql, /UPDATE provisioning_instances/);
174
178
  assert.match(seen[0].sql, /WHERE id = \$1 AND NOT \(COALESCE\(settings, '\{\}'::jsonb\) \? \$2::text\)/, 'write-once');
175
- assert.deepEqual(seen[0].params, [41, BIRTH_KEY, '2026-10-01T00:00:00.000Z']);
179
+ assert.deepEqual(seen[0].params, [41, BIRTH_KEY, '2026-10-01T00:00:00.000Z', 'founding_genesis']);
176
180
  });
177
181
 
178
182
  t('the instance reads the marker into founding.*, and never publishes it to the client', () => {
@@ -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
+ });