@bongos/core 1.24.23 → 1.24.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +212 -57
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +42 -0
- package/clients/bongos-client/index.cjs +42 -0
- package/clients/bongos-client/index.d.ts +55 -1
- package/clients/bongos-client/index.mjs +42 -0
- package/docs/api/openapi.json +1181 -207
- package/docs/api-reference.md +30 -5
- package/docs/architecture.md +42 -0
- package/docs/copy-inventory.md +18 -8
- package/docs/copy-registry.json +99 -9
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +64 -25
- package/migrations/core_291_payout_nda_tax.sql +75 -0
- package/migrations/core_292_module_sale_payouts.sql +170 -0
- package/migrations/core_293_cash_outs.sql +184 -0
- package/migrations/core_294_cash_out_attempts_sent_by_time.sql +21 -0
- package/modules/economy/ledger-check.js +360 -0
- package/modules/economy/migrations/economy_003_user_balances.sql +116 -0
- package/modules/economy/migrations/economy_004_module_share_payouts.sql +22 -0
- package/modules/economy/migrations/economy_005_project_account_holds_index.sql +20 -0
- package/modules/economy/migrations/economy_006_ledger_checks.sql +68 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/outflow.js +209 -0
- package/modules/economy/pollers/ledger-check.js +87 -0
- package/modules/economy/project-account.js +92 -7
- package/modules/economy/routes/balances.js +79 -0
- package/modules/economy/routes/project-account.js +40 -2
- package/modules/economy/user-balances.js +308 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/hall-ui/public/project-settings.css +18 -0
- package/modules/hall-ui/public/project-settings.html +15 -2
- package/modules/hall-ui/public/project-settings.js +142 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/blockers.js +26 -0
- package/modules/lifecycle/db-claims.js +22 -0
- package/modules/lifecycle/db-ship.js +5 -5
- package/modules/lifecycle/funding-wall.js +253 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +9 -0
- package/modules/lifecycle/routes/pay-type-gates.js +7 -0
- package/modules/lifecycle/routes/project-settings.js +59 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +44 -0
- package/scripts/bongos/government-walk.js +1 -0
- package/scripts/bongos/plain-cards.js +18 -0
- package/scripts/bongos/run-unit-tests.js +28 -0
- package/src/bongos/builder-payouts.js +6 -1
- package/src/bongos/cash-outs.js +399 -0
- package/src/bongos/module-payouts.js +432 -0
- package/src/bongos/package-registry.js +3 -2
- package/src/bongos/payout-approvals.js +5 -2
- package/src/bongos/payout-gates.js +44 -7
- package/src/bongos/project-settings.js +2 -0
- package/src/bongos/routes/core-update.js +11 -3
- package/src/bongos/routes/module-payouts.js +114 -0
- package/src/bongos/routes/my-cash-outs.js +103 -0
- package/src/bongos/routes/payout-gates.js +42 -1
- package/src/bongos/routes.js +6 -0
- package/src/bongos/software-update.js +36 -16
- package/src/module-api.js +1 -1
- package/tests/builder_payouts_db.mjs +1 -0
- package/tests/cash_outs.mjs +96 -0
- package/tests/cash_outs_db.mjs +205 -0
- package/tests/funded_project_pays_proof_db.mjs +181 -0
- package/tests/funding_wall.mjs +205 -0
- package/tests/funding_wall_db.mjs +174 -0
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/ledger_check.mjs +189 -0
- package/tests/ledger_check_db.mjs +119 -0
- package/tests/module_payouts.mjs +295 -0
- package/tests/module_payouts_db.mjs +140 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/payout_gates.mjs +67 -6
- package/tests/payout_gates_db.mjs +23 -1
- package/tests/project_account_db.mjs +5 -2
- package/tests/project_outflow.mjs +103 -0
- package/tests/project_outflow_db.mjs +100 -0
- package/tests/software_update.mjs +61 -3
- package/tests/user_balances.mjs +241 -0
- package/tests/user_balances_db.mjs +137 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// tests/cash_outs_db.mjs — task 1004911 (goal 1000092 N7). Real DB.
|
|
2
|
+
//
|
|
3
|
+
// Applies the REAL core_283 (approval log), core_284 + core_291 (payout gates), economy_003
|
|
4
|
+
// (member accounts) and core_293 (cash-outs) migrations, then proves what Postgres itself
|
|
5
|
+
// refuses: a cash-out approval by someone other than the person it pays, a cash-out citing
|
|
6
|
+
// an owner's payout approval or another amount, two cash-outs on one approval, a try number
|
|
7
|
+
// the processor has not earned, an arrival nobody sent, a second payout on one try, anything
|
|
8
|
+
// after a cancellation or while a payout is out, and any rewrite. Then drives the real
|
|
9
|
+
// requestCashOut / retryCashOut / cancelCashOut / recordProcessorOutcome against a stand-in
|
|
10
|
+
// processor with test references only: held with no processor, sent once whatever the
|
|
11
|
+
// presses, a timeout retried on the SAME key, a refusal or a bank return retried on a new
|
|
12
|
+
// one, a gated person held with a hold that tells them, the minimum, one open at a time.
|
|
13
|
+
//
|
|
14
|
+
// Self-skips without a reachable Postgres.
|
|
15
|
+
// Run: DATABASE_URL=... node tests/cash_outs_db.mjs
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const { Pool } = require('pg');
|
|
23
|
+
const C = require('../src/bongos/cash-outs.js');
|
|
24
|
+
const approvals = require('../src/bongos/payout-approvals.js');
|
|
25
|
+
const gates = require('../src/bongos/payout-gates.js');
|
|
26
|
+
const { memberAccount } = require('../modules/economy/user-balances.js');
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const mig = (...p) => readFileSync(join(here, '..', ...p), 'utf8');
|
|
29
|
+
|
|
30
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
31
|
+
let client;
|
|
32
|
+
try { client = await pgPool.connect(); } catch (e) {
|
|
33
|
+
console.log(`cash_outs_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
34
|
+
await pgPool.end().catch(() => {});
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
const SCHEMA = `t1004911_${process.pid}`;
|
|
38
|
+
let pass = 0;
|
|
39
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
40
|
+
const refused = async (sql, params, m) => {
|
|
41
|
+
await client.query('SAVEPOINT s');
|
|
42
|
+
let err = null;
|
|
43
|
+
try { await client.query(sql, params); } catch (e) { err = e; }
|
|
44
|
+
await client.query('ROLLBACK TO SAVEPOINT s');
|
|
45
|
+
ok(err !== null, m);
|
|
46
|
+
};
|
|
47
|
+
const refusal = async (fn) => { try { await fn(); } catch (e) { return e; } return null; };
|
|
48
|
+
|
|
49
|
+
// 1 owner · 2 Ada (adult, cleared) · 3 Bo (no age recorded) · 4 a machine principal
|
|
50
|
+
const actor = (id) => ({ id, rank: 'thetes', session_id: 900 + Number(id), session_source: 'web' });
|
|
51
|
+
const deps = (extra = {}) => ({ pool: client, authority: approvals, account: { memberAccount }, ...extra });
|
|
52
|
+
|
|
53
|
+
// A stand-in processor with test references. It is idempotent on the key, as the real one is.
|
|
54
|
+
function fakeProcessor() {
|
|
55
|
+
const calls = [];
|
|
56
|
+
const made = new Map();
|
|
57
|
+
let next = null;
|
|
58
|
+
return {
|
|
59
|
+
calls,
|
|
60
|
+
failNext(err) { next = err; },
|
|
61
|
+
memberBalanceMicro: async () => 500 * 10_000 * 100, // $500.00
|
|
62
|
+
memberPayout: async (req) => {
|
|
63
|
+
calls.push(req);
|
|
64
|
+
if (next) { const e = next; next = null; throw e; }
|
|
65
|
+
if (!made.has(req.idempotencyKey)) made.set(req.idempotencyKey, `po_test_${made.size + 1}`);
|
|
66
|
+
return { ref: made.get(req.idempotencyKey) };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
73
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
74
|
+
await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, display_name text, system_role text)');
|
|
75
|
+
await client.query('CREATE TABLE schema_migrations (version text PRIMARY KEY)');
|
|
76
|
+
for (const m of ['core_283_payout_approvals.sql', 'core_284_payout_gates.sql', 'core_291_payout_nda_tax.sql']) await client.query(mig('migrations', m));
|
|
77
|
+
await client.query(mig('modules', 'economy', 'migrations', 'economy_003_user_balances.sql'));
|
|
78
|
+
await client.query(mig('migrations', 'core_293_cash_outs.sql'));
|
|
79
|
+
await client.query(mig('migrations', 'core_293_cash_outs.sql'));
|
|
80
|
+
ok(true, 'core_293 applies twice (idempotent)');
|
|
81
|
+
|
|
82
|
+
await client.query("INSERT INTO builders (display_name, system_role) VALUES ('Owner', NULL), ('Ada', NULL), ('Bo', NULL), ('bot', 'agent')");
|
|
83
|
+
await client.query("INSERT INTO economy_member_accounts (builder_id, processor_account) VALUES (2, 'acct_test_ada'), (3, 'acct_test_bo')");
|
|
84
|
+
const o = { pool: client };
|
|
85
|
+
await gates.recordAge({ builderId: 2, tier: 'adult', basis: 'verified_by_person', recordedBy: 1 }, o);
|
|
86
|
+
await gates.recordScreening({ builderId: 2, result: 'clear', screenedBy: 'person:owner', validUntil: new Date(Date.now() + 86_400_000).toISOString(), recordedBy: 1 }, o);
|
|
87
|
+
await gates.recordNda({ builderId: 2, status: 'on_file', ndaVersion: 'beta-v1', reference: 'envelope-test-1', recordedBy: 1 }, o);
|
|
88
|
+
await gates.recordTaxDetails({ builderId: 2, status: 'collected', collectedBy: 'processor:test', reference: 'kyc-test-1', recordedBy: 1 }, o);
|
|
89
|
+
ok((await gates.gateRecipient(2, o)).ok === true, 'Ada passes the payout gates');
|
|
90
|
+
await client.query('BEGIN');
|
|
91
|
+
|
|
92
|
+
// ── what the database refuses ────────────────────────────────────────────
|
|
93
|
+
const APPROVE = `INSERT INTO payout_approvals (kind, actor_id, session_id, session_source, recipient_id, amount_cents, currency)
|
|
94
|
+
VALUES ($1,$2,901,'web',$3,$4,'USD') RETURNING id`;
|
|
95
|
+
await refused(APPROVE, ['cashout_approved', 1, 2, 2000], 'a cash-out approval by anyone but the person it pays is refused');
|
|
96
|
+
await refused("INSERT INTO payout_approvals (kind, actor_id, session_id, session_source, recipient_id, amount_cents, currency) VALUES ('cashout_approved', 2, 1, 'cli', 2, 2000, 'USD')", [], 'a cash-out approval from a CLI session is refused');
|
|
97
|
+
await refused(APPROVE, ['cashout_approved', 4, 4, 2000], 'a machine principal cannot approve its own cash-out');
|
|
98
|
+
const ownerPay = (await client.query(APPROVE, ['payout_approved', 1, 2, 2000])).rows[0].id;
|
|
99
|
+
const mine = (await client.query(APPROVE, ['cashout_approved', 2, 2, 2000])).rows[0].id;
|
|
100
|
+
const CO = `INSERT INTO cash_outs (builder_id, approval_id, from_account, amount_cents, currency, instruction_id)
|
|
101
|
+
VALUES ($1,$2,'acct_test_ada',$3,'USD',$4) RETURNING id`;
|
|
102
|
+
await refused(CO, [2, ownerPay, 2000, 'press-owner-0001'], "an owner's payout approval cannot be spent as a cash-out");
|
|
103
|
+
await refused(CO, [2, mine, 2500, 'press-amount-001'], 'a cash-out for another amount than its approval is refused');
|
|
104
|
+
await refused(CO, [3, mine, 2000, 'press-other-0001'], "someone else's approval cannot be spent");
|
|
105
|
+
const co = (await client.query(CO, [2, mine, 2000, 'press-raw-00001'])).rows[0].id;
|
|
106
|
+
await refused(CO, [2, mine, 2000, 'press-raw-00002'], 'one approval pays one cash-out');
|
|
107
|
+
const TRY = 'INSERT INTO cash_out_attempts (cash_out_id, try_no, outcome, processor_ref, reason) VALUES ($1,$2,$3,$4,$5)';
|
|
108
|
+
await refused(TRY, [co, 2, 'sent', 'po_raw_1', null], 'a try number the processor has not earned is refused');
|
|
109
|
+
await refused(TRY, [co, 1, 'paid', 'po_raw_1', null], 'an arrival nobody sent is refused');
|
|
110
|
+
await refused(TRY, [co, 1, 'failed', null, null], 'a failure with no reason is refused');
|
|
111
|
+
await client.query(TRY, [co, 1, 'sent', 'po_raw_1', null]);
|
|
112
|
+
await refused(TRY, [co, 1, 'sent', 'po_raw_2', null], 'a second payout on one try is refused');
|
|
113
|
+
await refused(TRY, [co, 1, 'held', null, 'x'], 'nothing but arrival or return follows a sent payout');
|
|
114
|
+
await refused(TRY, [co, 1, 'cancelled', null, 'x'], 'a sent payout cannot be cancelled');
|
|
115
|
+
await refused(TRY, [co, 1, 'paid', 'po_raw_9', null], 'only the payout that was sent can arrive');
|
|
116
|
+
await client.query(TRY, [co, 1, 'returned', 'po_raw_1', 'account_closed']);
|
|
117
|
+
await client.query(TRY, [co, 2, 'cancelled', null, 'cancelled by the person']);
|
|
118
|
+
await refused(TRY, [co, 2, 'held', null, 'x'], 'nothing follows a cancellation');
|
|
119
|
+
await refused('UPDATE cash_outs SET amount_cents = 1 WHERE id = $1', [co], 'a cash-out cannot be edited');
|
|
120
|
+
await refused('DELETE FROM cash_out_attempts WHERE cash_out_id = $1', [co], 'an attempt cannot be deleted');
|
|
121
|
+
await refused('TRUNCATE cash_outs CASCADE', [], 'cash-outs cannot be truncated');
|
|
122
|
+
|
|
123
|
+
// ── the real flow ────────────────────────────────────────────────────────
|
|
124
|
+
let e = await refusal(() => C.requestCashOut({ builderId: '2', actor: actor(2), amountCents: 999, instructionId: 'press-min-00001' }, deps()));
|
|
125
|
+
ok(e && e.code === 'below_minimum' && e.details.minimum_cents === C.MIN_CASH_OUT_CENTS, 'below the minimum is refused, naming the minimum');
|
|
126
|
+
e = await refusal(() => C.requestCashOut({ builderId: '2', actor: { ...actor(2), session_source: 'cli' }, amountCents: 2000, instructionId: 'press-cli-00001' }, deps()));
|
|
127
|
+
ok(e && e.code === 'personal_session_required', 'a CLI session cannot take money out');
|
|
128
|
+
e = await refusal(() => C.requestCashOut({ builderId: '2', actor: actor(1), amountCents: 2000, instructionId: 'press-own-00001' }, deps()));
|
|
129
|
+
ok(e && e.code === 'personal_session_required', 'nobody can cash out for someone else');
|
|
130
|
+
|
|
131
|
+
let r = await C.requestCashOut({ builderId: '2', actor: actor(2), amountCents: 2500, instructionId: 'press-ada-00001' }, deps());
|
|
132
|
+
ok(r.status === 'held' && r.reason === 'no_processor' && r.cash_out.still_yours && r.cash_out.can_retry, 'with no processor it is recorded, held, and still yours');
|
|
133
|
+
const first = r.cash_out.id;
|
|
134
|
+
const again = await C.requestCashOut({ builderId: '2', actor: actor(2), amountCents: 2500, instructionId: 'press-ada-00001' }, deps());
|
|
135
|
+
ok(again.cash_out.id === first, 'pressing again with the same instruction is the same cash-out');
|
|
136
|
+
e = await refusal(() => C.requestCashOut({ builderId: '2', actor: actor(2), amountCents: 3000, instructionId: 'press-ada-00001' }, deps()));
|
|
137
|
+
ok(e && e.code === 'instruction_reused', 'the same instruction for another amount is refused');
|
|
138
|
+
e = await refusal(() => C.requestCashOut({ builderId: '2', actor: actor(2), amountCents: 2000, instructionId: 'press-ada-00002' }, deps()));
|
|
139
|
+
ok(e && e.code === 'cash_out_open' && e.details.cash_out_id === first, 'a second cash-out while one is open is refused, naming the open one');
|
|
140
|
+
|
|
141
|
+
const p = fakeProcessor();
|
|
142
|
+
e = await refusal(() => C.requestCashOut({ builderId: '2', actor: actor(2), amountCents: 2000, instructionId: 'press-ada-00003' }, deps({ processor: { ...p, memberBalanceMicro: async () => 1000 * 10_000 } })));
|
|
143
|
+
ok(e && e.code === 'over_balance', 'more than the processor says they have is refused');
|
|
144
|
+
|
|
145
|
+
r = await C.retryCashOut({ builderId: '2', cashOutId: first }, deps({ processor: p }));
|
|
146
|
+
ok(r.status === 'sent' && p.calls.length === 1 && p.calls[0].idempotencyKey === C.idempotencyKey(first, 1) && p.calls[0].account === 'acct_test_ada', 'retry sends from their own account with the try-1 key');
|
|
147
|
+
ok(p.calls[0].payTo === 'self' && p.calls[0].amountCents === 2500, 'it pays the person themself the approved amount');
|
|
148
|
+
r = await C.retryCashOut({ builderId: '2', cashOutId: first }, deps({ processor: p }));
|
|
149
|
+
ok(r.status === 'sent' && r.already && p.calls.length === 1, 'a second press on a sent cash-out sends nothing');
|
|
150
|
+
e = await refusal(() => C.cancelCashOut({ builderId: '2', cashOutId: first }, deps()));
|
|
151
|
+
ok(e && e.code === 'cannot_cancel', 'a sent cash-out cannot be cancelled');
|
|
152
|
+
e = await refusal(() => C.retryCashOut({ builderId: '3', cashOutId: first }, deps({ processor: p })));
|
|
153
|
+
ok(e && e.code === 'no_such_cash_out' && e.status === 404, "someone else's cash-out is not found");
|
|
154
|
+
|
|
155
|
+
ok((await C.recordProcessorOutcome({ processorRef: 'po_test_1', outcome: 'returned', reason: 'account_closed' }, o)).status === 'returned', 'the bank returns it');
|
|
156
|
+
ok((await C.recordProcessorOutcome({ processorRef: 'po_test_1', outcome: 'returned', reason: 'account_closed' }, o)).already, 'the same news twice is a no-op');
|
|
157
|
+
ok(await C.recordProcessorOutcome({ processorRef: 'po_never', outcome: 'paid' }, o) === null, 'news of a payout we never sent is ignored');
|
|
158
|
+
let list = await C.listMyCashOuts(2, deps({ processor: p }));
|
|
159
|
+
ok(list.cash_outs[0].status === 'returned' && list.cash_outs[0].still_yours && list.minimum_cents === C.MIN_CASH_OUT_CENTS, 'a returned cash-out stays on the list as still theirs');
|
|
160
|
+
r = await C.retryCashOut({ builderId: '2', cashOutId: first }, deps({ processor: p }));
|
|
161
|
+
ok(r.status === 'sent' && p.calls[1].idempotencyKey === C.idempotencyKey(first, 2), 'after a return the retry uses a NEW key');
|
|
162
|
+
await C.recordProcessorOutcome({ processorRef: r.processor_ref, outcome: 'paid' }, o);
|
|
163
|
+
list = await C.listMyCashOuts(2, deps({ processor: p }));
|
|
164
|
+
ok(list.cash_outs[0].status === 'paid' && !list.cash_outs[0].still_yours && list.cash_outs[0].tries === 2, 'it arrives on try 2');
|
|
165
|
+
|
|
166
|
+
// a timeout may have paid: the retry keeps the key, so the processor hands back the same payout
|
|
167
|
+
p.failNext(Object.assign(new Error('the processor did not answer in time'), { code: 'timeout' }));
|
|
168
|
+
r = await C.requestCashOut({ builderId: '2', actor: actor(2), amountCents: 1500, instructionId: 'press-ada-00004' }, deps({ processor: p }));
|
|
169
|
+
ok(r.status === 'failed' && r.cash_out.can_retry, 'a timeout is recorded as failed, open for retry');
|
|
170
|
+
const second = r.cash_out.id;
|
|
171
|
+
r = await C.retryCashOut({ builderId: '2', cashOutId: second }, deps({ processor: p }));
|
|
172
|
+
ok(r.status === 'sent' && p.calls[2].idempotencyKey === p.calls[3].idempotencyKey, 'the retry after a timeout uses the SAME key');
|
|
173
|
+
await C.recordProcessorOutcome({ processorRef: r.processor_ref, outcome: 'paid' }, o);
|
|
174
|
+
|
|
175
|
+
// a definite refusal made nothing: the next try gets a new key
|
|
176
|
+
p.failNext(Object.assign(new Error('insufficient funds'), { code: 'balance_insufficient', refused: true }));
|
|
177
|
+
r = await C.requestCashOut({ builderId: '2', actor: actor(2), amountCents: 1200, instructionId: 'press-ada-00005' }, deps({ processor: p }));
|
|
178
|
+
ok(r.status === 'refused' && /insufficient/.test(r.reason), 'a processor refusal is recorded with its reason');
|
|
179
|
+
const third = r.cash_out.id;
|
|
180
|
+
r = await C.cancelCashOut({ builderId: '2', cashOutId: third }, deps());
|
|
181
|
+
ok(r.status === 'cancelled' && !r.cash_out.can_retry, 'the person cancels a refused cash-out');
|
|
182
|
+
r = await C.retryCashOut({ builderId: '2', cashOutId: third }, deps({ processor: p }));
|
|
183
|
+
ok(r.status === 'cancelled' && r.already && p.calls.length === 5, 'a cancelled cash-out is never sent');
|
|
184
|
+
|
|
185
|
+
// a gated person: held, with a hold that tells them and what is theirs
|
|
186
|
+
r = await C.requestCashOut({ builderId: '3', actor: actor(3), amountCents: 2000, instructionId: 'press-bo-000001' }, deps({ processor: p }));
|
|
187
|
+
ok(r.status === 'held' && /^recipient_gated/.test(r.reason) && r.freeze_id && p.calls.length === 5, 'a person who fails the gates is held and nothing is sent');
|
|
188
|
+
const hold = await gates.readFreeze(r.freeze_id, o);
|
|
189
|
+
ok(hold.reason === 'age_unknown' && hold.owed_cents === 2000 && hold.notice_text, 'the hold tells them why, with the amount');
|
|
190
|
+
ok(r.cash_out.words === C.HELD_WORDS.recipient_gated, 'the cash-out says so in plain words');
|
|
191
|
+
|
|
192
|
+
const log = await approvals.listLog({ pool: client });
|
|
193
|
+
ok(log.filter((x) => x.kind === 'cashout_approved' && x.actor_id === '2' && x.recipient_id === '2').length === 4, "each of Ada's cash-outs is her own logged approval");
|
|
194
|
+
|
|
195
|
+
await client.query('ROLLBACK');
|
|
196
|
+
console.log(`\ncash_outs_db.mjs: ${pass} passed`);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
199
|
+
console.error(err);
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
} finally {
|
|
202
|
+
await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
203
|
+
client.release();
|
|
204
|
+
await pgPool.end();
|
|
205
|
+
}
|
|
@@ -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
|
+
});
|