@bongos/core 1.24.22 → 1.24.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +218 -63
- 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/modules/onboarding/founding-birth.js +5 -2
- package/modules/onboarding/founding-mode.js +10 -0
- package/modules/onboarding/genesis-home.js +3 -0
- package/modules/provisioning/birth.js +11 -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/payout-approvals.js +5 -2
- package/src/bongos/payout-gates.js +44 -7
- package/src/bongos/project-settings.js +2 -0
- package/src/bongos/routes/module-payouts.js +114 -0
- package/src/bongos/routes/my-cash-outs.js +103 -0
- package/src/bongos/routes/payout-gates.js +42 -1
- package/src/bongos/routes.js +6 -0
- package/src/branding.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/builder_payouts_db.mjs +1 -0
- package/tests/cash_outs.mjs +96 -0
- package/tests/cash_outs_db.mjs +205 -0
- package/tests/founding_birth.mjs +52 -12
- package/tests/founding_mode.mjs +5 -5
- 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/genesis_home.mjs +1 -1
- 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/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_settings_env.mjs +7 -3
- package/tests/user_balances.mjs +241 -0
- package/tests/user_balances_db.mjs +137 -0
package/tests/founding_birth.mjs
CHANGED
|
@@ -21,8 +21,8 @@ const birth = require('../modules/onboarding/founding-birth.js');
|
|
|
21
21
|
const gh = require('../modules/onboarding/genesis-home.js');
|
|
22
22
|
const fm = require('../modules/onboarding/founding-mode.js');
|
|
23
23
|
|
|
24
|
-
const BORN = { born: true, demo: null };
|
|
25
|
-
const BORN_DEMO = { born: true, demo: { people: 3, hours_each: 4 } };
|
|
24
|
+
const BORN = { born: true, demo: null, genesis: true };
|
|
25
|
+
const BORN_DEMO = { born: true, demo: { people: 3, hours_each: 4 }, genesis: true };
|
|
26
26
|
|
|
27
27
|
function world({ marker = BORN, settings = {}, withLifecycle = true } = {}) {
|
|
28
28
|
const store = new Map(Object.entries(settings));
|
|
@@ -43,19 +43,59 @@ function world({ marker = BORN, settings = {}, withLifecycle = true } = {}) {
|
|
|
43
43
|
|
|
44
44
|
// ── the marker ───────────────────────────────────────────────────────────────
|
|
45
45
|
|
|
46
|
-
test('the marker: born only with a readable birth stamp; a demo box only inside its limits', () => {
|
|
47
|
-
|
|
48
|
-
assert.deepEqual(birth.birthMarker(
|
|
49
|
-
assert.deepEqual(birth.birthMarker({
|
|
50
|
-
assert.deepEqual(birth.birthMarker({ birth: '
|
|
51
|
-
assert.deepEqual(birth.birthMarker({ birth:
|
|
52
|
-
|
|
53
|
-
assert.deepEqual(birth.birthMarker({ birth:
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
test('the marker: born only with a readable birth stamp; a demo box only inside its limits; genesis only when stamped', () => {
|
|
47
|
+
const AT = '2026-10-01T00:00:00.000Z';
|
|
48
|
+
assert.deepEqual(birth.birthMarker(undefined), { born: false, demo: null, genesis: false });
|
|
49
|
+
assert.deepEqual(birth.birthMarker({}), { born: false, demo: null, genesis: false });
|
|
50
|
+
assert.deepEqual(birth.birthMarker({ birth: 'whenever' }), { born: false, demo: null, genesis: false });
|
|
51
|
+
assert.deepEqual(birth.birthMarker({ birth: AT }), { born: true, demo: null, genesis: false }, 'a birth alone (every project born before the pause) never founds');
|
|
52
|
+
assert.deepEqual(birth.birthMarker({ birth: AT, genesis: 'on' }), { born: true, demo: null, genesis: true });
|
|
53
|
+
assert.deepEqual(birth.birthMarker({ birth: AT, genesis: 'yes' }), { born: true, demo: null, genesis: false }, 'only the stamped spelling counts');
|
|
54
|
+
assert.deepEqual(birth.birthMarker({ birth: AT, genesis: 'on', demoPeople: '3', demoHours: '4' }),
|
|
55
|
+
{ born: true, demo: { people: 3, hours_each: 4 }, genesis: true });
|
|
56
|
+
assert.deepEqual(birth.birthMarker({ birth: AT, genesis: 'on', demoPeople: '99', demoHours: '4' }),
|
|
57
|
+
{ born: true, demo: null, genesis: true }, 'a malformed box still founds, with the ordinary plan');
|
|
58
|
+
assert.deepEqual(birth.birthMarker({ demoPeople: '3', demoHours: '4' }), { born: false, demo: null, genesis: false },
|
|
56
59
|
'a demo box without a birth is not a birth');
|
|
57
60
|
});
|
|
58
61
|
|
|
62
|
+
test('genesis is paused: a birth without the genesis stamp starts nothing and writes nothing', async () => {
|
|
63
|
+
const w = world({ marker: { born: true, demo: null, genesis: false } });
|
|
64
|
+
const r = await birth.bornFounding(w.deps());
|
|
65
|
+
assert.deepEqual(r, { ran: false, reason: 'genesis_paused' });
|
|
66
|
+
assert.deepEqual(w.writes, [], 'no setting is touched');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the switch: founding.active alone reads as not founding; the switch makes it read', async () => {
|
|
70
|
+
const store = new Map([[fm.KEYS.ACTIVE, 'true'], [fm.KEYS.STAGE, 'charter']]);
|
|
71
|
+
const getSetting = async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null);
|
|
72
|
+
assert.equal(await fm.foundingModeFor({ getSetting, government: null }), null, 'a project already mid-founding opens as the full hall');
|
|
73
|
+
store.set(fm.KEYS.ENABLED, 'true');
|
|
74
|
+
assert.equal((await fm.foundingModeFor({ getSetting, government: null })).stage, 'charter');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('an owner starting genesis turns the switch on, so a started genesis is never invisible', async () => {
|
|
78
|
+
const w = world();
|
|
79
|
+
const r = await gh.startFounding('7', w.deps());
|
|
80
|
+
assert.equal(r.ok, true);
|
|
81
|
+
assert.equal(w.store.get(fm.KEYS.ENABLED), 'true');
|
|
82
|
+
assert.equal(r.mode.active, true, 'the mode it answers with reads, not null');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a paused project costs ONE settings read, not four', async () => {
|
|
86
|
+
let reads = 0;
|
|
87
|
+
const getSetting = async () => { reads += 1; return null; };
|
|
88
|
+
assert.equal(await fm.foundingModeFor({ getSetting }), null);
|
|
89
|
+
assert.equal(reads, 1, 'the switch is asked first and alone');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a birth with genesis turns the switch on, once, before founding starts', async () => {
|
|
93
|
+
const w = world();
|
|
94
|
+
await birth.bornFounding(w.deps());
|
|
95
|
+
assert.equal(w.store.get(fm.KEYS.ENABLED), 'true');
|
|
96
|
+
assert.ok(w.writes.indexOf(fm.KEYS.ENABLED) < w.writes.indexOf(fm.KEYS.ACTIVE), 'the switch is on before founding is');
|
|
97
|
+
});
|
|
98
|
+
|
|
59
99
|
// ── the birth hook ───────────────────────────────────────────────────────────
|
|
60
100
|
|
|
61
101
|
test('no marker: nothing runs, and the database is never read (a pre-existing hall is unchanged)', async () => {
|
package/tests/founding_mode.mjs
CHANGED
|
@@ -70,7 +70,7 @@ test('foundingModeFor fails closed when the settings read throws', async () => {
|
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
test('setFoundingMode refuses an unknown stage and stamps started_at once', async () => {
|
|
73
|
-
const store = new Map();
|
|
73
|
+
const store = new Map([[fm.KEYS.ENABLED, 'true']]);
|
|
74
74
|
const deps = {
|
|
75
75
|
getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
|
|
76
76
|
setSetting: async (k, v) => { store.set(k, String(v)); return { key: k, value: String(v) }; },
|
|
@@ -227,7 +227,7 @@ test('the band escapes what it prints — a project name cannot inject markup',
|
|
|
227
227
|
|
|
228
228
|
test('who the stage close is waiting on shows in the band when the server sends it', async () => {
|
|
229
229
|
const f = await fm.foundingModeFor({
|
|
230
|
-
getSetting: async (k) => ({ [fm.KEYS.ACTIVE]: { value: 'true' }, [fm.KEYS.STAGE]: { value: 'charter' } }[k] || null),
|
|
230
|
+
getSetting: async (k) => ({ [fm.KEYS.ENABLED]: { value: 'true' }, [fm.KEYS.ACTIVE]: { value: 'true' }, [fm.KEYS.STAGE]: { value: 'charter' } }[k] || null),
|
|
231
231
|
waitingOnFor: async (stage) => (stage === 'charter' ? ['Avery', 'Jordan'] : []),
|
|
232
232
|
});
|
|
233
233
|
assert.deepEqual(f.waiting_on, ['Avery', 'Jordan']);
|
|
@@ -245,20 +245,20 @@ test('waiting-on reads the government port\'s genesis-stage status, sitting stag
|
|
|
245
245
|
] }),
|
|
246
246
|
};
|
|
247
247
|
const read = (stage) => fm.foundingModeFor({
|
|
248
|
-
getSetting: async (k) => ({ [fm.KEYS.ACTIVE]: { value: 'true' }, [fm.KEYS.STAGE]: { value: stage } }[k] || null),
|
|
248
|
+
getSetting: async (k) => ({ [fm.KEYS.ENABLED]: { value: 'true' }, [fm.KEYS.ACTIVE]: { value: 'true' }, [fm.KEYS.STAGE]: { value: stage } }[k] || null),
|
|
249
249
|
government: gov,
|
|
250
250
|
});
|
|
251
251
|
assert.deepEqual((await read('charter')).waiting_on, ['Avery', 'jordan']);
|
|
252
252
|
assert.equal((await read('scope')).waiting_on, null, 'a passed stage waits on nobody');
|
|
253
253
|
assert.equal(fm.waitingOnFromGovernment({}), null, 'a port without the read offers none');
|
|
254
254
|
assert.equal((await fm.foundingModeFor({
|
|
255
|
-
getSetting: async (k) => ({ [fm.KEYS.ACTIVE]: { value: 'true' } }[k] || null), government: null,
|
|
255
|
+
getSetting: async (k) => ({ [fm.KEYS.ENABLED]: { value: 'true' }, [fm.KEYS.ACTIVE]: { value: 'true' } }[k] || null), government: null,
|
|
256
256
|
})).waiting_on, null, 'no government module: the mode still reads, without names');
|
|
257
257
|
});
|
|
258
258
|
|
|
259
259
|
test('a failing waiting-on source never turns the mode off', async () => {
|
|
260
260
|
const f = await fm.foundingModeFor({
|
|
261
|
-
getSetting: async (k) => ({ [fm.KEYS.ACTIVE]: { value: 'true' }, [fm.KEYS.STAGE]: { value: 'scope' } }[k] || null),
|
|
261
|
+
getSetting: async (k) => ({ [fm.KEYS.ENABLED]: { value: 'true' }, [fm.KEYS.ACTIVE]: { value: 'true' }, [fm.KEYS.STAGE]: { value: 'scope' } }[k] || null),
|
|
262
262
|
waitingOnFor: async () => { throw new Error('government down'); },
|
|
263
263
|
});
|
|
264
264
|
assert.equal(f.active, true);
|
|
@@ -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
|
+
});
|