@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
|
@@ -31,6 +31,7 @@ const { asyncHandler } = require('./_helpers');
|
|
|
31
31
|
const auth = require('../auth');
|
|
32
32
|
const { readCoreUpdate, readCoreUpgrades } = require('../core-update');
|
|
33
33
|
const { readSoftwareUpdate } = require('../software-update');
|
|
34
|
+
const { followForShape } = require('../../../scripts/bongos/update-channel.js');
|
|
34
35
|
const seams = require('../../module-seams');
|
|
35
36
|
const { loadIdpConfig } = require('../auth-config');
|
|
36
37
|
const upgradeRequest = require('../upgrade-request');
|
|
@@ -88,6 +89,13 @@ function updateDoor({ provisioning, idp, hubUpgradeUrl }) {
|
|
|
88
89
|
return url ? { kind: 'hub', url } : { kind: 'cli', url: null };
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
// WHICH PUBLISHED VERSIONS THIS HALL MAY BE OFFERED (task 1004593, ADR 0361 D5): the hall that
|
|
93
|
+
// runs the door is the platform's own, the one project that takes every candidate; every other
|
|
94
|
+
// project follows releases, as the runner and /deploy offer them. followForShape is the runner's.
|
|
95
|
+
function followFor(door) {
|
|
96
|
+
return followForShape(door && door.kind === 'local' ? 'control-plane' : null);
|
|
97
|
+
}
|
|
98
|
+
|
|
91
99
|
function safeHubUpgradeUrl() {
|
|
92
100
|
try { return require('../../instance-config').resolveEnv('HUB_UPGRADE_URL') || null; } catch { return null; }
|
|
93
101
|
}
|
|
@@ -327,12 +335,12 @@ function buildCoreUpdateRouter({
|
|
|
327
335
|
asyncHandler('GET /software-update', async (req, res) => {
|
|
328
336
|
let running = null;
|
|
329
337
|
try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
|
|
338
|
+
const door = updateDoor(doorInputs());
|
|
330
339
|
// canUpdate does not depend on the rule, so it runs beside the rule read, not after it.
|
|
331
340
|
const [update, can] = await Promise.all([
|
|
332
|
-
resolveChannel(selfChannel).then(async ({ channel, source }) => ({ ...(await softwareUpdate({ running, channel })), channel_source: source })),
|
|
341
|
+
resolveChannel(selfChannel).then(async ({ channel, source }) => ({ ...(await softwareUpdate({ running, channel, follow: followFor(door) })), channel_source: source })),
|
|
333
342
|
canUpdate(req),
|
|
334
343
|
]);
|
|
335
|
-
const door = updateDoor(doorInputs());
|
|
336
344
|
const ask = await readAsk({ update, door, deps: askDeps() });
|
|
337
345
|
const held = await safeHold(upgradeHold, { running, offered: update && update.reading === 'ok' ? update.offered : null });
|
|
338
346
|
// The history only for a viewer who holds core.pin.move (see safeMoved's gate note).
|
|
@@ -357,8 +365,8 @@ function buildCoreUpdateRouter({
|
|
|
357
365
|
let running = null;
|
|
358
366
|
try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
|
|
359
367
|
const { channel } = await resolveChannel(selfChannel);
|
|
360
|
-
const update = await softwareUpdate({ running, channel });
|
|
361
368
|
const door = updateDoor(doorInputs());
|
|
369
|
+
const update = await softwareUpdate({ running, channel, follow: followFor(door) });
|
|
362
370
|
const deps = askDeps();
|
|
363
371
|
return upgradeRequest.requestUpgrade({
|
|
364
372
|
host: deps.host,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/routes/module-payouts.js — a module sale's split rule, the sale, and paying
|
|
4
|
+
// each share (task 1004910, goal 1000092 N6; ADR 0388). The rules live in
|
|
5
|
+
// ../module-payouts.js; the database repeats the ones that matter (core_291).
|
|
6
|
+
//
|
|
7
|
+
// rank: every route needs `payout.approve` (read fresh per request, ADR 0016), except
|
|
8
|
+
// GET /payouts/module-shares/mine, where any signed-in builder sees their own shares and
|
|
9
|
+
// the weight behind each (Art. VIII). Every write is a PERSONAL act: the person's own web
|
|
10
|
+
// session, never a CLI token or a machine principal (payout-approvals' personalSession).
|
|
11
|
+
//
|
|
12
|
+
// No processor adapter exists yet (ADR 0397), so `processor` is null: a sale cannot be
|
|
13
|
+
// read and a share is recorded as owed and held. Dependencies are injectable for tests.
|
|
14
|
+
|
|
15
|
+
const express = require('express');
|
|
16
|
+
const auth = require('../auth');
|
|
17
|
+
const { validateOrRespond, parsePagination } = require('./_helpers');
|
|
18
|
+
const modulePayouts = require('../module-payouts');
|
|
19
|
+
const log = require('../logger').logger.child({ src: 'module-payouts' });
|
|
20
|
+
|
|
21
|
+
const PERSONAL_REFUSAL = 'Moving module money must be done by you, signed in on the website. A command-line or AI session cannot do it.';
|
|
22
|
+
|
|
23
|
+
module.exports = function buildModulePayoutsRouter({
|
|
24
|
+
payouts = modulePayouts,
|
|
25
|
+
store = require('../payout-approvals'),
|
|
26
|
+
processor = null,
|
|
27
|
+
requireBuilder = auth.requireBuilder,
|
|
28
|
+
requirePermission = auth.requirePermission,
|
|
29
|
+
} = {}) {
|
|
30
|
+
const router = express.Router();
|
|
31
|
+
|
|
32
|
+
function personal(req, res) {
|
|
33
|
+
const p = store.personalSession(req);
|
|
34
|
+
if (!p.ok) {
|
|
35
|
+
res.fail('personal_approval_required', { status: 403, message: PERSONAL_REFUSAL });
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return p.actor;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fail(res, err, what) {
|
|
42
|
+
if (err instanceof payouts.PayoutRefusal) return res.fail(err.code, { status: err.status, message: err.message });
|
|
43
|
+
log.error({ err: err && err.message }, `${what} failed`);
|
|
44
|
+
if (!res.headersSent) res.fail('module_payout_failed', { status: 500, message: 'internal error' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// GET /api/bongos/payouts/module-splits/:moduleKey — the module's split rule and the
|
|
48
|
+
// contributors' weights it would use now.
|
|
49
|
+
router.get('/payouts/module-splits/:moduleKey', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
50
|
+
try {
|
|
51
|
+
res.json(await payouts.previewSplit(req.params.moduleKey));
|
|
52
|
+
} catch (err) { fail(res, err, 'GET /payouts/module-splits'); }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// POST /api/bongos/payouts/module-splits/:moduleKey — record the project's rule.
|
|
56
|
+
// Body: { seller_id, seller_bps, contributors_bps, credit_row_kinds? }.
|
|
57
|
+
router.post('/payouts/module-splits/:moduleKey', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
58
|
+
const actor = personal(req, res);
|
|
59
|
+
if (!actor) return;
|
|
60
|
+
if (validateOrRespond(req, res, {
|
|
61
|
+
seller_id: { required: true }, seller_bps: { required: true }, contributors_bps: { required: true }, credit_row_kinds: {},
|
|
62
|
+
})) return;
|
|
63
|
+
try {
|
|
64
|
+
const rule = await payouts.setSplitRule({ moduleKey: req.params.moduleKey, body: req.body, actorId: actor.id });
|
|
65
|
+
res.status(201).json({ rule });
|
|
66
|
+
} catch (err) { fail(res, err, 'POST /payouts/module-splits'); }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// POST /api/bongos/payouts/module-sales — record ONE sale the processor reports, and
|
|
70
|
+
// write every share as owed. Body: { processor_sale_ref }. The price is read from the
|
|
71
|
+
// processor, never the body. Recording the same sale again answers with the one there.
|
|
72
|
+
router.post('/payouts/module-sales', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
73
|
+
const actor = personal(req, res);
|
|
74
|
+
if (!actor) return;
|
|
75
|
+
if (validateOrRespond(req, res, { processor_sale_ref: { required: true } })) return;
|
|
76
|
+
try {
|
|
77
|
+
const out = await payouts.recordSale({ processorSaleRef: req.body.processor_sale_ref, actorId: actor.id }, { processor });
|
|
78
|
+
res.status(out.already ? 200 : 201).json(out);
|
|
79
|
+
} catch (err) { fail(res, err, 'POST /payouts/module-sales'); }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// GET /api/bongos/payouts/module-shares — every share and where it stands.
|
|
83
|
+
router.get('/payouts/module-shares', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
84
|
+
try {
|
|
85
|
+
const { limit, offset } = parsePagination(req, { defaultLimit: 50, maxLimit: 200 });
|
|
86
|
+
res.json({ shares: await payouts.listShares(undefined, { limit, offset }) });
|
|
87
|
+
} catch (err) { fail(res, err, 'GET /payouts/module-shares'); }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// GET /api/bongos/payouts/module-shares/mine — the signed-in builder's own shares.
|
|
91
|
+
router.get('/payouts/module-shares/mine', requireBuilder, async (req, res) => {
|
|
92
|
+
try {
|
|
93
|
+
const { limit, offset } = parsePagination(req, { defaultLimit: 50, maxLimit: 200 });
|
|
94
|
+
res.set('Cache-Control', 'no-store');
|
|
95
|
+
res.json({ shares: await payouts.listShares(undefined, { recipientId: req.builder.id, limit, offset }) });
|
|
96
|
+
} catch (err) { fail(res, err, 'GET /payouts/module-shares/mine'); }
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// POST /api/bongos/payouts/module-shares/:id/pay — send ONE share under ONE approval.
|
|
100
|
+
// Body: { approval_id }. The person and the amount come from the share; the approval
|
|
101
|
+
// must name exactly them. Pressing again is safe, as for a task payout.
|
|
102
|
+
router.post('/payouts/module-shares/:id/pay', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
103
|
+
const actor = personal(req, res);
|
|
104
|
+
if (!actor) return;
|
|
105
|
+
if (validateOrRespond(req, res, { approval_id: { required: true } })) return;
|
|
106
|
+
try {
|
|
107
|
+
const out = await payouts.payShare({ shareId: req.params.id, approvalId: req.body.approval_id, actorId: actor.id }, { processor });
|
|
108
|
+
res.set('Cache-Control', 'no-store');
|
|
109
|
+
res.json(out);
|
|
110
|
+
} catch (err) { fail(res, err, 'POST /payouts/module-shares/:id/pay'); }
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return router;
|
|
114
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/routes/my-cash-outs.js — a person takes their own balance out to their bank
|
|
4
|
+
// (task 1004911, goal 1000092 N7; ADR 0385 D1/D8, ADR 0397 D2/D7).
|
|
5
|
+
//
|
|
6
|
+
// GET /me/cash-outs my withdrawals, each in plain words, the minimum, my balance
|
|
7
|
+
// POST /me/cash-outs { amount_cents, instruction_id } → ask for one withdrawal
|
|
8
|
+
// POST /me/cash-outs/:id/retry try an unfinished one again (never pays twice)
|
|
9
|
+
// POST /me/cash-outs/:id/cancel withdraw a request that has not been sent
|
|
10
|
+
//
|
|
11
|
+
// Own-scoped (tests/own_scope_guard.mjs): every route acts on req.builder.id and nothing
|
|
12
|
+
// else; someone else's withdrawal answers 404. Every write needs the person's own web
|
|
13
|
+
// session: pressing it IS their logged personal approval (N9), so a CLI token, an agent or a
|
|
14
|
+
// machine principal can never take a person's money out. ../cash-outs.js owns the order.
|
|
15
|
+
//
|
|
16
|
+
// The processor adapter (ADR 0397: the only code that may name the rail) does not exist
|
|
17
|
+
// yet, so every withdrawal is recorded and held `no_processor` until it lands.
|
|
18
|
+
|
|
19
|
+
const express = require('express');
|
|
20
|
+
const auth = require('../auth');
|
|
21
|
+
const { validateOrRespond } = require('./_helpers');
|
|
22
|
+
const log = require('../logger').logger.child({ src: 'my-cash-outs' });
|
|
23
|
+
|
|
24
|
+
const PERSONAL_REFUSAL = 'A cash-out is made by you, signed in on the website. A command-line or AI session cannot take money out.';
|
|
25
|
+
|
|
26
|
+
function buildMyCashOutsRouter({
|
|
27
|
+
cashOuts = require('../cash-outs'),
|
|
28
|
+
approvals = require('../payout-approvals'),
|
|
29
|
+
processor = null,
|
|
30
|
+
requireBuilder = auth.requireBuilder,
|
|
31
|
+
} = {}) {
|
|
32
|
+
const router = express.Router();
|
|
33
|
+
|
|
34
|
+
function personal(req, res) {
|
|
35
|
+
const p = approvals.personalSession(req);
|
|
36
|
+
if (!p.ok) {
|
|
37
|
+
res.fail('personal_approval_required', { status: 403, message: PERSONAL_REFUSAL });
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return p.actor;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fault(res, err, what) {
|
|
44
|
+
if (err instanceof cashOuts.CashOutRefusal) {
|
|
45
|
+
return res.fail(err.code, { status: err.status, message: err.message, ...(err.details ? { details: err.details } : {}) });
|
|
46
|
+
}
|
|
47
|
+
// a database rule refusing (core_293's triggers): say no in words, keep its text in the log
|
|
48
|
+
if (err && (err.code === '23514' || err.code === '42501' || err.code === '23505')) {
|
|
49
|
+
log.warn({ err: err.message }, `${what} refused by the database`);
|
|
50
|
+
return res.fail('cash_out_refused', { status: 409, message: 'The cash-out rules refused this. Nothing was sent.' });
|
|
51
|
+
}
|
|
52
|
+
log.error({ err: err && err.message }, `${what} failed`);
|
|
53
|
+
if (!res.headersSent) res.fail('cash_out_failed', { status: 500, message: 'internal error' });
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const answer = (res, out) => {
|
|
58
|
+
res.set('Cache-Control', 'no-store');
|
|
59
|
+
res.status(out.status === 'sent' || out.status === 'paid' || out.status === 'cancelled' ? 200 : 202).json(out);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// GET /api/bongos/me/cash-outs — my withdrawals and where each stands.
|
|
63
|
+
router.get('/me/cash-outs', requireBuilder, async (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
const out = await cashOuts.listMyCashOuts(req.builder.id, { processor, limit: req.query.limit, log });
|
|
66
|
+
res.set('Cache-Control', 'no-store');
|
|
67
|
+
res.json(out);
|
|
68
|
+
} catch (err) { fault(res, err, 'GET /me/cash-outs'); }
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// POST /api/bongos/me/cash-outs — ask to take amount_cents of my balance to my bank.
|
|
72
|
+
router.post('/me/cash-outs', requireBuilder, async (req, res) => {
|
|
73
|
+
const actor = personal(req, res);
|
|
74
|
+
if (!actor) return;
|
|
75
|
+
if (validateOrRespond(req, res, { amount_cents: { required: true }, instruction_id: { required: true } })) return;
|
|
76
|
+
try {
|
|
77
|
+
const out = await cashOuts.requestCashOut({
|
|
78
|
+
builderId: String(req.builder.id), actor, amountCents: req.body.amount_cents, instructionId: req.body.instruction_id,
|
|
79
|
+
}, { processor, log });
|
|
80
|
+
answer(res, out);
|
|
81
|
+
} catch (err) { fault(res, err, 'POST /me/cash-outs'); }
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// POST /api/bongos/me/cash-outs/:id/retry — try one of my unfinished withdrawals again.
|
|
85
|
+
router.post('/me/cash-outs/:id/retry', requireBuilder, async (req, res) => {
|
|
86
|
+
if (!personal(req, res)) return;
|
|
87
|
+
try {
|
|
88
|
+
answer(res, await cashOuts.retryCashOut({ builderId: String(req.builder.id), cashOutId: req.params.id }, { processor }));
|
|
89
|
+
} catch (err) { fault(res, err, 'POST /me/cash-outs/:id/retry'); }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// POST /api/bongos/me/cash-outs/:id/cancel — withdraw one of my requests that has not been sent.
|
|
93
|
+
router.post('/me/cash-outs/:id/cancel', requireBuilder, async (req, res) => {
|
|
94
|
+
if (!personal(req, res)) return;
|
|
95
|
+
try {
|
|
96
|
+
answer(res, await cashOuts.cancelCashOut({ builderId: String(req.builder.id), cashOutId: req.params.id }));
|
|
97
|
+
} catch (err) { fault(res, err, 'POST /me/cash-outs/:id/cancel'); }
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return router;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = buildMyCashOutsRouter;
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// WHY. The gates themselves run inside every money route (../payout-gates.js,
|
|
7
7
|
// called from routes/payout-approvals.js and from checkRun / checkApproval). This
|
|
8
8
|
// file is where their facts are recorded — an age tier, a guardian link, a
|
|
9
|
-
// sanctions screening, a hand-made hold
|
|
9
|
+
// sanctions screening, a hand-made hold, and the contractor papers (an NDA on
|
|
10
|
+
// file, tax details collected; task 1004908) — and where an appeal is decided. The
|
|
10
11
|
// person whose pay is held reads why and appeals in routes/my-payouts.js (the
|
|
11
12
|
// framework, cited by Article only, forbids withholding earned pay silently).
|
|
12
13
|
//
|
|
@@ -137,6 +138,46 @@ function buildPayoutGatesRouter({
|
|
|
137
138
|
} catch (err) { fault(res, err, 'POST /payouts/screenings'); }
|
|
138
139
|
});
|
|
139
140
|
|
|
141
|
+
// POST /api/bongos/payouts/ndas — record that a builder's signed NDA is on file
|
|
142
|
+
// (or withdrawn), and where the signed copy lives. Never the NDA's text.
|
|
143
|
+
// Body: { builder_id, status: on_file|withdrawn, nda_version, reference? }.
|
|
144
|
+
router.post('/payouts/ndas', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
145
|
+
const actor = personal(req, res);
|
|
146
|
+
if (!actor) return;
|
|
147
|
+
if (validateOrRespond(req, res, { builder_id: { required: true }, status: { required: true }, nda_version: { required: true }, reference: {} })) return;
|
|
148
|
+
const b = req.body;
|
|
149
|
+
if (!approvals.isId(b.builder_id)) return res.fail('bad_builder_id', { status: 400, message: 'builder_id must be a number' });
|
|
150
|
+
if (String(b.builder_id) === String(actor.id)) return res.fail('own_papers', { status: 403, message: 'Someone else must record your own NDA.' });
|
|
151
|
+
if (!gates.NDA_STATUSES.includes(b.status)) return res.fail('bad_status', { status: 400, message: `status must be one of ${gates.NDA_STATUSES.join(', ')}` });
|
|
152
|
+
if (typeof b.nda_version !== 'string' || !/^[A-Za-z0-9._-]{1,40}$/.test(b.nda_version)) return res.fail('bad_nda_version', { status: 400, message: 'nda_version must be a short label (letters, digits, dot, dash, underscore), such as beta-v1.' });
|
|
153
|
+
const reference = b.reference == null ? null : statement(b.reference, 200);
|
|
154
|
+
if (reference !== null && reference.length > 200) return res.fail('bad_reference', { status: 400, message: 'reference must be up to 200 characters' });
|
|
155
|
+
try {
|
|
156
|
+
res.status(201).json({ record: await gates.recordNda({ builderId: b.builder_id, status: b.status, ndaVersion: b.nda_version, reference, recordedBy: actor.id }) });
|
|
157
|
+
} catch (err) { fault(res, err, 'POST /payouts/ndas'); }
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// POST /api/bongos/payouts/tax-details — record that the processor's onboarding has
|
|
161
|
+
// (or has not) collected a builder's identity and tax details, as checked by a
|
|
162
|
+
// person. The tax id itself never comes here: a reference that looks like one is
|
|
163
|
+
// refused. Body: { builder_id, status: collected|incomplete, reference? }.
|
|
164
|
+
router.post('/payouts/tax-details', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
165
|
+
const actor = personal(req, res);
|
|
166
|
+
if (!actor) return;
|
|
167
|
+
if (validateOrRespond(req, res, { builder_id: { required: true }, status: { required: true }, reference: {} })) return;
|
|
168
|
+
const b = req.body;
|
|
169
|
+
if (!approvals.isId(b.builder_id)) return res.fail('bad_builder_id', { status: 400, message: 'builder_id must be a number' });
|
|
170
|
+
if (String(b.builder_id) === String(actor.id)) return res.fail('own_papers', { status: 403, message: 'Someone else must record your own tax details.' });
|
|
171
|
+
if (!gates.TAX_STATUSES.includes(b.status)) return res.fail('bad_status', { status: 400, message: `status must be one of ${gates.TAX_STATUSES.join(', ')}` });
|
|
172
|
+
const reference = b.reference == null ? null : statement(b.reference, 200);
|
|
173
|
+
if (reference !== null && reference.length > 200) return res.fail('bad_reference', { status: 400, message: 'reference must be up to 200 characters' });
|
|
174
|
+
if (reference !== null && gates.looksLikeTaxId(reference)) return res.fail('tax_id_refused', { status: 400, message: 'That looks like a tax id. Record the processor\'s onboarding reference instead; Bongos never stores tax ids.' });
|
|
175
|
+
const login = String(req.builder.github_login || actor.id).replace(/[^A-Za-z0-9._-]/g, '').slice(0, 60) || String(actor.id);
|
|
176
|
+
try {
|
|
177
|
+
res.status(201).json({ record: await gates.recordTaxDetails({ builderId: b.builder_id, status: b.status, collectedBy: `person:${login}`, reference, recordedBy: actor.id }) });
|
|
178
|
+
} catch (err) { fault(res, err, 'POST /payouts/tax-details'); }
|
|
179
|
+
});
|
|
180
|
+
|
|
140
181
|
// POST /api/bongos/payouts/freezes — a person freezes a builder's payouts by hand.
|
|
141
182
|
// The notice is required: there is no silent freeze. Body: { builder_id, notice_text, owed_cents? }.
|
|
142
183
|
router.post('/payouts/freezes', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
package/src/bongos/routes.js
CHANGED
|
@@ -37,6 +37,10 @@ const buildPayoutApprovalsRouter = require('./routes/payout-approvals');
|
|
|
37
37
|
// Who may be paid at all: age tier, sanctions, a hold that tells the person (task 1004914).
|
|
38
38
|
const buildPayoutGatesRouter = require('./routes/payout-gates');
|
|
39
39
|
const buildMyPayoutsRouter = require('./routes/my-payouts');
|
|
40
|
+
// A person takes their own balance out to their bank (task 1004911, N7).
|
|
41
|
+
const buildMyCashOutsRouter = require('./routes/my-cash-outs');
|
|
42
|
+
// A module sale's split rule, the sale and each share's payout (task 1004910, ADR 0388).
|
|
43
|
+
const buildModulePayoutsRouter = require('./routes/module-payouts');
|
|
40
44
|
const buildBuildersRouter = require('./routes/builders');
|
|
41
45
|
// The LIFECYCLE routes (tasks · claims · versions · done-when · goals ·
|
|
42
46
|
// dependencies · analytics) carved into modules/lifecycle/
|
|
@@ -179,6 +183,8 @@ function buildGdsRouter() {
|
|
|
179
183
|
router.use(buildPayoutApprovalsRouter());
|
|
180
184
|
router.use(buildPayoutGatesRouter());
|
|
181
185
|
router.use(buildMyPayoutsRouter());
|
|
186
|
+
router.use(buildMyCashOutsRouter());
|
|
187
|
+
router.use(buildModulePayoutsRouter());
|
|
182
188
|
router.use(buildBuildersRouter());
|
|
183
189
|
// tasks · claims · versions · done-when · goals · dependencies · analytics now
|
|
184
190
|
// mount via moduleLoader.mountModuleRoutes below (lifecycle module, BV1.R86) —
|
|
@@ -22,6 +22,12 @@
|
|
|
22
22
|
// /deploy, via the provisioning.selfUpdateChannel seam — task 1004535), else the runner's
|
|
23
23
|
// default. The banner (core-update.js) is handed the same rule, so the two cannot disagree.
|
|
24
24
|
//
|
|
25
|
+
// ONLY WHAT THIS PROJECT'S FOLLOW ADMITS IS OFFERED (task 1004593, ADR 0361 D5). Merges publish
|
|
26
|
+
// as candidates above the registry's `latest` label; a project that follows releases is offered
|
|
27
|
+
// nothing above it, through `versionsForFollow`, the function the runner and the banner use.
|
|
28
|
+
// Before this the panel offered a candidate that /deploy then said did not exist. The route
|
|
29
|
+
// names the follow: candidates only on the platform's own hall.
|
|
30
|
+
//
|
|
25
31
|
// A READING, NEVER A GUESS. `reading` is 'ok' only when the registry answered; "up to date"
|
|
26
32
|
// is `offered: null` on an ok reading and nothing else. Notes that could not be read say so,
|
|
27
33
|
// and a version whose notes are missing is shown as missing rather than as empty.
|
|
@@ -30,6 +36,8 @@ const {
|
|
|
30
36
|
CORE_PKG,
|
|
31
37
|
DEFAULT_CHANNEL,
|
|
32
38
|
normalizeChannel,
|
|
39
|
+
normalizeFollow,
|
|
40
|
+
versionsForFollow,
|
|
33
41
|
resolveChannelTarget,
|
|
34
42
|
compareSemver,
|
|
35
43
|
} = require('../../scripts/bongos/update-channel.js');
|
|
@@ -62,22 +70,29 @@ function entriesFor(notes, version) {
|
|
|
62
70
|
return out;
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
/** The published versions `follow` admits, oldest first; [] when a release follower has no label. */
|
|
74
|
+
function admittedVersions(follow, registry) {
|
|
75
|
+
return versionsForFollow({ follow, available: registry.versions, released: registry.latest });
|
|
76
|
+
}
|
|
77
|
+
|
|
65
78
|
/**
|
|
66
79
|
* The panel's whole answer, from one registry reading. PURE — no clock, no I/O.
|
|
67
80
|
*
|
|
68
81
|
* `registry` and `notes` are package-registry.js's `readRegistry` result. Versions in the
|
|
69
|
-
* update are the
|
|
82
|
+
* update are the admitted ones above `running` and at or below `offered`, newest first, and
|
|
83
|
+
* `newest` is the newest admitted version, so a candidate is never named to a release follower.
|
|
70
84
|
*/
|
|
71
|
-
function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt }) {
|
|
85
|
+
function softwareUpdateVerdict({ running, channel, follow, registry, notes, checkedAt }) {
|
|
72
86
|
const ch = normalizeChannel(channel == null ? DEFAULT_CHANNEL : channel);
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (!registry || !registry.ok)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
const fol = normalizeFollow(follow);
|
|
88
|
+
const base = { checked_at: checkedAt, running: running || null, channel: ch, follow: fol };
|
|
89
|
+
const noAnswer = (error) => ({ ...base, reading: 'no_answer', error, offered: null, newest: null, whats_new: null });
|
|
90
|
+
if (!running || !VERSION_RE.test(String(running))) return noAnswer('this project could not name the version it runs');
|
|
91
|
+
if (!registry || !registry.ok) return noAnswer((registry && registry.error) || 'the registry could not be read');
|
|
92
|
+
const admitted = admittedVersions(fol, registry);
|
|
93
|
+
// No label, no answer: the runner moves a release follower on nothing it cannot call released.
|
|
94
|
+
if (!admitted.length) return noAnswer('the registry does not say which version is released');
|
|
95
|
+
const offered = resolveChannelTarget({ installed: running, channel: ch, available: admitted }) || null;
|
|
81
96
|
const published = registry.published || {};
|
|
82
97
|
const answer = {
|
|
83
98
|
...base,
|
|
@@ -85,12 +100,12 @@ function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt })
|
|
|
85
100
|
error: null,
|
|
86
101
|
offered,
|
|
87
102
|
offered_published_at: offered ? published[offered] || null : null,
|
|
88
|
-
newest:
|
|
103
|
+
newest: admitted.reduce((a, v) => (compareSemver(v, a) > 0 ? v : a)),
|
|
89
104
|
whats_new: null,
|
|
90
105
|
};
|
|
91
106
|
if (!offered) return answer;
|
|
92
107
|
|
|
93
|
-
const inUpdate =
|
|
108
|
+
const inUpdate = admitted
|
|
94
109
|
.filter((v) => compareSemver(v, running) > 0 && compareSemver(v, offered) <= 0)
|
|
95
110
|
.sort((a, b) => compareSemver(b, a));
|
|
96
111
|
const shown = inUpdate.slice(0, VERSIONS_MAX);
|
|
@@ -115,14 +130,19 @@ function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt })
|
|
|
115
130
|
* `get`/`now`/`store` pass through to the registry reader, so a test drives the real
|
|
116
131
|
* packument parsing, the real tarball stream and the real cache rather than a stub.
|
|
117
132
|
*/
|
|
118
|
-
async function readSoftwareUpdate({ running, channel, get, now = Date.now, store } = {}) {
|
|
133
|
+
async function readSoftwareUpdate({ running, channel, follow, get, now = Date.now, store } = {}) {
|
|
119
134
|
const t = now();
|
|
135
|
+
const fol = normalizeFollow(follow);
|
|
120
136
|
const { registry, notes } = await readRegistry({
|
|
121
137
|
pkg: CORE_PKG, get, now, store,
|
|
122
|
-
// Nothing to describe when nothing
|
|
123
|
-
haveNotesFor: (
|
|
138
|
+
// Nothing to describe when nothing this project could take is newer: skip the download.
|
|
139
|
+
haveNotesFor: (_newest, facts) => {
|
|
140
|
+
if (!VERSION_RE.test(String(running || ''))) return true;
|
|
141
|
+
const admitted = admittedVersions(fol, facts);
|
|
142
|
+
return !admitted.some((v) => compareSemver(v, running) > 0);
|
|
143
|
+
},
|
|
124
144
|
});
|
|
125
|
-
return softwareUpdateVerdict({ running, channel, registry, notes, checkedAt: new Date(t).toISOString() });
|
|
145
|
+
return softwareUpdateVerdict({ running, channel, follow: fol, registry, notes, checkedAt: new Date(t).toISOString() });
|
|
126
146
|
}
|
|
127
147
|
|
|
128
148
|
module.exports = { readSoftwareUpdate };
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.25'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -57,6 +57,7 @@ try {
|
|
|
57
57
|
await client.query(`INSERT INTO payout_approvals (kind, recipient_id, amount_cents, currency)
|
|
58
58
|
VALUES ('payout_approved', 1, 12500, 'USD'), ('payout_approved', 1, 300, 'USD'), ('payout_approved', 1, 400, 'USD')`);
|
|
59
59
|
await client.query(mig('modules', 'economy', 'migrations', 'economy_002_project_account.sql'));
|
|
60
|
+
await client.query(mig('modules', 'economy', 'migrations', 'economy_004_module_share_payouts.sql'));
|
|
60
61
|
await client.query(mig('migrations', 'core_290_builder_payouts.sql'));
|
|
61
62
|
await client.query('BEGIN');
|
|
62
63
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// tests/cash_outs.mjs — task 1004911 (goal 1000092 N7). DB-free.
|
|
2
|
+
//
|
|
3
|
+
// The pure half of cash-outs: how a withdrawal's attempts fold into where it stands (and
|
|
4
|
+
// which try, so which idempotency key, comes next), the refusals made before anything is
|
|
5
|
+
// recorded, and the routes' own gate: only the person, signed in on the web, can take their
|
|
6
|
+
// money out. The real tables and the real flow are tests/cash_outs_db.mjs.
|
|
7
|
+
//
|
|
8
|
+
// Run: node tests/cash_outs.mjs
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const express = require('express');
|
|
14
|
+
const C = require('../src/bongos/cash-outs.js');
|
|
15
|
+
const buildMyCashOuts = require('../src/bongos/routes/my-cash-outs.js');
|
|
16
|
+
|
|
17
|
+
let pass = 0;
|
|
18
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
19
|
+
const refusal = async (fn) => { try { await fn(); } catch (e) { return e; } return null; };
|
|
20
|
+
const at = (outcome, tryNo, ref = null) => ({ outcome, try_no: tryNo, processor_ref: ref, reason: ref ? null : 'x' });
|
|
21
|
+
|
|
22
|
+
// ── standing ─────────────────────────────────────────────────────────────
|
|
23
|
+
ok(C.standing([]).status === 'held' && C.standing([]).try_no === 1, 'nothing tried yet: waiting, try 1');
|
|
24
|
+
ok(C.standing([at('failed', 1)]).try_no === 1, 'a failure we cannot trust keeps try 1 (the same key)');
|
|
25
|
+
ok(C.standing([at('refused', 1)]).try_no === 2, 'a processor refusal moves on to try 2');
|
|
26
|
+
ok(C.standing([at('sent', 1, 'po_1')]).status === 'sent', 'sent and not yet arrived');
|
|
27
|
+
ok(C.standing([at('sent', 1, 'po_1'), at('paid', 1, 'po_1')]).status === 'paid', 'arrived');
|
|
28
|
+
const back = C.standing([at('sent', 1, 'po_1'), at('paid', 1, 'po_1'), { outcome: 'returned', try_no: 1, processor_ref: 'po_1', reason: 'closed' }]);
|
|
29
|
+
ok(back.status === 'returned' && back.try_no === 2, 'a bank return after arrival reopens it on try 2');
|
|
30
|
+
ok(C.standing([at('held', 1), at('cancelled', 1)]).status === 'cancelled', 'cancelled');
|
|
31
|
+
ok(C.idempotencyKey('7', 2) === 'bongos-cashout-7-2', 'the key names the withdrawal and the try');
|
|
32
|
+
|
|
33
|
+
// ── refusals before anything is recorded ─────────────────────────────────
|
|
34
|
+
const noDb = { query: async () => { throw new Error('the database must not be reached'); } };
|
|
35
|
+
const me = { id: '5', session_id: '1', session_source: 'web' };
|
|
36
|
+
let e = await refusal(() => C.requestCashOut({ builderId: '5', actor: me, amountCents: 999, instructionId: 'press-000001' }, { pool: noDb, account: { memberAccount: async () => 'acct_test' } }));
|
|
37
|
+
ok(e && e.code === 'below_minimum', 'below the minimum is refused without touching the database');
|
|
38
|
+
e = await refusal(() => C.requestCashOut({ builderId: '5', actor: me, amountCents: 1.5, instructionId: 'press-000001' }, { pool: noDb }));
|
|
39
|
+
ok(e && e.code === 'bad_amount', 'a fractional amount is refused');
|
|
40
|
+
e = await refusal(() => C.requestCashOut({ builderId: '5', actor: me, amountCents: 2000, instructionId: 'x' }, { pool: noDb }));
|
|
41
|
+
ok(e && e.code === 'bad_instruction_id', 'a malformed instruction id is refused');
|
|
42
|
+
e = await refusal(() => C.requestCashOut({ builderId: '5', actor: { ...me, id: '6' }, amountCents: 2000, instructionId: 'press-000001' }, { pool: noDb }));
|
|
43
|
+
ok(e && e.code === 'personal_session_required', "nobody cashes out someone else's money");
|
|
44
|
+
e = await refusal(() => C.requestCashOut({ builderId: '5', actor: me, amountCents: 2000, instructionId: 'press-000001' }, { pool: noDb, account: null }));
|
|
45
|
+
ok(e && e.code === 'no_account_record', 'with balances switched off there is nothing to cash out');
|
|
46
|
+
e = await refusal(() => C.requestCashOut({ builderId: '5', actor: me, amountCents: 2000, instructionId: 'press-000001' }, { pool: noDb, account: { memberAccount: async () => null } }));
|
|
47
|
+
ok(e && e.code === 'no_account', 'no processor account here: nothing to cash out');
|
|
48
|
+
e = await refusal(() => C.recordProcessorOutcome({ processorRef: 'po_1', outcome: 'returned' }, { pool: noDb }));
|
|
49
|
+
ok(e && e.code === 'no_reason', 'a returned payout must say why');
|
|
50
|
+
|
|
51
|
+
// ── the routes' gate ─────────────────────────────────────────────────────
|
|
52
|
+
const seen = [];
|
|
53
|
+
const stub = {
|
|
54
|
+
CashOutRefusal: C.CashOutRefusal,
|
|
55
|
+
listMyCashOuts: async (id) => { seen.push(['list', id]); return { cash_outs: [] }; },
|
|
56
|
+
requestCashOut: async (i) => { seen.push(['request', i.builderId, i.actor.id]); return { status: 'held' }; },
|
|
57
|
+
retryCashOut: async (i) => { seen.push(['retry', i.builderId, i.cashOutId]); throw new C.CashOutRefusal('no_such_cash_out', 'none', 404); },
|
|
58
|
+
cancelCashOut: async (i) => { seen.push(['cancel', i.builderId]); return { status: 'cancelled' }; },
|
|
59
|
+
};
|
|
60
|
+
function serve(as) {
|
|
61
|
+
const a = express();
|
|
62
|
+
a.use(express.json());
|
|
63
|
+
a.use((req, res, next) => { res.fail = (code, o = {}) => res.status(o.status || 400).json({ error: code, message: o.message }); req.builder = as.builder; req.gdsSession = as.gdsSession; next(); });
|
|
64
|
+
a.use('/', buildMyCashOuts({ cashOuts: stub, requireBuilder: (q, r, nx) => nx() }));
|
|
65
|
+
return http.createServer(a).listen(0);
|
|
66
|
+
}
|
|
67
|
+
async function call(srv, method, path, body) {
|
|
68
|
+
const r = await fetch(`http://127.0.0.1:${srv.address().port}${path}`, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
|
|
69
|
+
return { status: r.status, body: await r.json() };
|
|
70
|
+
}
|
|
71
|
+
const web = serve({ builder: { id: '5' }, gdsSession: { source: 'web', session_id: '3' } });
|
|
72
|
+
const cli = serve({ builder: { id: '5' }, gdsSession: { source: 'cli', session_id: '4' } });
|
|
73
|
+
const bot = serve({ builder: { id: '9', system_role: 'agent' }, gdsSession: { source: 'web', session_id: '5' } });
|
|
74
|
+
try {
|
|
75
|
+
for (const [srv, who] of [[cli, 'a CLI session'], [bot, 'a machine principal']]) {
|
|
76
|
+
for (const [act, p] of [['request', '/me/cash-outs'], ['retry', '/me/cash-outs/1/retry'], ['cancel', '/me/cash-outs/1/cancel']]) {
|
|
77
|
+
const r = await call(srv, 'POST', p, { amount_cents: 2000, instruction_id: 'press-000001' });
|
|
78
|
+
ok(r.status === 403 && r.body.error === 'personal_approval_required', `${who} cannot ${act} a cash-out`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
ok(!seen.some(([k]) => k !== 'list'), 'nothing reached the cash-out code from a refused session');
|
|
82
|
+
let r = await call(cli, 'GET', '/me/cash-outs');
|
|
83
|
+
ok(r.status === 200 && seen.at(-1)[1] === '5', 'reading my own list works from any session, keyed on me');
|
|
84
|
+
r = await call(web, 'POST', '/me/cash-outs', { amount_cents: 2000, instruction_id: 'press-000001', builder_id: '6' });
|
|
85
|
+
ok(r.status === 400 && seen.at(-1)[0] === 'list', 'a body naming someone else is refused outright');
|
|
86
|
+
r = await call(web, 'POST', '/me/cash-outs', { amount_cents: 2000, instruction_id: 'press-000001' });
|
|
87
|
+
ok(r.status === 202 && seen.at(-1)[1] === '5' && seen.at(-1)[2] === '5', 'a request is mine: the signed-in person, never the body');
|
|
88
|
+
r = await call(web, 'POST', '/me/cash-outs/77/retry');
|
|
89
|
+
ok(r.status === 404 && r.body.error === 'no_such_cash_out', 'a refusal is passed on with its status');
|
|
90
|
+
r = await call(web, 'POST', '/me/cash-outs', {});
|
|
91
|
+
ok(r.status === 400, 'a request with no amount is refused');
|
|
92
|
+
} finally {
|
|
93
|
+
for (const s of [web, cli, bot]) s.close();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log(`\ncash_outs.mjs: ${pass} passed`);
|