@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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// tests/funding_wall_db.mjs — task 1003185 (goal 1000092 M06; ADR 0386). Real DB.
|
|
2
|
+
//
|
|
3
|
+
// Applies the REAL project-account migration (economy_002) and pay-terms migration
|
|
4
|
+
// (lifecycle_018), then drives the real funding wall (modules/lifecycle/funding-wall.js)
|
|
5
|
+
// against the real account functions (modules/economy/project-account.js) and the real
|
|
6
|
+
// pay-terms read:
|
|
7
|
+
// - a funded claim books a `hold` row; an underfunded one is refused with the shortfall;
|
|
8
|
+
// - a task let go has its hold released by state (a `hold_release` row) at the next claim;
|
|
9
|
+
// - a shipped task keeps its hold until a payout is booked for it (task 1003186);
|
|
10
|
+
// - a task with no pay terms is not walled; the project's standing cash terms are;
|
|
11
|
+
// - the owner view's held figure counts only work in flight;
|
|
12
|
+
// - refuse_at_completion counts a task's own hold for it;
|
|
13
|
+
// - TWO SIMULTANEOUS CLAIMS cannot both hold the last of one balance: the second waits
|
|
14
|
+
// on the account lock and then sees the first one's hold.
|
|
15
|
+
//
|
|
16
|
+
// Self-skips without a reachable Postgres.
|
|
17
|
+
// Run: DATABASE_URL=... node tests/funding_wall_db.mjs
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const { Pool } = require('pg');
|
|
25
|
+
const A = require('../modules/economy/project-account.js');
|
|
26
|
+
const W = require('../modules/lifecycle/funding-wall.js');
|
|
27
|
+
const P = require('../modules/lifecycle/pay-terms.js');
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const sql = (...p) => readFileSync(join(here, '..', ...p), 'utf8');
|
|
30
|
+
// economy_004 (task 1004910) adds module_share_id, which recordMovement writes.
|
|
31
|
+
const ACCOUNT_MIGRATION = sql('modules', 'economy', 'migrations', 'economy_002_project_account.sql')
|
|
32
|
+
+ sql('modules', 'economy', 'migrations', 'economy_004_module_share_payouts.sql');
|
|
33
|
+
const HOLDS_INDEX_MIGRATION = sql('modules', 'economy', 'migrations', 'economy_005_project_account_holds_index.sql');
|
|
34
|
+
const TERMS_MIGRATION = sql('modules', 'lifecycle', 'migrations', 'lifecycle_018_pay_terms.sql');
|
|
35
|
+
|
|
36
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 3 });
|
|
37
|
+
let client;
|
|
38
|
+
try { client = await pgPool.connect(); } catch (e) {
|
|
39
|
+
console.log(`funding_wall_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
40
|
+
await pgPool.end().catch(() => {});
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
const SCHEMA = `t1003185_${process.pid}`;
|
|
44
|
+
const USD = 1_000_000;
|
|
45
|
+
let pass = 0;
|
|
46
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
47
|
+
const account = { lockAccount: A.lockAccount, releaseSettledHolds: A.releaseSettledHolds, readAvailable: A.readAvailable, recordMovement: A.recordMovement };
|
|
48
|
+
const rule = (mode) => ({ account, resolveFundingWall: async () => ({ mode, chosen: true }) });
|
|
49
|
+
const claim = (db, ids, mode = 'hold_at_claim', place = true) =>
|
|
50
|
+
W.fundingWallFailures({ client: db, tasks: ids.map((id) => ({ id })), place, deps: rule(mode) });
|
|
51
|
+
const setStatus = (db, id, status) => db.query('UPDATE tasks SET status = $2 WHERE id = $1', [id, status]);
|
|
52
|
+
const cash = (db, taskId, amount) => P.setPayTerms({ taskId, terms: [{ pay_type: 'cash', amount, unit: 'USD', note: null }], builderId: 1 }, { db });
|
|
53
|
+
const holdsFor = async (db, taskId) => (await db.query(
|
|
54
|
+
"SELECT kind, amount_micro::bigint AS a FROM economy_project_account_movements WHERE task_id = $1 AND kind IN ('hold','hold_release') ORDER BY id", [taskId])).rows
|
|
55
|
+
.map((r) => `${r.kind}:${Number(r.a) / USD}`);
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
59
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
60
|
+
await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, display_name text, github_login text)');
|
|
61
|
+
await client.query("CREATE TABLE tasks (id bigserial PRIMARY KEY, title text, status text NOT NULL DEFAULT 'ready')");
|
|
62
|
+
await client.query('CREATE TABLE schema_migrations (version text PRIMARY KEY)');
|
|
63
|
+
await client.query("INSERT INTO builders (display_name, github_login) VALUES ('Owner', 'owner')");
|
|
64
|
+
await client.query("INSERT INTO tasks (title) SELECT 'task ' || g FROM generate_series(1, 8) g");
|
|
65
|
+
await client.query(ACCOUNT_MIGRATION);
|
|
66
|
+
await client.query(TERMS_MIGRATION);
|
|
67
|
+
await client.query(HOLDS_INDEX_MIGRATION);
|
|
68
|
+
// schemaname: a migrated database has the same index in its own schema too (task 1003186)
|
|
69
|
+
ok((await client.query("SELECT 1 FROM pg_indexes WHERE schemaname = $1 AND indexname = 'economy_project_account_holds'", [SCHEMA])).rows.length === 1, 'the hold read has its partial index');
|
|
70
|
+
await A.recordMovement(client, { kind: 'funding', amountMicro: 300 * USD, processorRef: 'ch_seed' });
|
|
71
|
+
await cash(client, 1, '250');
|
|
72
|
+
await cash(client, 2, '100');
|
|
73
|
+
|
|
74
|
+
// 1. a funded claim holds its pay
|
|
75
|
+
await client.query('BEGIN');
|
|
76
|
+
ok((await claim(client, [1])).length === 0, 'a funded claim passes the wall');
|
|
77
|
+
await setStatus(client, 1, 'active');
|
|
78
|
+
await client.query('COMMIT');
|
|
79
|
+
ok((await holdsFor(client, 1)).join() === 'hold:250', 'the claim booked a hold for the task\'s cash pay');
|
|
80
|
+
|
|
81
|
+
// 2. the next claim sees it: the project is short and the refusal says by how much
|
|
82
|
+
await client.query('BEGIN');
|
|
83
|
+
const short = await claim(client, [2]);
|
|
84
|
+
await client.query('ROLLBACK');
|
|
85
|
+
ok(short.length === 1 && short[0].refusal.code === 'FUNDING_SHORTFALL', 'a claim the project cannot cover is refused');
|
|
86
|
+
ok(short[0].refusal.shortfall_micro === 50 * USD && /\$50\.00 short/.test(short[0].refusal.message), 'the refusal names the shortfall: $50.00');
|
|
87
|
+
ok(short[0].refusal.held_micro === 250 * USD, 'and what is already held');
|
|
88
|
+
|
|
89
|
+
// 3. a task with no pay terms is never walled
|
|
90
|
+
ok((await claim(client, [3])).length === 0, 'a task that pays no cash is not walled');
|
|
91
|
+
|
|
92
|
+
// 4. task 1 is let go: its hold is released BY STATE at the next claim, and that claim fits
|
|
93
|
+
await setStatus(client, 1, 'ready');
|
|
94
|
+
ok((await A.readAvailable(client)).held_micro === 0, 'a let-go task\'s hold no longer counts, even before its release row');
|
|
95
|
+
await client.query('BEGIN');
|
|
96
|
+
ok((await claim(client, [2])).length === 0, 'with the hold gone, the claim fits');
|
|
97
|
+
await setStatus(client, 2, 'active');
|
|
98
|
+
await client.query('COMMIT');
|
|
99
|
+
ok((await holdsFor(client, 1)).join() === 'hold:250,hold_release:250', 'the next claim wrote the hold_release row');
|
|
100
|
+
const view = await A.readAccount(client);
|
|
101
|
+
ok(view.held_micro === 100 * USD && view.available_micro === 200 * USD, 'the owner view shows only work in flight as held');
|
|
102
|
+
|
|
103
|
+
// 5. the project's standing cash terms price a task with none of its own
|
|
104
|
+
await P.setPayTerms({ taskId: null, terms: [{ pay_type: 'cash', amount: '$500', unit: null, note: null }], builderId: 1 }, { db: client });
|
|
105
|
+
const standing = await claim(client, [3], 'hold_at_claim', false);
|
|
106
|
+
ok(standing.length === 1 && standing[0].refusal.price_micro === 500 * USD, 'standing cash terms are walled too');
|
|
107
|
+
|
|
108
|
+
// 6. a batch is judged cumulatively in one transaction
|
|
109
|
+
await cash(client, 4, '150'); await cash(client, 5, '150');
|
|
110
|
+
await client.query('BEGIN');
|
|
111
|
+
const batch = await claim(client, [4, 5]);
|
|
112
|
+
await client.query('ROLLBACK');
|
|
113
|
+
ok(batch.length === 1 && batch[0].task_id === 5 && batch[0].refusal.shortfall_micro === 100 * USD, 'a batch cannot over-commit: the second task is $100 short');
|
|
114
|
+
ok((await holdsFor(client, 4)).length === 0, 'a refused batch, rolled back, left no hold');
|
|
115
|
+
|
|
116
|
+
// 7. refuse at completion: the task's own hold counts for it
|
|
117
|
+
await cash(client, 6, '200');
|
|
118
|
+
await client.query('BEGIN');
|
|
119
|
+
await claim(client, [6]);
|
|
120
|
+
await setStatus(client, 6, 'active');
|
|
121
|
+
await client.query('COMMIT');
|
|
122
|
+
ok((await A.readAvailable(client)).available_micro === 0, 'two holds take the whole balance');
|
|
123
|
+
await client.query('BEGIN');
|
|
124
|
+
await W.fundingWallOnResolve(client, 6, 'shipped', rule('refuse_at_completion'));
|
|
125
|
+
await client.query('ROLLBACK');
|
|
126
|
+
ok(true, 'a task held at claim can be marked done under refuse_at_completion: its own hold covers it');
|
|
127
|
+
await cash(client, 7, '50');
|
|
128
|
+
await setStatus(client, 7, 'active');
|
|
129
|
+
let refused = null;
|
|
130
|
+
await client.query('BEGIN');
|
|
131
|
+
try { await W.fundingWallOnResolve(client, 7, 'shipped', rule('refuse_at_completion')); } catch (e) { refused = e; }
|
|
132
|
+
await client.query('ROLLBACK');
|
|
133
|
+
ok(refused && refused.code === 'FUNDING_SHORTFALL_AT_COMPLETION' && refused.shortfall_micro === 50 * USD, 'an unheld task marked done while short is refused with the shortfall');
|
|
134
|
+
|
|
135
|
+
// 8. a shipped task keeps its hold until its builder is paid (task 1003186)
|
|
136
|
+
await setStatus(client, 2, 'shipped');
|
|
137
|
+
await setStatus(client, 6, 'shipped');
|
|
138
|
+
ok((await A.readAvailable(client)).held_micro === 300 * USD, 'shipped but unpaid, both holds still count');
|
|
139
|
+
ok((await A.releaseSettledHolds(client)).length === 0, 'and neither is released');
|
|
140
|
+
await A.recordMovement(client, { kind: 'payout', amountMicro: 100 * USD, builderId: 1, taskId: 2, processorRef: 'tr_2' });
|
|
141
|
+
ok((await A.readAvailable(client)).held_micro === 200 * USD, 'a payout booked for a task lets its hold go at once');
|
|
142
|
+
await A.recordMovement(client, { kind: 'payout', amountMicro: 200 * USD, builderId: 1, taskId: 6, processorRef: 'tr_6' });
|
|
143
|
+
ok((await A.releaseSettledHolds(client)).length === 2, 'and the next release writes both hold_release rows');
|
|
144
|
+
await A.recordMovement(client, { kind: 'funding', amountMicro: 300 * USD, processorRef: 'ch_refill' }); // balance 300, nothing in flight
|
|
145
|
+
|
|
146
|
+
// 9. two simultaneous claims on the last of one balance: one wins, one is told it is short
|
|
147
|
+
await cash(client, 8, '200');
|
|
148
|
+
const other = await pgPool.connect();
|
|
149
|
+
try {
|
|
150
|
+
await other.query(`SET search_path TO ${SCHEMA}`);
|
|
151
|
+
await client.query('BEGIN');
|
|
152
|
+
await other.query('BEGIN');
|
|
153
|
+
ok((await claim(client, [4])).length === 0, 'the first claim takes the lock and holds $150');
|
|
154
|
+
let second = null;
|
|
155
|
+
const pending = claim(other, [8]).then((f) => { second = f; });
|
|
156
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
157
|
+
ok(second === null, 'the second claim waits on the account lock');
|
|
158
|
+
await setStatus(client, 4, 'active');
|
|
159
|
+
await client.query('COMMIT');
|
|
160
|
+
await pending;
|
|
161
|
+
await other.query('ROLLBACK');
|
|
162
|
+
ok(second.length === 1 && second[0].refusal.code === 'FUNDING_SHORTFALL' && second[0].refusal.shortfall_micro === 50 * USD,
|
|
163
|
+
'then sees the first hold and is refused, $50 short — the balance was not committed twice');
|
|
164
|
+
} finally {
|
|
165
|
+
await other.query('ROLLBACK').catch(() => {});
|
|
166
|
+
other.release();
|
|
167
|
+
}
|
|
168
|
+
console.log(`funding_wall_db.mjs: ${pass} passed`);
|
|
169
|
+
} finally {
|
|
170
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
171
|
+
await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
172
|
+
client.release();
|
|
173
|
+
await pgPool.end().catch(() => {});
|
|
174
|
+
}
|
package/tests/genesis_home.mjs
CHANGED
|
@@ -146,7 +146,7 @@ test('a demo genesis reads in minutes: each stage due minutes after it began', (
|
|
|
146
146
|
// ── the flow, end to end over fakes ──────────────────────────────────────────
|
|
147
147
|
|
|
148
148
|
function world({ withGovernment = true } = {}) {
|
|
149
|
-
const store = new Map();
|
|
149
|
+
const store = new Map([['genesis.enabled', 'true']]);
|
|
150
150
|
const tasks = {};
|
|
151
151
|
const board = {};
|
|
152
152
|
let now = new Date('2026-10-01T00:00:00Z');
|
|
@@ -244,6 +244,7 @@ const REQUESTS = {
|
|
|
244
244
|
'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
|
|
245
245
|
'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
|
|
246
246
|
'PATCH /project-settings/pay-terms': ['PATCH', '/project-settings/pay-terms', { terms: 'not a list' }],
|
|
247
|
+
'PATCH /project-settings/funding-wall': ['PATCH', '/project-settings/funding-wall', { mode: 'sometimes' }],
|
|
247
248
|
'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
|
|
248
249
|
// task 1004941: the owner deciding a Market Watch proposal
|
|
249
250
|
'POST /market-watch/proposals/:id/decide': ['POST', `/market-watch/proposals/${BAD}/decide`, { decision: 'approve' }],
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// tests/ledger_check.mjs — the daily check of our record against the processor's (task
|
|
2
|
+
// 1004912, goal 1000092 N8; ADR 0397 decision 7). Behaviour of modules/economy/ledger-check.js:
|
|
3
|
+
// - with no processor connected, the check is recorded `unchecked` and raises nothing;
|
|
4
|
+
// - agreeing figures are `clean`; a balance difference, a movement only one side has, or one
|
|
5
|
+
// with two amounts is `drift`, each finding naming the reference and both amounts;
|
|
6
|
+
// - a processor that cannot be read is `error`, never clean;
|
|
7
|
+
// - a difference opens ONE blocker, with no amounts or references in it (the queue is
|
|
8
|
+
// public); the same set the next day is `unchanged`, a changed set or a set after a clean
|
|
9
|
+
// day is filed again;
|
|
10
|
+
// - a check runs once a day by the ledger, not on every tick.
|
|
11
|
+
// The SQL and the table's own rules are proven against real Postgres in tests/ledger_check_db.mjs.
|
|
12
|
+
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const L = require('../modules/economy/ledger-check.js');
|
|
19
|
+
|
|
20
|
+
const DAY = 24 * 60 * 60_000;
|
|
21
|
+
const NOW = Date.parse('2026-11-02T12:00:00Z');
|
|
22
|
+
const inWin = (h) => new Date(NOW - h * 60 * 60_000).toISOString();
|
|
23
|
+
|
|
24
|
+
// A store over plain arrays, recording what was inserted.
|
|
25
|
+
function fakeStore({ recorded = 0, ours = [], checks = [] } = {}) {
|
|
26
|
+
const saved = [];
|
|
27
|
+
return {
|
|
28
|
+
saved,
|
|
29
|
+
async recordedBalanceMicro() { return recorded; },
|
|
30
|
+
async ourMovements({ since, until, refs } = {}) {
|
|
31
|
+
return ours.filter((o) => (refs ? refs.includes(o.ref) : true)
|
|
32
|
+
&& (!since || Date.parse(o.created_at) >= since.getTime())
|
|
33
|
+
&& (!until || Date.parse(o.created_at) < until.getTime()));
|
|
34
|
+
},
|
|
35
|
+
async recentChecks() { return [...saved].reverse().concat(checks); },
|
|
36
|
+
async insertCheck(row) { const r = { ...row, id: saved.length + 1, created_at: new Date(NOW) }; saved.push(r); return r; },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const ourRow = (ref, micro, hoursAgo = 5, source = 'project_account:payout') => ({ ref, amount_micro: micro, created_at: inWin(hoursAgo), source });
|
|
40
|
+
const theirRow = (ref, micro, hoursAgo = 5) => ({ ref, amountMicro: micro, createdAt: inWin(hoursAgo) });
|
|
41
|
+
const processor = ({ balance, list }) => ({
|
|
42
|
+
balanceMicro: async () => { if (balance instanceof Error) throw balance; return balance; },
|
|
43
|
+
listMovements: async () => { if (list instanceof Error) throw list; return list; },
|
|
44
|
+
});
|
|
45
|
+
const run = (store, p, extra = {}) => L.runLedgerCheck({ store, processor: p, now: () => NOW, force: true, ...extra });
|
|
46
|
+
|
|
47
|
+
test('with no processor connected the check is recorded unchecked and raises nothing', async () => {
|
|
48
|
+
const store = fakeStore({ recorded: 5_000_000 });
|
|
49
|
+
let filed = 0;
|
|
50
|
+
const r = await run(store, null, { fileBlocker: async () => { filed++; return { id: 1 }; } });
|
|
51
|
+
assert.equal(r.outcome, 'unchecked');
|
|
52
|
+
assert.equal(store.saved[0].reason, 'no_processor');
|
|
53
|
+
assert.equal(store.saved[0].alarm, 'none');
|
|
54
|
+
assert.equal(store.saved[0].recorded_balance_micro, 5_000_000);
|
|
55
|
+
assert.equal(filed, 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('agreeing figures are clean', async () => {
|
|
59
|
+
const store = fakeStore({ recorded: 7_000_000, ours: [ourRow('tr_1', 2_000_000), ourRow('ch_1', 9_000_000, 30, 'project_account:funding')] });
|
|
60
|
+
const r = await run(store, processor({ balance: 7_000_000, list: [theirRow('tr_1', 2_000_000), theirRow('ch_1', 9_000_000, 30)] }));
|
|
61
|
+
assert.equal(r.outcome, 'clean');
|
|
62
|
+
assert.equal(store.saved[0].drift_micro, 0);
|
|
63
|
+
assert.equal(store.saved[0].refs_compared, 2);
|
|
64
|
+
assert.equal(store.saved[0].fingerprint, null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('every kind of difference is a finding naming the reference and both amounts', async () => {
|
|
68
|
+
const store = fakeStore({
|
|
69
|
+
recorded: 10_000_000,
|
|
70
|
+
ours: [ourRow('tr_ours_only', 1_000_000), ourRow('tr_amt', 3_000_000),
|
|
71
|
+
// a member funding the project is two of our records for one reference
|
|
72
|
+
ourRow('tr_twice', 4_000_000, 5, 'member_payment'), ourRow('tr_twice', 5_000_000, 5, 'project_account:funding')],
|
|
73
|
+
});
|
|
74
|
+
const r = await run(store, processor({
|
|
75
|
+
balance: 9_500_000,
|
|
76
|
+
list: [theirRow('tr_amt', 2_500_000), theirRow('tr_twice', 4_000_000), theirRow('po_theirs_only', 700_000)],
|
|
77
|
+
}), { fileBlocker: async () => ({ id: 41 }) });
|
|
78
|
+
assert.equal(r.outcome, 'drift');
|
|
79
|
+
const f = store.saved[0].findings;
|
|
80
|
+
const by = (kind) => f.filter((x) => x.kind === kind);
|
|
81
|
+
assert.deepEqual(by('balance')[0], { kind: 'balance', ref: null, ours_micro: 10_000_000, theirs_micro: 9_500_000 });
|
|
82
|
+
assert.equal(store.saved[0].drift_micro, -500_000);
|
|
83
|
+
assert.equal(by('missing_at_processor')[0].ref, 'tr_ours_only');
|
|
84
|
+
assert.equal(by('amount_differs')[0].ref, 'tr_amt');
|
|
85
|
+
assert.equal(by('amount_differs')[0].theirs_micro, 2_500_000);
|
|
86
|
+
assert.equal(by('unknown_to_us')[0].ref, 'po_theirs_only');
|
|
87
|
+
assert.equal(by('records_disagree')[0].ref, 'tr_twice');
|
|
88
|
+
for (const x of f) assert.ok(L.KIND_WORDS[x.kind], `${x.kind} has words for the owner`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a movement the processor lists but we booked outside the window is looked up, not reported unknown', async () => {
|
|
92
|
+
// ours is 8 days old (outside), theirs is timestamped inside the window
|
|
93
|
+
const store = fakeStore({ recorded: 0, ours: [ourRow('tr_old', 1_000_000, 8 * 24)] });
|
|
94
|
+
const r = await run(store, processor({ balance: 0, list: [theirRow('tr_old', 1_000_000, 10)] }));
|
|
95
|
+
assert.equal(r.outcome, 'clean');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the newest hour and the edges of the window are left alone', async () => {
|
|
99
|
+
const store = fakeStore({ recorded: 0, ours: [ourRow('tr_new', 1_000_000, 0.25)] });
|
|
100
|
+
// ours is too new to judge; theirs is too new too, and one is from before the window
|
|
101
|
+
const r = await run(store, processor({ balance: 0, list: [theirRow('tr_brand_new', 5, 0.1), theirRow('tr_ancient', 5, 8 * 24 + 2)] }));
|
|
102
|
+
assert.equal(r.outcome, 'clean');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a processor that cannot be read is an error, never clean, and is raised', async () => {
|
|
106
|
+
const store = fakeStore({ recorded: 0 });
|
|
107
|
+
let filed = null;
|
|
108
|
+
const r = await run(store, processor({ balance: new Error('stripe down'), list: [] }), { fileBlocker: async (b) => { filed = b; return { id: 7 }; } });
|
|
109
|
+
assert.equal(r.outcome, 'error');
|
|
110
|
+
assert.match(store.saved[0].reason, /unreadable: balance/);
|
|
111
|
+
assert.equal(store.saved[0].alarm, 'filed');
|
|
112
|
+
assert.match(filed.title, /could not be read/);
|
|
113
|
+
// A list that is not a list is unreadable too
|
|
114
|
+
const s2 = fakeStore({ recorded: 0 });
|
|
115
|
+
assert.equal((await run(s2, processor({ balance: 0, list: { nope: 1 } }))).outcome, 'error');
|
|
116
|
+
// a balance that is not a whole number is not taken as one
|
|
117
|
+
const s3 = fakeStore({ recorded: 0 });
|
|
118
|
+
assert.equal((await run(s3, { balanceMicro: async () => 'lots' })).outcome, 'error');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('the alarm is a public blocker with no amounts and no references in it', async () => {
|
|
122
|
+
const store = fakeStore({ recorded: 12_345_678, ours: [ourRow('tr_secret_ref', 1_234_567)] });
|
|
123
|
+
let filed = null;
|
|
124
|
+
await run(store, processor({ balance: 1, list: [] }), { fileBlocker: async (b) => { filed = b; return { id: 9 }; } });
|
|
125
|
+
assert.equal(filed.source, 'ledger-check');
|
|
126
|
+
assert.match(filed.sourceRef, /^ledger-check-[0-9a-f]{16}-2026-11-02$/);
|
|
127
|
+
const text = `${filed.title}\n${filed.bodyMd}`;
|
|
128
|
+
assert.ok(!/tr_secret_ref/.test(text), 'no processor reference');
|
|
129
|
+
assert.ok(!/12[.,]?345|1[.,]?234|\$/.test(text), 'no amount');
|
|
130
|
+
assert.match(text, /Project settings/);
|
|
131
|
+
assert.equal(store.saved[0].blocker_id, '9');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('the same differences are raised once; a changed set or a return after a clean day is raised again', async () => {
|
|
135
|
+
const store = fakeStore({ recorded: 100 });
|
|
136
|
+
let filed = 0;
|
|
137
|
+
const fileBlocker = async () => ({ id: ++filed });
|
|
138
|
+
const bad = processor({ balance: 90, list: [] });
|
|
139
|
+
assert.equal((await run(store, bad, { fileBlocker })).alarm, 'filed');
|
|
140
|
+
assert.equal((await run(store, bad, { fileBlocker })).alarm, 'unchanged');
|
|
141
|
+
// an unchecked day in between does not reset it
|
|
142
|
+
assert.equal((await run(store, null, { fileBlocker })).outcome, 'unchecked');
|
|
143
|
+
assert.equal((await run(store, bad, { fileBlocker })).alarm, 'unchanged');
|
|
144
|
+
assert.equal((await run(store, processor({ balance: 80, list: [] }), { fileBlocker })).alarm, 'filed');
|
|
145
|
+
assert.equal((await run(store, processor({ balance: 100, list: [] }), { fileBlocker })).outcome, 'clean');
|
|
146
|
+
assert.equal((await run(store, bad, { fileBlocker })).alarm, 'filed');
|
|
147
|
+
assert.equal(filed, 3);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('with nowhere to file, the alarm is undelivered and the next day tries again', async () => {
|
|
151
|
+
const store = fakeStore({ recorded: 100 });
|
|
152
|
+
const bad = processor({ balance: 90, list: [] });
|
|
153
|
+
assert.equal((await run(store, bad, { fileBlocker: null })).alarm, 'undelivered');
|
|
154
|
+
assert.equal((await run(store, bad, { fileBlocker: async () => { throw new Error('db'); } })).alarm, 'undelivered');
|
|
155
|
+
assert.equal((await run(store, bad, { fileBlocker: async () => ({ id: 3 }) })).alarm, 'filed');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a check runs once a day by the ledger, not on every tick', async () => {
|
|
159
|
+
const store = fakeStore({ checks: [{ outcome: 'unchecked', created_at: new Date(NOW - 3 * 60 * 60_000) }] });
|
|
160
|
+
const r = await L.runLedgerCheck({ store, processor: null, now: () => NOW });
|
|
161
|
+
assert.equal(r.skipped, 'not_due');
|
|
162
|
+
assert.equal(L.isDue([{ created_at: new Date(NOW - DAY + 20 * 60_000) }], NOW), true, 'within the half-hour slack');
|
|
163
|
+
assert.equal(L.isDue([], NOW), true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('the poller never throws and is a quiet no-op without a database', async () => {
|
|
167
|
+
const P = require('../modules/economy/pollers/ledger-check.js');
|
|
168
|
+
const r = await P.tick({ pool: { connect: async () => { throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }); } }, processor: null, fileBlocker: null });
|
|
169
|
+
assert.equal(r.skipped, 'db_unreachable');
|
|
170
|
+
const r2 = await P.tick({ pool: { connect: async () => { throw new Error('boom'); } }, processor: null, fileBlocker: null });
|
|
171
|
+
assert.equal(r2.skipped, 'error');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('the blocker port holds a server caller to the route length rules', async () => {
|
|
175
|
+
const { fileBlockerFromServer } = require('../modules/ideas/routes/blockers.js');
|
|
176
|
+
const ok = { title: 'Ledger check: x', bodyMd: 'y', source: 'ledger-check', sourceRef: 'ledger-check-abc-2026-11-02' };
|
|
177
|
+
const refused = (over) => { try { fileBlockerFromServer({ ...ok, ...over }); } catch (e) { return e.code; } return null; };
|
|
178
|
+
assert.equal(refused({ title: '' }), 'BAD_BLOCKER');
|
|
179
|
+
assert.equal(refused({ title: 'x'.repeat(201) }), 'BAD_BLOCKER');
|
|
180
|
+
assert.equal(refused({ source: undefined }), 'BAD_BLOCKER');
|
|
181
|
+
assert.equal(refused({ sourceRef: 'x'.repeat(257) }), 'BAD_BLOCKER');
|
|
182
|
+
assert.equal(refused({ bodyMd: 42 }), 'BAD_BLOCKER');
|
|
183
|
+
// the real alarm fits
|
|
184
|
+
let filed = null;
|
|
185
|
+
const store = { recordedBalanceMicro: async () => 1, ourMovements: async () => [], recentChecks: async () => [], insertCheck: async (r) => r };
|
|
186
|
+
await L.runLedgerCheck({ store, processor: { balanceMicro: async () => 2 }, now: () => NOW, force: true, fileBlocker: async (b) => { filed = b; return { id: 1 }; } });
|
|
187
|
+
assert.ok(filed.title.length <= 200 && filed.sourceRef.length <= 256 && filed.source.length <= 64);
|
|
188
|
+
assert.match(filed.bodyMd, /\(\/project-settings\)/, 'the alarm links straight to the numbers');
|
|
189
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// tests/ledger_check_db.mjs — task 1004912 (goal 1000092 N8). Real DB.
|
|
2
|
+
//
|
|
3
|
+
// Applies the REAL economy migrations (002 project account, 003 member payments, 004, 006 the
|
|
4
|
+
// ledger checks) beside a stand-in for core's cash-out tables, books one movement of each kind
|
|
5
|
+
// the check compares, and runs the real runLedgerCheck through its real store against a test
|
|
6
|
+
// processor: agreeing figures are clean, then a drift is found and filed once. Then proves what
|
|
7
|
+
// Postgres itself refuses: a check row edited or deleted, a drift with no fingerprint, a clean
|
|
8
|
+
// row that claims an alarm, and a filed alarm with no blocker.
|
|
9
|
+
//
|
|
10
|
+
// Self-skips without a reachable Postgres.
|
|
11
|
+
// Run: DATABASE_URL=... node tests/ledger_check_db.mjs
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const { Pool } = require('pg');
|
|
19
|
+
const L = require('../modules/economy/ledger-check.js');
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const MIGRATION = ['economy_002_project_account.sql', 'economy_003_user_balances.sql', 'economy_004_module_share_payouts.sql', 'economy_006_ledger_checks.sql']
|
|
22
|
+
.map((f) => readFileSync(join(here, '..', 'modules', 'economy', 'migrations', f), 'utf8')).join('\n');
|
|
23
|
+
|
|
24
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
25
|
+
let client;
|
|
26
|
+
try { client = await pgPool.connect(); await client.query('SELECT 1'); } catch (e) {
|
|
27
|
+
console.log(`ledger_check_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
28
|
+
await pgPool.end().catch(() => {});
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
const SCHEMA = `t1004912_${process.pid}`;
|
|
32
|
+
let pass = 0;
|
|
33
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
34
|
+
const rejects = async (sql, params) => {
|
|
35
|
+
await client.query('SAVEPOINT s');
|
|
36
|
+
let err = null;
|
|
37
|
+
try { await client.query(sql, params); } catch (e) { err = e; }
|
|
38
|
+
await client.query('ROLLBACK TO SAVEPOINT s');
|
|
39
|
+
return err;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
44
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
45
|
+
await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, display_name text)');
|
|
46
|
+
await client.query("CREATE TABLE tasks (id bigserial PRIMARY KEY, title text, status text NOT NULL DEFAULT 'shipped')");
|
|
47
|
+
await client.query('CREATE TABLE schema_migrations (version text PRIMARY KEY)');
|
|
48
|
+
// core's cash-out tables (migrations/core_293_cash_outs.sql), only the columns the check reads
|
|
49
|
+
await client.query('CREATE TABLE cash_outs (id bigserial PRIMARY KEY, amount_cents bigint NOT NULL)');
|
|
50
|
+
await client.query(`CREATE TABLE cash_out_attempts (id bigserial PRIMARY KEY, cash_out_id bigint NOT NULL REFERENCES cash_outs(id),
|
|
51
|
+
outcome text NOT NULL, processor_ref text, created_at timestamptz NOT NULL DEFAULT now())`);
|
|
52
|
+
await client.query("INSERT INTO builders (display_name) VALUES ('Ada'), ('Bo')");
|
|
53
|
+
await client.query("INSERT INTO tasks (title) VALUES ('Build the thing')");
|
|
54
|
+
await client.query(MIGRATION);
|
|
55
|
+
await client.query(readFileSync(join(here, '..', 'migrations', 'core_294_cash_out_attempts_sent_by_time.sql'), 'utf8').replace(/^\s*(BEGIN|COMMIT);\s*$/gm, ''));
|
|
56
|
+
await client.query('BEGIN');
|
|
57
|
+
|
|
58
|
+
const ago = (h) => new Date(Date.now() - h * 3600_000).toISOString();
|
|
59
|
+
const T = 'economy_project_account_movements';
|
|
60
|
+
await client.query(`INSERT INTO ${T} (kind, direction, amount_micro, processor_ref, created_at) VALUES ('funding','in',50000000,'ch_fund',$1)`, [ago(30)]);
|
|
61
|
+
await client.query(`INSERT INTO ${T} (kind, direction, amount_micro, builder_id, task_id, processor_ref, created_at) VALUES ('payout','out',20000000,1,1,'tr_pay',$1)`, [ago(20)]);
|
|
62
|
+
await client.query(`INSERT INTO ${T} (kind, direction, amount_micro, task_id, created_at) VALUES ('hold','none',1000000,1,$1)`, [ago(20)]);
|
|
63
|
+
await client.query(`INSERT INTO economy_user_transfers (kind, from_builder_id, to_builder_id, from_account, to_account, amount_cents, instruction_id)
|
|
64
|
+
VALUES ('pay_user', 1, 2, 'acct_a', 'acct_b', 300, 'instr-0001')`);
|
|
65
|
+
await client.query(`INSERT INTO economy_user_transfer_attempts (transfer_id, outcome, processor_ref, created_at) VALUES (1, 'sent', 'tr_member', $1)`, [ago(10)]);
|
|
66
|
+
await client.query('INSERT INTO cash_outs (amount_cents) VALUES (1500)');
|
|
67
|
+
await client.query(`INSERT INTO cash_out_attempts (cash_out_id, outcome, processor_ref, created_at) VALUES (1, 'sent', 'po_cash', $1)`, [ago(5)]);
|
|
68
|
+
// a held cash-out never reached the processor and is not compared
|
|
69
|
+
await client.query("INSERT INTO cash_out_attempts (cash_out_id, outcome) VALUES (1, 'held')");
|
|
70
|
+
|
|
71
|
+
const idx = (await client.query(`SELECT indexname FROM pg_indexes WHERE schemaname = $1`, [SCHEMA])).rows.map((r) => r.indexname);
|
|
72
|
+
ok(idx.includes('economy_user_transfer_attempts_sent_by_time') && idx.includes('cash_out_attempts_sent_by_time'),
|
|
73
|
+
"the week's sent member payments and cash-outs are read by a time index, not a scan of every attempt");
|
|
74
|
+
|
|
75
|
+
const store = L.createStore(client);
|
|
76
|
+
ok(await store.recordedBalanceMicro() === 30_000_000, 'the recorded balance is in less out, holds not counted');
|
|
77
|
+
const ours = await store.ourMovements({ since: new Date(Date.now() - 7 * 86400_000), until: new Date() });
|
|
78
|
+
const refs = Object.fromEntries(ours.map((o) => [o.ref, o.amount_micro]));
|
|
79
|
+
ok(refs.ch_fund === 50_000_000 && refs.tr_pay === 20_000_000 && refs.tr_member === 3_000_000 && refs.po_cash === 15_000_000 && ours.length === 4,
|
|
80
|
+
'our movements are read from all three records, in micro-dollars, sent only');
|
|
81
|
+
ok((await store.ourMovements({ refs: ['po_cash'] })).length === 1, 'a movement is found by its reference at any time');
|
|
82
|
+
|
|
83
|
+
const theirs = Object.entries(refs).map(([ref, amountMicro]) => ({ ref, amountMicro, createdAt: ours.find((o) => o.ref === ref).created_at }));
|
|
84
|
+
const agree = { balanceMicro: async () => 30_000_000, listMovements: async () => theirs };
|
|
85
|
+
const r1 = await L.runLedgerCheck({ client, processor: agree, force: true });
|
|
86
|
+
ok(r1.outcome === 'clean' && Number(r1.check.refs_compared) === 4, 'agreeing figures are recorded clean, through the real store');
|
|
87
|
+
|
|
88
|
+
let filed = 0;
|
|
89
|
+
const fileBlocker = async () => ({ id: 100 + (++filed) });
|
|
90
|
+
const off = { balanceMicro: async () => 29_000_000, listMovements: async () => theirs.filter((t) => t.ref !== 'po_cash') };
|
|
91
|
+
const r2 = await L.runLedgerCheck({ client, processor: off, force: true, fileBlocker });
|
|
92
|
+
ok(r2.outcome === 'drift' && r2.alarm === 'filed' && String(r2.check.blocker_id) === '101', 'a drift is recorded and filed');
|
|
93
|
+
ok(r2.check.findings.some((f) => f.kind === 'missing_at_processor' && f.ref === 'po_cash') && Number(r2.check.drift_micro) === -1_000_000,
|
|
94
|
+
'the row keeps each difference with its numbers');
|
|
95
|
+
const r3 = await L.runLedgerCheck({ client, processor: off, force: true, fileBlocker });
|
|
96
|
+
ok(r3.alarm === 'unchanged' && filed === 1, 'the same drift the next day is not filed again');
|
|
97
|
+
ok((await L.runLedgerCheck({ client, processor: agree })).skipped === 'not_due', 'a check is not run twice in a day');
|
|
98
|
+
|
|
99
|
+
const view = await L.readLedgerChecks(client, { limit: 5 });
|
|
100
|
+
ok(view.checks.length === 3 && view.checks[0].outcome === 'drift' && view.checks[0].findings.length >= 2, "the owner's view lists the checks, newest first, numbers included");
|
|
101
|
+
|
|
102
|
+
const code = (e) => e && e.code;
|
|
103
|
+
ok(code(await rejects('UPDATE economy_ledger_checks SET outcome = \'clean\'')) === '42501', 'a check row cannot be edited');
|
|
104
|
+
ok(code(await rejects('DELETE FROM economy_ledger_checks')) === '42501', 'a check row cannot be deleted');
|
|
105
|
+
const INS = 'INSERT INTO economy_ledger_checks (outcome, reason, fingerprint, alarm, blocker_id) VALUES ($1,$2,$3,$4,$5)';
|
|
106
|
+
ok(code(await rejects(INS, ['drift', null, null, 'filed', 1])) === '23514', 'a drift with no fingerprint is refused');
|
|
107
|
+
ok(code(await rejects(INS, ['clean', null, null, 'filed', 1])) === '23514', 'a clean check cannot claim an alarm');
|
|
108
|
+
ok(code(await rejects(INS, ['drift', null, 'abc', 'filed', null])) === '23514', 'a filed alarm names its blocker');
|
|
109
|
+
ok(code(await rejects(INS, ['unchecked', null, null, 'none', null])) === '23514', 'an unchecked row says why');
|
|
110
|
+
ok(code(await rejects(INS, ['drift', null, 'abc', 'none', null])) === '23514', 'a drift always says what became of its alarm');
|
|
111
|
+
|
|
112
|
+
await client.query('ROLLBACK');
|
|
113
|
+
console.log(`ledger_check_db.mjs: ${pass} passed`);
|
|
114
|
+
} finally {
|
|
115
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
116
|
+
await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
117
|
+
client.release();
|
|
118
|
+
await pgPool.end();
|
|
119
|
+
}
|