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