@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,181 @@
1
+ // tests/funded_project_pays_proof_db.mjs — task 1003186 (goal 1000092 M07; criterion
2
+ // money-cannot-outrun). Real DB: the integration lane's fully migrated database.
3
+ //
4
+ // THE PROOF, end to end, on the real schema and the real code paths — no stand-in tables:
5
+ // 1. the project is funded (a `funding` movement, as the fund-project transfer books it);
6
+ // 2. a builder claims a cash task through the real claimTask: the funding wall holds its pay;
7
+ // 3. a second claim the balance cannot cover is refused with the reason and the shortfall;
8
+ // 4. the first task ships through the real resolveClaim → confirmTask → shipTask, and its pay
9
+ // STAYS held until the builder is paid — the hole this proof found: the hold used to go at
10
+ // the ship, so the same money could be promised to another task before anyone was paid;
11
+ // 5. the owner approves the payout (the real append-only approval log), the builder clears the
12
+ // real payout gates, and the first transfer fails: the debt is on record, owed, with why;
13
+ // 6. the owner's outflow view shows the debt as owed-but-unpaid and the balance after it;
14
+ // 7. pay is pressed again, twice at once: money leaves the account exactly once, and a third
15
+ // press sends nothing;
16
+ // 8. the owner sees the payout, the per-builder total and nothing left owed;
17
+ // 9. the balance is drained by one more claim, and the next is refused with its shortfall.
18
+ //
19
+ // WHAT IS NOT REAL. The processor. No payment-rail adapter exists yet (ADR 0397: Stripe
20
+ // Connect, and only an adapter may name it; payout-approvals.js passes `processor = null`), so
21
+ // step 5/7's transfers go to a test rail at the one port builder-payouts.js defines,
22
+ // { transfer(req) → { ref } }, which remembers each idempotency key as a processor does. Every
23
+ // other step is the code a live instance runs. No real money moves here.
24
+ //
25
+ // The tables it writes are append-only by design (the movements, debts, attempts, approvals),
26
+ // so it cleans up nothing: its rows use ids unique to the run and it measures the account
27
+ // relative to the balance it finds. Run it only against a throwaway database (the CI lane's).
28
+ // Self-skips without a reachable Postgres.
29
+ // Run: DATABASE_URL=... node tests/funded_project_pays_proof_db.mjs
30
+ import { createRequire } from 'node:module';
31
+ import { strict as assert } from 'node:assert';
32
+ const require = createRequire(import.meta.url);
33
+ const { Pool } = require('pg');
34
+
35
+ process.env.NODE_ENV = 'test';
36
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
37
+ try {
38
+ await pool.query('SELECT 1');
39
+ } catch (e) {
40
+ console.log(`funded_project_pays_proof_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
41
+ await pool.end().catch(() => {});
42
+ process.exit(0);
43
+ }
44
+
45
+ const seams = require('../src/module-seams.js');
46
+ const A = require('../modules/economy/project-account.js');
47
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
48
+ if (!seams.hasProvider('economy.account')) {
49
+ seams.registerProvider('economy.account', { recordMovement: A.recordMovement, lockAccount: A.lockAccount, releaseSettledHolds: A.releaseSettledHolds, readAvailable: A.readAvailable });
50
+ }
51
+ const db = require('../modules/lifecycle/db.js');
52
+ const P = require('../modules/lifecycle/pay-terms.js');
53
+ const settings = require('../src/bongos/project-settings.js');
54
+ const approvals = require('../src/bongos/payout-approvals.js');
55
+ const gates = require('../src/bongos/payout-gates.js');
56
+ const payouts = require('../src/bongos/builder-payouts.js');
57
+ const { readOutflow } = require('../modules/economy/outflow.js');
58
+
59
+ const USD = 1_000_000;
60
+ const RUN = Date.now() % 1_000_000;
61
+ const BASE = 930_000_000 + RUN * 10;
62
+ const OWNER = BASE, ADA = BASE + 1;
63
+ const TASK_A = BASE, TASK_B = BASE + 1, TASK_C = BASE + 2;
64
+ const VER = `ZZZ-M07-${RUN}`;
65
+ const WALL_KEY = settings.SETTING_KEYS.FUNDING_WALL;
66
+ const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
67
+ let pass = 0;
68
+ const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
69
+ const available = async () => (await A.readAvailable(pool)).available_micro;
70
+ const claim = async (taskId) => {
71
+ try { return { claim: await db.claimTask({ taskId: String(taskId), builderId: String(ADA), worktreeName: `m07-${RUN}-${taskId}` }) }; } catch (e) { return { err: e }; }
72
+ };
73
+ const cash = (taskId, amount) => P.setPayTerms({ taskId, terms: [{ pay_type: 'cash', amount, unit: 'USD', note: null }], builderId: OWNER }, { db: pool });
74
+
75
+ // The test rail: a processor that keeps one transfer per idempotency key, and declines the first.
76
+ const rail = {
77
+ transfers: new Map(), calls: 0, declineNext: true,
78
+ async transfer(req) {
79
+ this.calls += 1;
80
+ if (this.declineNext) { this.declineNext = false; throw Object.assign(new Error('the bank declined the transfer'), { code: 'declined' }); }
81
+ if (!this.transfers.has(req.idempotencyKey)) this.transfers.set(req.idempotencyKey, { ref: `tr_m07_${RUN}`, amountCents: req.amountCents });
82
+ return { ref: this.transfers.get(req.idempotencyKey).ref };
83
+ },
84
+ };
85
+
86
+ const priorWall = await settings.getSetting(WALL_KEY);
87
+ try {
88
+ await q(`INSERT INTO versions (id,name,status,track) VALUES ($1,'m07 proof','building','internal') ON CONFLICT (id) DO NOTHING`, [VER]);
89
+ await q(`INSERT INTO builders (id,github_id,github_login,display_name,rank) VALUES ($1,$2,$3,'M07 Owner','archon'), ($4,$5,$6,'Ada','archon')`,
90
+ [OWNER, `m07-o-${RUN}`, `m07-owner-${RUN}`, ADA, `m07-a-${RUN}`, `m07-ada-${RUN}`]);
91
+ for (const [id, title] of [[TASK_A, 'Build the paid thing'], [TASK_B, 'Build the second thing'], [TASK_C, 'Build the last thing']]) {
92
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline) VALUES ($1,$2,$3,'ready',10,'feature','engineer')`, [id, VER, title]);
93
+ }
94
+ await cash(TASK_A, '100'); await cash(TASK_B, '80'); await cash(TASK_C, '40');
95
+ await settings.setSetting(WALL_KEY, 'hold_at_claim', OWNER);
96
+
97
+ // 1. fund the project so $150 is available (relative to whatever the database already holds)
98
+ const before = await available();
99
+ if (before < 150 * USD) await A.recordMovement(pool, { kind: 'funding', amountMicro: 150 * USD - before, processorRef: `ch_m07_${RUN}` });
100
+ if (before > 150 * USD) await A.recordMovement(pool, { kind: 'fee', amountMicro: before - 150 * USD, processorRef: `fee_m07_${RUN}`, note: 'M07 proof: start from $150' });
101
+ ok(await available() === 150 * USD, 'the project is funded: $150 available');
102
+ const out0 = await readOutflow(pool, { months: 1 });
103
+ const otherCommitted = out0.balance.available_micro - out0.balance.after_commitments_micro;
104
+
105
+ // 2. a funded claim passes and holds the task's pay
106
+ const a = await claim(TASK_A);
107
+ ok(a.claim && !a.err, `Ada claims the $100 task (${a.err ? a.err.code : 'claimed'})`);
108
+ ok(await available() === 50 * USD, 'its $100 pay is held: $50 left to promise');
109
+
110
+ // 3. an underfunded claim is refused, with the reason and the shortfall
111
+ const b = await claim(TASK_B);
112
+ ok(b.err && b.err.code === 'FUNDING_SHORTFALL', `the $80 task is refused (${b.err && b.err.code})`);
113
+ ok(/\$30\.00 short/.test(b.err.message), `and the refusal says by how much: "${b.err.message}"`);
114
+ ok((await q('SELECT status FROM tasks WHERE id = $1', [TASK_B]))[0].status === 'ready', 'the refused task stays in the queue, unclaimed');
115
+
116
+ // 4. the task ships through the real state machine, and its pay stays held until it is paid
117
+ await db.resolveClaim({ claimId: a.claim.id, builderId: String(ADA), outcome: 'shipped', notesMd: 'M07 proof', force: true });
118
+ await db.confirmTask({ taskId: String(TASK_A), builderId: String(OWNER) });
119
+ await db.shipTask({ taskId: String(TASK_A), builderId: String(ADA), landAssurance: { kind: 'proof', reason: 'test fixture: land proven' } });
120
+ ok((await q('SELECT status FROM tasks WHERE id = $1', [TASK_A]))[0].status === 'shipped', 'the task shipped');
121
+ ok(await available() === 50 * USD, 'shipped but not yet paid, its $100 is still held');
122
+ const b2 = await claim(TASK_B);
123
+ ok(b2.err && b2.err.code === 'FUNDING_SHORTFALL', 'so the $80 task is still refused: money owed to Ada cannot be promised again');
124
+
125
+ // 5. the owner approves; Ada clears the gates; the first transfer is declined
126
+ const person = { id: OWNER, rank: 'archon', session_id: 1, session_source: 'web' };
127
+ const approval = await approvals.insertEvent({ kind: 'payout_approved', actor: person, recipient_id: ADA, amount_cents: 10000, currency: 'USD' }, { pool });
128
+ const o = { pool };
129
+ await gates.recordAge({ builderId: ADA, tier: 'adult', basis: 'verified_by_person', recordedBy: OWNER }, o);
130
+ await gates.recordScreening({ builderId: ADA, result: 'clear', screenedBy: 'person:owner', validUntil: new Date(Date.now() + 86_400_000).toISOString(), recordedBy: OWNER }, o);
131
+ await gates.recordNda({ builderId: ADA, status: 'on_file', ndaVersion: 'beta-v1', reference: `m07-${RUN}`, recordedBy: OWNER }, o);
132
+ await gates.recordTaxDetails({ builderId: ADA, status: 'collected', collectedBy: 'person:owner', reference: `onb-m07-${RUN}`, recordedBy: OWNER }, o);
133
+ const press = () => payouts.payForTask({ approvalId: approval.id, taskId: String(TASK_A), actorId: String(OWNER) }, { pool, processor: rail });
134
+ const first = await press();
135
+ ok(first.status === 'failed' && /declined/.test(first.reason), `a declined transfer is recorded, not lost (${first.status}: ${first.reason})`);
136
+ ok(await available() === 50 * USD, 'nothing left the account, and the pay is still held');
137
+
138
+ // 6. the owner sees the debt as owed but unpaid, and the balance after it
139
+ const out1 = await readOutflow(pool, { months: 1 });
140
+ const owedRow = out1.committed.owed.find((r) => r.task_id === String(TASK_A));
141
+ ok(owedRow && owedRow.amount_micro === 100 * USD && /declined/.test(owedRow.last_reason), 'the owner sees $100 owed to Ada, and why it is unpaid');
142
+ ok(owedRow.held_micro === 100 * USD, 'already covered by the hold');
143
+ ok(out1.balance.after_commitments_micro === 50 * USD - otherCommitted, 'so the balance after commitments counts it once, not twice');
144
+
145
+ // 7. pressed again, twice at once: paid exactly once; a third press sends nothing
146
+ const [p1, p2] = await Promise.all([press(), press()]);
147
+ ok([p1, p2].every((p) => p.status === 'paid' && p.processor_ref === `tr_m07_${RUN}`), 'both presses answer paid, with one transfer reference');
148
+ ok(rail.transfers.size === 1, 'the rail made one transfer');
149
+ const third = await press();
150
+ ok(third.status === 'paid' && third.already === true, 'a third press finds it paid and sends nothing');
151
+ const moved = await q("SELECT amount_micro FROM economy_project_account_movements WHERE kind = 'payout' AND task_id = $1", [TASK_A]);
152
+ ok(moved.length === 1 && Number(moved[0].amount_micro) === 100 * USD, 'one $100 payout left the account');
153
+ ok(await available() === 50 * USD, 'the balance fell by $100 and the hold went with it: still $50 to promise');
154
+
155
+ // 8. the owner sees the burn, the per-builder total, nothing owed
156
+ const out2 = await readOutflow(pool, { months: 1 });
157
+ const ada = out2.by_builder.find((r) => r.builder_id === String(ADA));
158
+ ok(ada && ada.paid_micro === 100 * USD && ada.owed_micro === 0, 'Ada reads $100 paid, nothing owed');
159
+ ok(!out2.committed.owed.some((r) => r.task_id === String(TASK_A)), 'nothing is left owed for the task');
160
+ ok(out2.burn.months[0].payouts_micro - out0.burn.months[0].payouts_micro === 100 * USD, 'this month\'s burn carries the $100 payout');
161
+
162
+ // 9. drain the balance: one more claim fits, the next is refused with its shortfall
163
+ const c = await claim(TASK_C);
164
+ ok(c.claim && !c.err, 'the $40 task fits and is claimed');
165
+ ok(await available() === 10 * USD, '$10 left');
166
+ const b3 = await claim(TASK_B);
167
+ ok(b3.err && b3.err.code === 'FUNDING_SHORTFALL' && b3.err.shortfall_micro === 70 * USD, `the $80 task is refused, $70 short (${b3.err && b3.err.message})`);
168
+
169
+ console.log(`funded_project_pays_proof_db.mjs: ${pass} passed`);
170
+ } catch (e) {
171
+ console.error('funded_project_pays_proof_db.mjs FAILED:', e.stack || e.message);
172
+ process.exitCode = 1;
173
+ } finally {
174
+ if (priorWall) await settings.setSetting(WALL_KEY, priorWall.value, priorWall.updated_by).catch(() => {});
175
+ else await q('DELETE FROM project_settings WHERE key = $1', [WALL_KEY]).catch(() => {});
176
+ await q('UPDATE claims SET released_at = now() WHERE task_id = ANY($1::bigint[]) AND released_at IS NULL', [[TASK_A, TASK_B, TASK_C]]).catch(() => {});
177
+ await q("UPDATE tasks SET status = 'abandoned' WHERE id = ANY($1::bigint[]) AND status <> 'shipped'", [[TASK_B, TASK_C]]).catch(() => {});
178
+ await pool.end();
179
+ const { pool: corePool } = require('../src/bongos/pool.js');
180
+ await corePool.end().catch(() => {});
181
+ }
@@ -0,0 +1,205 @@
1
+ // tests/funding_wall.mjs — the funding wall (task 1003185, goal 1000092 M06; ADR 0386).
2
+ // Behaviour of modules/lifecycle/funding-wall.js, with the account port and the pay-terms
3
+ // read stubbed:
4
+ // - a task that pays no cash is never walled, and the rule and account are not even read;
5
+ // - under hold_at_claim a short project refuses with the REASON and the SHORTFALL, and a
6
+ // funded one holds the task's cash pay; a batch is judged cumulatively;
7
+ // - the dry run never locks or holds; an unreadable price and a switched-off account
8
+ // refuse rather than guess;
9
+ // - refuse_at_completion lets the claim through and refuses the "done" instead;
10
+ // carry_on refuses nothing;
11
+ // - the three claim surfaces, the resolve path and the routes are wired to it.
12
+ // The SQL is proven against real Postgres in tests/funding_wall_db.mjs.
13
+
14
+ import { test } from 'node:test';
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import { readFileSync } from 'node:fs';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const W = require('../modules/lifecycle/funding-wall.js');
21
+ const { cardFor } = require('../scripts/bongos/plain-cards.js');
22
+ const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n');
23
+
24
+ const USD = 1_000_000;
25
+ const cash = (amount, unit = null) => ({ source: 'task', terms: [{ pay_type: 'cash', amount, unit }] });
26
+ const terms = (byId) => async (ids) => new Map(ids.map((id) => [id, byId[id] || { source: 'none', terms: [] }]));
27
+ const mode = (m) => async () => ({ mode: m, chosen: true });
28
+
29
+ function fakeAccount({ balance = 0, held = 0 } = {}) {
30
+ const calls = [];
31
+ return {
32
+ calls,
33
+ async lockAccount() { calls.push('lock'); },
34
+ async releaseSettledHolds() { calls.push('release'); return []; },
35
+ async readAvailable(_db, opts = {}) { calls.push(['read', opts]); return { balance_micro: balance, held_micro: held, available_micro: balance - held }; },
36
+ async recordMovement(_db, m) { calls.push(['hold', m]); return { recorded: true, row: m }; },
37
+ };
38
+ }
39
+ const failNow = () => { throw new Error('must not be read'); };
40
+
41
+ test('the rule parses three modes and nothing else', () => {
42
+ assert.deepEqual(W.FUNDING_WALL_MODES, ['hold_at_claim', 'refuse_at_completion', 'carry_on']);
43
+ assert.equal(W.DEFAULT_FUNDING_WALL, 'hold_at_claim', 'a project that sets nothing is protected at claim (ADR 0386)');
44
+ assert.equal(W.parseFundingWall(' Carry_On ').mode, 'carry_on');
45
+ assert.ok(W.parseFundingWall('halt').reason);
46
+ assert.ok(W.parseFundingWall('').reason);
47
+ });
48
+
49
+ test('only a plain dollar figure is read as a price; nothing is guessed', () => {
50
+ assert.equal(W.dollarsMicro('250', null), 250 * USD);
51
+ assert.equal(W.dollarsMicro('$1,250.50', null), 1250.5 * USD);
52
+ assert.equal(W.dollarsMicro('40', 'USD'), 40 * USD);
53
+ assert.equal(W.dollarsMicro('USD 40', null), 40 * USD);
54
+ assert.equal(W.dollarsMicro('0.05', 'dollars'), 50_000);
55
+ for (const [a, u] of [['250', 'EUR'], ['negotiable', null], ['about 200', null], ['1.234', null], ['-5', null], [null, null], ['12,34', null]]) {
56
+ assert.equal(W.dollarsMicro(a, u), null, `${a} ${u} is not a dollar figure`);
57
+ }
58
+ assert.equal(W.usd(1250.5 * USD), '$1,250.50');
59
+ assert.equal(W.usd(-3 * USD), '-$3.00');
60
+ });
61
+
62
+ test('a task whose pay is not cash is never walled', () => {
63
+ assert.deepEqual(W.cashPrice({ terms: [{ pay_type: 'credit_pool', amount: '5' }, { pay_type: 'self_managed', amount: '900' }] }), { cash: false });
64
+ assert.deepEqual(W.cashPrice({ terms: [{ pay_type: 'cash', amount: '10' }, { pay_type: 'cash', amount: '5', unit: 'usd' }] }), { cash: true, micro: 15 * USD });
65
+ assert.equal(W.cashPrice({ terms: [{ pay_type: 'cash', amount: 'a lot' }] }).unreadable, 'a lot');
66
+ });
67
+
68
+ test('no cash pay: the rule and the account are not even read', async () => {
69
+ const out = await W.fundingWallFailures({
70
+ client: {}, tasks: [{ id: 7 }], place: true,
71
+ deps: { readPayTerms: terms({}), resolveFundingWall: failNow, account: { lockAccount: failNow } },
72
+ });
73
+ assert.deepEqual(out, []);
74
+ });
75
+
76
+ test('hold at claim: a short project refuses, naming the price, the balance, what is held and the shortfall', async () => {
77
+ const account = fakeAccount({ balance: 180 * USD, held: 80 * USD });
78
+ const out = await W.fundingWallFailures({
79
+ client: {}, tasks: [{ id: 7 }], place: true,
80
+ deps: { readPayTerms: terms({ 7: cash('250') }), resolveFundingWall: mode('hold_at_claim'), account },
81
+ });
82
+ assert.equal(out.length, 1);
83
+ const r = out[0].refusal;
84
+ assert.equal(r.code, 'FUNDING_SHORTFALL');
85
+ assert.equal(r.price_micro, 250 * USD);
86
+ assert.equal(r.available_micro, 100 * USD);
87
+ assert.equal(r.shortfall_micro, 150 * USD);
88
+ for (const figure of ['$250.00', '$100.00', '$180.00', '$80.00', '$150.00 short']) assert.ok(r.message.includes(figure), `the message names ${figure}`);
89
+ assert.ok(r.hint, 'the refusal names the way forward');
90
+ assert.ok(!account.calls.some((c) => c[0] === 'hold'), 'nothing is held when the claim is refused');
91
+ });
92
+
93
+ test('hold at claim: a funded project takes the lock, lets go of settled holds, then holds the pay', async () => {
94
+ const account = fakeAccount({ balance: 300 * USD });
95
+ const out = await W.fundingWallFailures({
96
+ client: {}, tasks: [{ id: 7 }], place: true,
97
+ deps: { readPayTerms: terms({ 7: cash('250') }), resolveFundingWall: mode('hold_at_claim'), account },
98
+ });
99
+ assert.deepEqual(out, []);
100
+ assert.deepEqual(account.calls.slice(0, 2), ['lock', 'release'], 'the lock comes first, so two claims cannot read the same balance');
101
+ const hold = account.calls.find((c) => c[0] === 'hold')[1];
102
+ assert.equal(hold.kind, 'hold');
103
+ assert.equal(hold.amountMicro, 250 * USD);
104
+ assert.equal(hold.taskId, '7');
105
+ });
106
+
107
+ test('a batch is judged cumulatively, so it cannot over-commit one balance', async () => {
108
+ const account = fakeAccount({ balance: 100 * USD });
109
+ const out = await W.fundingWallFailures({
110
+ client: {}, tasks: [{ id: 1 }, { id: 2 }], place: true,
111
+ deps: { readPayTerms: terms({ 1: cash('60'), 2: cash('60') }), resolveFundingWall: mode('hold_at_claim'), account },
112
+ });
113
+ assert.equal(out.length, 1);
114
+ assert.equal(out[0].task_id, 2);
115
+ assert.equal(out[0].refusal.held_micro, 60 * USD, 'the second task sees the first one held');
116
+ assert.equal(out[0].refusal.shortfall_micro, 20 * USD);
117
+ assert.ok(!account.calls.some((c) => c[0] === 'hold'), 'a refused batch holds nothing');
118
+ });
119
+
120
+ test('the dry run reads only: no lock, no release, no hold', async () => {
121
+ const account = fakeAccount({ balance: 300 * USD });
122
+ await W.fundingWallFailures({
123
+ client: {}, tasks: [{ id: 7 }],
124
+ deps: { readPayTerms: terms({ 7: cash('250') }), resolveFundingWall: mode('hold_at_claim'), account },
125
+ });
126
+ assert.deepEqual(account.calls.map((c) => (Array.isArray(c) ? c[0] : c)), ['read']);
127
+ });
128
+
129
+ test('an unreadable price and a switched-off account refuse rather than guess', async () => {
130
+ const unreadable = await W.fundingWallFailures({
131
+ client: {}, tasks: [{ id: 7 }], place: true,
132
+ deps: { readPayTerms: terms({ 7: cash('negotiable') }), resolveFundingWall: mode('hold_at_claim'), account: fakeAccount({ balance: 1e12 }) },
133
+ });
134
+ assert.equal(unreadable[0].refusal.code, 'FUNDING_PRICE_UNREADABLE');
135
+ assert.ok(unreadable[0].refusal.message.includes('"negotiable"'), 'it says what it read');
136
+ const noAccount = await W.fundingWallFailures({
137
+ client: {}, tasks: [{ id: 7 }], place: true,
138
+ deps: { readPayTerms: terms({ 7: cash('5') }), resolveFundingWall: mode('hold_at_claim'), account: null },
139
+ });
140
+ assert.equal(noAccount[0].refusal.code, 'FUNDING_NO_ACCOUNT');
141
+ });
142
+
143
+ test('refuse at completion and carry on let the claim through and hold nothing', async () => {
144
+ for (const m of ['refuse_at_completion', 'carry_on']) {
145
+ const account = fakeAccount({ balance: 0 });
146
+ const out = await W.fundingWallFailures({
147
+ client: {}, tasks: [{ id: 7 }], place: true,
148
+ deps: { readPayTerms: terms({ 7: cash('250') }), resolveFundingWall: mode(m), account },
149
+ });
150
+ assert.deepEqual(out, [], `${m} never refuses a claim`);
151
+ assert.deepEqual(account.calls, [], `${m} never touches the account at claim`);
152
+ }
153
+ });
154
+
155
+ test('refuse at completion: declaring a short task done is refused with the shortfall; its own hold counts for it', async () => {
156
+ const account = fakeAccount({ balance: 100 * USD });
157
+ const deps = { readPayTerms: terms({ 7: cash('250') }), resolveFundingWall: mode('refuse_at_completion'), account };
158
+ await assert.rejects(W.fundingWallOnResolve({}, 7, 'shipped', deps), (e) => {
159
+ assert.equal(e.code, 'FUNDING_SHORTFALL_AT_COMPLETION');
160
+ assert.equal(e.shortfall_micro, 150 * USD);
161
+ assert.ok(e.message.includes('marked done'));
162
+ return true;
163
+ });
164
+ assert.deepEqual(account.calls.find((c) => c[0] === 'read')[1], { exceptTaskId: 7 });
165
+ await W.fundingWallOnResolve({}, 7, 'shipped', { ...deps, account: fakeAccount({ balance: 250 * USD }) });
166
+ });
167
+
168
+ test('at completion, the other rules refuse nothing; a let-go task releases its hold', async () => {
169
+ for (const m of ['hold_at_claim', 'carry_on']) {
170
+ await W.fundingWallOnResolve({}, 7, 'shipped', { readPayTerms: terms({ 7: cash('250') }), resolveFundingWall: mode(m), account: fakeAccount() });
171
+ }
172
+ const account = fakeAccount();
173
+ await W.fundingWallOnResolve({}, 7, 'abandoned', { readPayTerms: failNow, resolveFundingWall: failNow, account });
174
+ assert.deepEqual(account.calls, ['release']);
175
+ await W.fundingWallOnResolve({}, 7, 'abandoned', { account: null }); // no account: nothing to release, no throw
176
+ });
177
+
178
+ test('the rule is read from the project setting; a bad or unreadable value falls to the protective default', async () => {
179
+ assert.equal((await W.resolveFundingWall({ getSetting: async () => ({ value: 'carry_on' }) })).mode, 'carry_on');
180
+ const bad = await W.resolveFundingWall({ getSetting: async () => ({ value: 'never' }) });
181
+ assert.equal(bad.mode, 'hold_at_claim');
182
+ assert.ok(bad.invalid && bad.invalid.raw === 'never');
183
+ assert.equal((await W.resolveFundingWall({ getSetting: async () => { throw new Error('db down'); } })).mode, 'hold_at_claim');
184
+ const none = await W.resolveFundingWall({ getSetting: async () => null });
185
+ assert.equal(none.chosen, false);
186
+ assert.ok(/has not chosen/.test(W.describeFundingWall(none)));
187
+ });
188
+
189
+ test('wiring: all three claim surfaces, the resolve path, the routes and the cards', () => {
190
+ const claims = read('modules/lifecycle/db-claims.js');
191
+ assert.equal((claims.match(/await fundingWallFailures\(/g) || []).length, 3, 'claimTask, claimTasksBatch and validateClaimBatch each ask the wall');
192
+ assert.equal((claims.match(/fundingWallFailures\(\{ client, tasks: [^}]*place: true \}\)/g) || []).length, 2, 'both real claim paths place the hold');
193
+ assert.match(claims, /fundingWallFailures\(\{ client: pool, tasks: taskRows \}\)/, 'the dry run only reads');
194
+ assert.match(read('modules/lifecycle/db-ship.js'), /fundingWallOnResolve\(client, claim\.task_id, outcome\)/);
195
+ const routes = read('modules/lifecycle/routes/claims.js');
196
+ for (const code of ['FUNDING_SHORTFALL', 'FUNDING_SHORTFALL_AT_COMPLETION', 'FUNDING_PRICE_UNREADABLE', 'FUNDING_NO_ACCOUNT']) {
197
+ assert.match(routes, new RegExp(`'${code}' \\? 409`), `${code} answers 409`);
198
+ assert.ok(cardFor(code), `${code} has a plain card`);
199
+ }
200
+ const settings = read('modules/lifecycle/routes/project-settings.js');
201
+ assert.match(settings, /router\.patch\('\/project-settings\/funding-wall', api\.requireBuilder, api\.requirePermission\('project\.settings\.manage'\)/);
202
+ assert.match(settings, /insertAuditLog\(/, 'a change to the rule is audited');
203
+ assert.match(settings, /funding_wall: \{ mode: wall\.mode/, 'the rule rides beside a task\'s pay terms');
204
+ assert.match(read('modules/economy/routes/project-account.js'), /lockAccount, releaseSettledHolds, readAvailable[,} ]/);
205
+ });
@@ -0,0 +1,174 @@
1
+ // tests/funding_wall_db.mjs — task 1003185 (goal 1000092 M06; ADR 0386). Real DB.
2
+ //
3
+ // Applies the REAL project-account migration (economy_002) and pay-terms migration
4
+ // (lifecycle_018), then drives the real funding wall (modules/lifecycle/funding-wall.js)
5
+ // against the real account functions (modules/economy/project-account.js) and the real
6
+ // pay-terms read:
7
+ // - a funded claim books a `hold` row; an underfunded one is refused with the shortfall;
8
+ // - a task let go has its hold released by state (a `hold_release` row) at the next claim;
9
+ // - a shipped task keeps its hold until a payout is booked for it (task 1003186);
10
+ // - a task with no pay terms is not walled; the project's standing cash terms are;
11
+ // - the owner view's held figure counts only work in flight;
12
+ // - refuse_at_completion counts a task's own hold for it;
13
+ // - TWO SIMULTANEOUS CLAIMS cannot both hold the last of one balance: the second waits
14
+ // on the account lock and then sees the first one's hold.
15
+ //
16
+ // Self-skips without a reachable Postgres.
17
+ // Run: DATABASE_URL=... node tests/funding_wall_db.mjs
18
+ import { createRequire } from 'node:module';
19
+ import { readFileSync } from 'node:fs';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { dirname, join } from 'node:path';
22
+ import { strict as assert } from 'node:assert';
23
+ const require = createRequire(import.meta.url);
24
+ const { Pool } = require('pg');
25
+ const A = require('../modules/economy/project-account.js');
26
+ const W = require('../modules/lifecycle/funding-wall.js');
27
+ const P = require('../modules/lifecycle/pay-terms.js');
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const sql = (...p) => readFileSync(join(here, '..', ...p), 'utf8');
30
+ // economy_004 (task 1004910) adds module_share_id, which recordMovement writes.
31
+ const ACCOUNT_MIGRATION = sql('modules', 'economy', 'migrations', 'economy_002_project_account.sql')
32
+ + sql('modules', 'economy', 'migrations', 'economy_004_module_share_payouts.sql');
33
+ const HOLDS_INDEX_MIGRATION = sql('modules', 'economy', 'migrations', 'economy_005_project_account_holds_index.sql');
34
+ const TERMS_MIGRATION = sql('modules', 'lifecycle', 'migrations', 'lifecycle_018_pay_terms.sql');
35
+
36
+ const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 3 });
37
+ let client;
38
+ try { client = await pgPool.connect(); } catch (e) {
39
+ console.log(`funding_wall_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
40
+ await pgPool.end().catch(() => {});
41
+ process.exit(0);
42
+ }
43
+ const SCHEMA = `t1003185_${process.pid}`;
44
+ const USD = 1_000_000;
45
+ let pass = 0;
46
+ const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
47
+ const account = { lockAccount: A.lockAccount, releaseSettledHolds: A.releaseSettledHolds, readAvailable: A.readAvailable, recordMovement: A.recordMovement };
48
+ const rule = (mode) => ({ account, resolveFundingWall: async () => ({ mode, chosen: true }) });
49
+ const claim = (db, ids, mode = 'hold_at_claim', place = true) =>
50
+ W.fundingWallFailures({ client: db, tasks: ids.map((id) => ({ id })), place, deps: rule(mode) });
51
+ const setStatus = (db, id, status) => db.query('UPDATE tasks SET status = $2 WHERE id = $1', [id, status]);
52
+ const cash = (db, taskId, amount) => P.setPayTerms({ taskId, terms: [{ pay_type: 'cash', amount, unit: 'USD', note: null }], builderId: 1 }, { db });
53
+ const holdsFor = async (db, taskId) => (await db.query(
54
+ "SELECT kind, amount_micro::bigint AS a FROM economy_project_account_movements WHERE task_id = $1 AND kind IN ('hold','hold_release') ORDER BY id", [taskId])).rows
55
+ .map((r) => `${r.kind}:${Number(r.a) / USD}`);
56
+
57
+ try {
58
+ await client.query(`CREATE SCHEMA ${SCHEMA}`);
59
+ await client.query(`SET search_path TO ${SCHEMA}`);
60
+ await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, display_name text, github_login text)');
61
+ await client.query("CREATE TABLE tasks (id bigserial PRIMARY KEY, title text, status text NOT NULL DEFAULT 'ready')");
62
+ await client.query('CREATE TABLE schema_migrations (version text PRIMARY KEY)');
63
+ await client.query("INSERT INTO builders (display_name, github_login) VALUES ('Owner', 'owner')");
64
+ await client.query("INSERT INTO tasks (title) SELECT 'task ' || g FROM generate_series(1, 8) g");
65
+ await client.query(ACCOUNT_MIGRATION);
66
+ await client.query(TERMS_MIGRATION);
67
+ await client.query(HOLDS_INDEX_MIGRATION);
68
+ // schemaname: a migrated database has the same index in its own schema too (task 1003186)
69
+ ok((await client.query("SELECT 1 FROM pg_indexes WHERE schemaname = $1 AND indexname = 'economy_project_account_holds'", [SCHEMA])).rows.length === 1, 'the hold read has its partial index');
70
+ await A.recordMovement(client, { kind: 'funding', amountMicro: 300 * USD, processorRef: 'ch_seed' });
71
+ await cash(client, 1, '250');
72
+ await cash(client, 2, '100');
73
+
74
+ // 1. a funded claim holds its pay
75
+ await client.query('BEGIN');
76
+ ok((await claim(client, [1])).length === 0, 'a funded claim passes the wall');
77
+ await setStatus(client, 1, 'active');
78
+ await client.query('COMMIT');
79
+ ok((await holdsFor(client, 1)).join() === 'hold:250', 'the claim booked a hold for the task\'s cash pay');
80
+
81
+ // 2. the next claim sees it: the project is short and the refusal says by how much
82
+ await client.query('BEGIN');
83
+ const short = await claim(client, [2]);
84
+ await client.query('ROLLBACK');
85
+ ok(short.length === 1 && short[0].refusal.code === 'FUNDING_SHORTFALL', 'a claim the project cannot cover is refused');
86
+ ok(short[0].refusal.shortfall_micro === 50 * USD && /\$50\.00 short/.test(short[0].refusal.message), 'the refusal names the shortfall: $50.00');
87
+ ok(short[0].refusal.held_micro === 250 * USD, 'and what is already held');
88
+
89
+ // 3. a task with no pay terms is never walled
90
+ ok((await claim(client, [3])).length === 0, 'a task that pays no cash is not walled');
91
+
92
+ // 4. task 1 is let go: its hold is released BY STATE at the next claim, and that claim fits
93
+ await setStatus(client, 1, 'ready');
94
+ ok((await A.readAvailable(client)).held_micro === 0, 'a let-go task\'s hold no longer counts, even before its release row');
95
+ await client.query('BEGIN');
96
+ ok((await claim(client, [2])).length === 0, 'with the hold gone, the claim fits');
97
+ await setStatus(client, 2, 'active');
98
+ await client.query('COMMIT');
99
+ ok((await holdsFor(client, 1)).join() === 'hold:250,hold_release:250', 'the next claim wrote the hold_release row');
100
+ const view = await A.readAccount(client);
101
+ ok(view.held_micro === 100 * USD && view.available_micro === 200 * USD, 'the owner view shows only work in flight as held');
102
+
103
+ // 5. the project's standing cash terms price a task with none of its own
104
+ await P.setPayTerms({ taskId: null, terms: [{ pay_type: 'cash', amount: '$500', unit: null, note: null }], builderId: 1 }, { db: client });
105
+ const standing = await claim(client, [3], 'hold_at_claim', false);
106
+ ok(standing.length === 1 && standing[0].refusal.price_micro === 500 * USD, 'standing cash terms are walled too');
107
+
108
+ // 6. a batch is judged cumulatively in one transaction
109
+ await cash(client, 4, '150'); await cash(client, 5, '150');
110
+ await client.query('BEGIN');
111
+ const batch = await claim(client, [4, 5]);
112
+ await client.query('ROLLBACK');
113
+ ok(batch.length === 1 && batch[0].task_id === 5 && batch[0].refusal.shortfall_micro === 100 * USD, 'a batch cannot over-commit: the second task is $100 short');
114
+ ok((await holdsFor(client, 4)).length === 0, 'a refused batch, rolled back, left no hold');
115
+
116
+ // 7. refuse at completion: the task's own hold counts for it
117
+ await cash(client, 6, '200');
118
+ await client.query('BEGIN');
119
+ await claim(client, [6]);
120
+ await setStatus(client, 6, 'active');
121
+ await client.query('COMMIT');
122
+ ok((await A.readAvailable(client)).available_micro === 0, 'two holds take the whole balance');
123
+ await client.query('BEGIN');
124
+ await W.fundingWallOnResolve(client, 6, 'shipped', rule('refuse_at_completion'));
125
+ await client.query('ROLLBACK');
126
+ ok(true, 'a task held at claim can be marked done under refuse_at_completion: its own hold covers it');
127
+ await cash(client, 7, '50');
128
+ await setStatus(client, 7, 'active');
129
+ let refused = null;
130
+ await client.query('BEGIN');
131
+ try { await W.fundingWallOnResolve(client, 7, 'shipped', rule('refuse_at_completion')); } catch (e) { refused = e; }
132
+ await client.query('ROLLBACK');
133
+ ok(refused && refused.code === 'FUNDING_SHORTFALL_AT_COMPLETION' && refused.shortfall_micro === 50 * USD, 'an unheld task marked done while short is refused with the shortfall');
134
+
135
+ // 8. a shipped task keeps its hold until its builder is paid (task 1003186)
136
+ await setStatus(client, 2, 'shipped');
137
+ await setStatus(client, 6, 'shipped');
138
+ ok((await A.readAvailable(client)).held_micro === 300 * USD, 'shipped but unpaid, both holds still count');
139
+ ok((await A.releaseSettledHolds(client)).length === 0, 'and neither is released');
140
+ await A.recordMovement(client, { kind: 'payout', amountMicro: 100 * USD, builderId: 1, taskId: 2, processorRef: 'tr_2' });
141
+ ok((await A.readAvailable(client)).held_micro === 200 * USD, 'a payout booked for a task lets its hold go at once');
142
+ await A.recordMovement(client, { kind: 'payout', amountMicro: 200 * USD, builderId: 1, taskId: 6, processorRef: 'tr_6' });
143
+ ok((await A.releaseSettledHolds(client)).length === 2, 'and the next release writes both hold_release rows');
144
+ await A.recordMovement(client, { kind: 'funding', amountMicro: 300 * USD, processorRef: 'ch_refill' }); // balance 300, nothing in flight
145
+
146
+ // 9. two simultaneous claims on the last of one balance: one wins, one is told it is short
147
+ await cash(client, 8, '200');
148
+ const other = await pgPool.connect();
149
+ try {
150
+ await other.query(`SET search_path TO ${SCHEMA}`);
151
+ await client.query('BEGIN');
152
+ await other.query('BEGIN');
153
+ ok((await claim(client, [4])).length === 0, 'the first claim takes the lock and holds $150');
154
+ let second = null;
155
+ const pending = claim(other, [8]).then((f) => { second = f; });
156
+ await new Promise((r) => setTimeout(r, 300));
157
+ ok(second === null, 'the second claim waits on the account lock');
158
+ await setStatus(client, 4, 'active');
159
+ await client.query('COMMIT');
160
+ await pending;
161
+ await other.query('ROLLBACK');
162
+ ok(second.length === 1 && second[0].refusal.code === 'FUNDING_SHORTFALL' && second[0].refusal.shortfall_micro === 50 * USD,
163
+ 'then sees the first hold and is refused, $50 short — the balance was not committed twice');
164
+ } finally {
165
+ await other.query('ROLLBACK').catch(() => {});
166
+ other.release();
167
+ }
168
+ console.log(`funding_wall_db.mjs: ${pass} passed`);
169
+ } finally {
170
+ await client.query('ROLLBACK').catch(() => {});
171
+ await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
172
+ client.release();
173
+ await pgPool.end().catch(() => {});
174
+ }
@@ -244,6 +244,7 @@ const REQUESTS = {
244
244
  'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
245
245
  'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
246
246
  'PATCH /project-settings/pay-terms': ['PATCH', '/project-settings/pay-terms', { terms: 'not a list' }],
247
+ 'PATCH /project-settings/funding-wall': ['PATCH', '/project-settings/funding-wall', { mode: 'sometimes' }],
247
248
  'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
248
249
  // task 1004941: the owner deciding a Market Watch proposal
249
250
  'POST /market-watch/proposals/:id/decide': ['POST', `/market-watch/proposals/${BAD}/decide`, { decision: 'approve' }],