@bongos/core 1.24.16 → 1.24.18

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.
@@ -0,0 +1,264 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/builder-payouts.js — A BUILDER IS PAID FOR A TASK, EXACTLY ONCE, OUT OF THE
4
+ // PROJECT'S ACCOUNT (task 1003183, goal 1000092 M04; ADR 0385 D1/D4, ADR 0397 D2/D5/D7).
5
+ //
6
+ // WHY. Two failures are worse than any other here: paying a builder twice, and a builder
7
+ // who was never paid with no trace that anything was due. This file is built against both.
8
+ //
9
+ // THE ORDER IS THE DESIGN.
10
+ // 1. The debt is written first, in its own committed step (builder_payouts_owed: one row
11
+ // per builder and task, citing the approval). From here on, whatever happens, the
12
+ // record says the money is owed.
13
+ // 2. The authority is checked: a person's logged approval that covers exactly this
14
+ // recipient, amount and currency, and the payout gates (age, sanctions, holds), read
15
+ // again now because standing can change after the approval (core checkApproval).
16
+ // A no is recorded as a `held` attempt with the reason; the debt stays.
17
+ // 3. Only then is the processor asked to transfer, under a per-debt lock, with an
18
+ // idempotency key derived from the debt's id. A success books the `sent` attempt and
19
+ // the project-account `payout` movement in ONE transaction (ADR 0185: money rides
20
+ // inside the transaction, never a best-effort listener). A failure, a refusal or a
21
+ // timeout books a `failed` attempt with the reason, and nothing is re-sent on its own
22
+ // (ADR 0397 D7): the next person who presses pay retries with the SAME key, so the
23
+ // processor answers with the transfer it already made rather than a second one.
24
+ //
25
+ // WHAT THIS IS NOT. It holds no money and never names a processor (ADR 0397 consequence:
26
+ // only the adapter does). The processor is a port, { transfer(req) → { ref } }, handed in
27
+ // by the caller. With none connected, every payout is recorded as owed and held with
28
+ // `no_processor`, so nothing can move until the owner opens the account and the adapter
29
+ // lands. Rule runs (checkRun) are not paid here yet: their lines carry no task, and a
30
+ // payout without a task is refused by the project-account record.
31
+ //
32
+ // WHERE IT LIVES. Core, beside the approval log and the gates it must cite (a module may not
33
+ // reach those). The project-account record is the economy module's, so the `payout` movement
34
+ // is booked through its `economy.account` port, inside this file's transaction. With that
35
+ // module off there is nowhere to book a payment, so nothing is sent (`no_account_record`).
36
+ //
37
+ // Amounts are integer cents, as the approval is; the movement is booked in micro-dollars.
38
+
39
+ const seams = require('../module-seams');
40
+
41
+ const LOCK_SPACE = 1003183;
42
+ const TRANSFER_TIMEOUT_MS = 15_000;
43
+ const MICRO_PER_CENT = 10_000;
44
+ const CURRENCY = 'USD'; // the project account is kept in USD micro-dollars
45
+ const REASON_MAX = 300;
46
+
47
+ const isId = (v) => (typeof v === 'number' && Number.isSafeInteger(v) && v > 0) || (typeof v === 'string' && /^[1-9][0-9]{0,18}$/.test(v));
48
+
49
+ class PayoutRefusal extends Error {
50
+ constructor(code, message, status = 400) { super(message); this.code = code; this.status = status; }
51
+ }
52
+
53
+ // Plain words for each reason a payout waits, shown to the owner beside the debt.
54
+ const HELD_WORDS = Object.freeze({
55
+ no_processor: 'No payment processor is connected yet, so nothing was sent. The payment stays owed.',
56
+ no_account_record: 'The project account record is switched off, so the payment could not be booked and was not sent.',
57
+ currency_not_supported: 'The project account is kept in US dollars; this approval is in another currency.',
58
+ no_such_approval: 'The approval this payment cites could not be found.',
59
+ different_recipient: 'The approval is for a different person.',
60
+ different_amount: 'The approval is for a different amount or currency.',
61
+ recipient_gated: 'The person cannot be paid right now. They have been told why and can appeal.',
62
+ guardian_cosign_missing: 'The person is 16 or 17, and their guardian has not co-signed this payment yet.',
63
+ });
64
+
65
+ const clip = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, REASON_MAX) || 'unknown';
66
+ const idempotencyKey = (owedId) => `bongos-payout-${owedId}`;
67
+
68
+ // ── db half (pool injectable; tests hand in a stand-in) ─────────────────────
69
+
70
+ function defaultPool() { return require('./pool').pool; }
71
+ function defaultAuthority() { return require('./payout-approvals'); }
72
+ function defaultAccount() { return seams.resolveOptional('economy.account'); }
73
+
74
+ const OWED_COLS = 'id, builder_id, task_id, approval_id, amount_cents, currency, recorded_by, created_at';
75
+
76
+ function shapeOwed(r) {
77
+ if (!r) return null;
78
+ return {
79
+ ...r,
80
+ id: String(r.id), builder_id: String(r.builder_id), task_id: String(r.task_id),
81
+ approval_id: String(r.approval_id), recorded_by: String(r.recorded_by), amount_cents: Number(r.amount_cents),
82
+ };
83
+ }
84
+
85
+ async function insertAttempt(db, owedId, { outcome, processorRef = null, reason = null, actorId = null }) {
86
+ const { rows } = await db.query(
87
+ `INSERT INTO builder_payout_attempts (owed_id, outcome, processor_ref, reason, actor_id)
88
+ VALUES ($1,$2,$3,$4,$5) RETURNING id, owed_id, outcome, processor_ref, reason, created_at`,
89
+ [owedId, outcome, processorRef, reason === null ? null : clip(reason), actorId]);
90
+ return rows[0];
91
+ }
92
+
93
+ async function sentAttempt(db, owedId) {
94
+ const { rows } = await db.query(
95
+ "SELECT id, processor_ref, created_at FROM builder_payout_attempts WHERE owed_id = $1 AND outcome = 'sent'", [owedId]);
96
+ return rows[0] || null;
97
+ }
98
+
99
+ // Step 1: the debt. Reads the approval for who and how much (never the caller's word),
100
+ // then records the debt once. A second call for the same approval and task finds the row
101
+ // already there; an approval already spent on another task, or a task already owed under
102
+ // another approval, is refused.
103
+ async function recordOwed(db, { approvalId, taskId, actorId }) {
104
+ const ap = await db.query(
105
+ 'SELECT id, kind, recipient_id, amount_cents, currency FROM payout_approvals WHERE id = $1', [approvalId]);
106
+ const a = ap.rows[0];
107
+ if (!a || a.kind !== 'payout_approved') throw new PayoutRefusal('no_such_approval', 'There is no payout approval with that id.', 404);
108
+ const task = await db.query('SELECT id FROM tasks WHERE id = $1', [taskId]);
109
+ if (!task.rows[0]) throw new PayoutRefusal('no_such_task', 'There is no task with that id.', 404);
110
+
111
+ await db.query(
112
+ `INSERT INTO builder_payouts_owed (builder_id, task_id, approval_id, amount_cents, currency, recorded_by)
113
+ VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`,
114
+ [a.recipient_id, taskId, a.id, a.amount_cents, a.currency, actorId]);
115
+ const byApproval = shapeOwed((await db.query(`SELECT ${OWED_COLS} FROM builder_payouts_owed WHERE approval_id = $1`, [a.id])).rows[0]);
116
+ if (byApproval) {
117
+ if (byApproval.task_id !== String(taskId)) {
118
+ throw new PayoutRefusal('approval_already_used', `This approval already pays task ${byApproval.task_id}. One approval pays one task.`, 409);
119
+ }
120
+ return byApproval;
121
+ }
122
+ const byTask = shapeOwed((await db.query(
123
+ `SELECT ${OWED_COLS} FROM builder_payouts_owed WHERE builder_id = $1 AND task_id = $2`, [a.recipient_id, taskId])).rows[0]);
124
+ throw new PayoutRefusal('task_already_owed',
125
+ `This person is already owed for this task under approval ${byTask ? byTask.approval_id : 'another'}. A task is paid once.`, 409);
126
+ }
127
+
128
+ function withTimeout(promise, ms) {
129
+ let timer;
130
+ return Promise.race([
131
+ promise,
132
+ new Promise((_, rej) => { timer = setTimeout(() => rej(Object.assign(new Error('the processor did not answer in time'), { code: 'timeout' })), ms); }),
133
+ ]).finally(() => clearTimeout(timer));
134
+ }
135
+
136
+ // A transaction on a checked-out client when the pool can give one; a stand-in that is
137
+ // already a single connection runs the statements as they are (the payout-gates idiom).
138
+ async function inTx(pool, fn) {
139
+ if (typeof pool.connect !== 'function' || typeof pool.release === 'function') return fn(pool);
140
+ const client = await pool.connect();
141
+ try {
142
+ await client.query('BEGIN');
143
+ const out = await fn(client);
144
+ await client.query('COMMIT');
145
+ return out;
146
+ } catch (err) {
147
+ await client.query('ROLLBACK').catch(() => {});
148
+ throw err;
149
+ } finally {
150
+ client.release();
151
+ }
152
+ }
153
+
154
+ // Step 3: the send, under a lock on the debt so two presses at once cannot both send.
155
+ async function send(pool, owed, { processor, account, payTo, actorId, timeoutMs }) {
156
+ return inTx(pool, async (db) => {
157
+ await db.query('SELECT pg_advisory_xact_lock($1::int, hashtext($2::text))', [LOCK_SPACE, owed.id]);
158
+ const already = await sentAttempt(db, owed.id);
159
+ if (already) return { status: 'paid', already: true, processor_ref: already.processor_ref };
160
+ let ref;
161
+ try {
162
+ const out = await withTimeout(Promise.resolve(processor.transfer({
163
+ idempotencyKey: idempotencyKey(owed.id),
164
+ amountCents: owed.amount_cents,
165
+ currency: owed.currency,
166
+ builderId: owed.builder_id,
167
+ taskId: owed.task_id,
168
+ payTo,
169
+ })), timeoutMs);
170
+ ref = out && typeof out.ref === 'string' && out.ref.trim() ? out.ref.trim() : null;
171
+ if (!ref) throw Object.assign(new Error('the processor gave no transfer reference'), { code: 'no_reference' });
172
+ } catch (err) {
173
+ const reason = clip(`${err && err.code ? err.code + ': ' : ''}${err && err.message ? err.message : 'transfer failed'}`);
174
+ await insertAttempt(db, owed.id, { outcome: 'failed', reason, actorId });
175
+ return { status: 'failed', reason };
176
+ }
177
+ // If the database fails from here the transfer happened and nothing says so; the
178
+ // transaction rolls back, the debt still reads owed, and the next press sends the same
179
+ // idempotency key, so the processor hands back this transfer and it is booked then.
180
+ await insertAttempt(db, owed.id, { outcome: 'sent', processorRef: ref, actorId });
181
+ await account.recordMovement(db, {
182
+ kind: 'payout', amountMicro: owed.amount_cents * MICRO_PER_CENT,
183
+ builderId: owed.builder_id, taskId: owed.task_id, processorRef: ref,
184
+ });
185
+ return { status: 'paid', already: false, processor_ref: ref };
186
+ });
187
+ }
188
+
189
+ // payForTask({ approvalId, taskId, actorId }, { pool, authority, account, processor }) →
190
+ // { owed, status: 'paid' | 'held' | 'failed', reason?, words?, processor_ref?, already? }
191
+ // Throws PayoutRefusal only BEFORE a debt exists (bad ids, no such approval or task, an
192
+ // approval or task already spoken for). Once the debt is recorded every outcome is a
193
+ // returned status, and the debt stays until a `sent` attempt settles it. authority and
194
+ // account default to core's approval log and the `economy.account` port; tests inject both.
195
+ async function payForTask({ approvalId, taskId, actorId }, {
196
+ pool = defaultPool(), authority = defaultAuthority(), account = defaultAccount(),
197
+ processor = null, timeoutMs = TRANSFER_TIMEOUT_MS,
198
+ } = {}) {
199
+ if (!isId(approvalId)) throw new PayoutRefusal('bad_approval_id', 'approval_id must be a number.');
200
+ if (!isId(taskId)) throw new PayoutRefusal('bad_task_id', 'task_id must be a number.');
201
+ if (!isId(actorId)) throw new PayoutRefusal('no_actor', 'A payout is pressed by a signed-in person.', 403);
202
+ if (!authority || typeof authority.checkApproval !== 'function') throw new Error('payForTask: no payout authority');
203
+
204
+ const owed = await recordOwed(pool, { approvalId, taskId, actorId });
205
+ const paid = await sentAttempt(pool, owed.id);
206
+ if (paid) return { owed, status: 'paid', already: true, processor_ref: paid.processor_ref };
207
+
208
+ const held = async (reason) => {
209
+ await insertAttempt(pool, owed.id, { outcome: 'held', reason, actorId });
210
+ return { owed, status: 'held', reason, words: HELD_WORDS[reason] || null };
211
+ };
212
+ if (owed.currency !== CURRENCY) return held('currency_not_supported');
213
+
214
+ const auth = await authority.checkApproval({
215
+ id: owed.approval_id, kind: 'payout_approved', recipientId: owed.builder_id,
216
+ amountCents: owed.amount_cents, currency: owed.currency,
217
+ }, { pool });
218
+ if (!auth || !auth.ok) return held((auth && auth.reason) || 'no_such_approval');
219
+
220
+ if (!account || typeof account.recordMovement !== 'function') return held('no_account_record');
221
+ if (!processor || typeof processor.transfer !== 'function') return held('no_processor');
222
+
223
+ const out = await send(pool, owed, { processor, account, payTo: auth.pay_to, actorId, timeoutMs });
224
+ return { owed, ...out };
225
+ }
226
+
227
+ // listPayouts(db, { limit, offset }) — every debt with where it stands, newest first. A
228
+ // debt is `paid` once it has a sent attempt; otherwise `owed`, with its latest try.
229
+ async function listPayouts(db = defaultPool(), { limit = 50, offset = 0 } = {}) {
230
+ const lim = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
231
+ const off = Math.max(parseInt(offset, 10) || 0, 0);
232
+ const [rows, count] = await Promise.all([
233
+ db.query(
234
+ `SELECT o.id, o.builder_id, b.display_name AS builder_name, o.task_id, t.title AS task_title,
235
+ o.approval_id, o.amount_cents, o.currency, o.created_at,
236
+ s.processor_ref AS sent_ref, s.created_at AS paid_at,
237
+ l.outcome AS last_outcome, l.reason AS last_reason, l.created_at AS last_try_at
238
+ FROM builder_payouts_owed o
239
+ LEFT JOIN builders b ON b.id = o.builder_id
240
+ LEFT JOIN tasks t ON t.id = o.task_id
241
+ LEFT JOIN builder_payout_attempts s ON s.owed_id = o.id AND s.outcome = 'sent'
242
+ LEFT JOIN LATERAL (
243
+ SELECT outcome, reason, created_at FROM builder_payout_attempts a
244
+ WHERE a.owed_id = o.id ORDER BY a.id DESC LIMIT 1) l ON true
245
+ ORDER BY o.created_at DESC, o.id DESC
246
+ LIMIT $1 OFFSET $2`, [lim, off]),
247
+ db.query('SELECT count(*) AS n FROM builder_payouts_owed'),
248
+ ]);
249
+ return {
250
+ total: Number(count.rows[0].n),
251
+ payouts: rows.rows.map((r) => ({
252
+ id: String(r.id), builder_id: String(r.builder_id), builder_name: r.builder_name,
253
+ task_id: String(r.task_id), task_title: r.task_title, approval_id: String(r.approval_id),
254
+ amount_cents: Number(r.amount_cents), currency: r.currency, created_at: r.created_at,
255
+ status: r.sent_ref ? 'paid' : 'owed',
256
+ processor_ref: r.sent_ref || null, paid_at: r.paid_at || null,
257
+ last_try: r.last_outcome ? { outcome: r.last_outcome, reason: r.last_reason, at: r.last_try_at, words: HELD_WORDS[r.last_reason] || null } : null,
258
+ })),
259
+ };
260
+ }
261
+
262
+ // One object, as payout-gates.js does: the router reaches it through an injectable default.
263
+ const api = { PayoutRefusal, idempotencyKey, payForTask, listPayouts };
264
+ module.exports = api;
@@ -26,7 +26,8 @@
26
26
 
27
27
  const express = require('express');
28
28
  const auth = require('../auth');
29
- const { validateOrRespond } = require('./_helpers');
29
+ const { validateOrRespond, parsePagination, pageMeta } = require('./_helpers');
30
+ const builderPayouts = require('../builder-payouts');
30
31
  const log = require('../logger').logger.child({ src: 'payout-approvals' });
31
32
 
32
33
  const PERSONAL_REFUSALS = {
@@ -57,6 +58,10 @@ function cleanDoc(raw) {
57
58
  module.exports = function buildPayoutApprovalsRouter({
58
59
  store = require('../payout-approvals'),
59
60
  gates = require('../payout-gates'),
61
+ payouts = builderPayouts,
62
+ // The processor adapter (ADR 0397: the only code that may name the rail) does not exist
63
+ // yet, so nothing is sent: every builder payout is recorded as owed and held.
64
+ processor = null,
60
65
  requireBuilder = auth.requireBuilder,
61
66
  requirePermission = auth.requirePermission,
62
67
  } = {}) {
@@ -308,5 +313,40 @@ module.exports = function buildPayoutApprovalsRouter({
308
313
  // POST /api/bongos/payouts/contracts — sign ONE contract. Body as for grants.
309
314
  router.post('/payouts/contracts', requireBuilder, requirePermission('payout.approve'), (req, res) => signDoc(req, res, '/payouts/contracts', 'contract_signed'));
310
315
 
316
+ // GET /api/bongos/payouts/builder-payouts — every builder payout and where it stands
317
+ // (task 1003183, M04): paid, or owed with its latest try and the reason in words.
318
+ router.get('/payouts/builder-payouts', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
319
+ try {
320
+ const { limit, offset } = parsePagination(req, { defaultLimit: 50, maxLimit: 200 });
321
+ const out = await payouts.listPayouts(undefined, { limit, offset });
322
+ res.set('Cache-Control', 'no-store');
323
+ res.json({ payouts: out.payouts, page: pageMeta(limit, offset, out.total) });
324
+ } catch (err) {
325
+ log.error({ err: err && err.message }, 'GET /payouts/builder-payouts failed');
326
+ if (!res.headersSent) res.fail('payouts_read_failed', { status: 500, message: 'internal error' });
327
+ }
328
+ });
329
+
330
+ // POST /api/bongos/payouts/builder-payouts — send ONE approved payout for ONE task
331
+ // (task 1003183, M04). Body: { approval_id, task_id }. The person and the amount come
332
+ // from the approval, never the body. A person presses it on the website: the approval
333
+ // says what may be paid, and this is the person sending it (Art. I). Pressing again is
334
+ // safe: a paid task answers paid, an owed one retries with the same processor key.
335
+ router.post('/payouts/builder-payouts', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
336
+ const actor = personal(req, res);
337
+ if (!actor) return;
338
+ if (validateOrRespond(req, res, { approval_id: { required: true }, task_id: { required: true } })) return;
339
+ const body = req.body || {};
340
+ try {
341
+ const out = await payouts.payForTask({ approvalId: body.approval_id, taskId: body.task_id, actorId: actor.id }, { processor });
342
+ res.set('Cache-Control', 'no-store');
343
+ res.json(out);
344
+ } catch (err) {
345
+ if (err instanceof payouts.PayoutRefusal) return res.fail(err.code, { status: err.status, message: err.message });
346
+ log.error({ err: err && err.message }, 'POST /payouts/builder-payouts failed');
347
+ if (!res.headersSent) res.fail('payout_failed', { status: 500, message: 'internal error' });
348
+ }
349
+ });
350
+
311
351
  return router;
312
352
  };
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.16'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.18'; // 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');
@@ -246,6 +246,8 @@ test('which key runs: theirs whenever they have one, unless they ask for ours',
246
246
  assert.deepEqual(K.chooseKey(['openrouter'], 'ours'), { source: 'ours' });
247
247
  assert.deepEqual(K.chooseKey([], 'own'), { error: 'no_own_key' });
248
248
  assert.deepEqual(K.chooseKey(['openrouter'], 'free'), { error: 'bad_pay_with' });
249
+ assert.deepEqual(K.chooseKey(['gemini', 'openrouter'], 'gemini'), { source: 'own', provider: 'gemini' }, 'a named key wins over the default');
250
+ assert.deepEqual(K.chooseKey(['openrouter'], 'gemini'), { error: 'no_own_key' });
249
251
  });
250
252
 
251
253
  test('on their own key no money moves: one own_key row, price 0, cost noted', async () => {
@@ -0,0 +1,295 @@
1
+ // tests/builder_payouts.mjs — a builder is paid for a task, exactly once (task 1003183,
2
+ // goal 1000092 M04; ADR 0385 D4, ADR 0397 D7). Behaviour of src/bongos/builder-payouts.js:
3
+ // - the debt is recorded before anything is sent, and stays when nothing is sent;
4
+ // - nothing is sent without the approval and the gates saying yes, or with no processor;
5
+ // - pressing pay again, or twice at once, sends once and books one movement;
6
+ // - a failed or timed-out transfer leaves the debt owed, and the retry reuses the same
7
+ // idempotency key so the processor cannot make a second transfer;
8
+ // - an approval pays one task, and a task is paid once.
9
+ // The stand-in below enforces the same unique rules the migration does, and holds the
10
+ // advisory lock until COMMIT, so the concurrency case is real. The SQL itself is proven
11
+ // against Postgres in tests/builder_payouts_db.mjs.
12
+
13
+ import { test } from 'node:test';
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const P = require('../src/bongos/builder-payouts.js');
19
+ const ACCOUNT = require('../modules/economy/project-account.js');
20
+
21
+ function fakeDb({ approvals = [], tasks = [1, 2] } = {}) {
22
+ const s = { owed: [], attempts: [], movements: [], approvals, tasks: tasks.map(String) };
23
+ let lockTail = Promise.resolve();
24
+ const unique = (msg) => Object.assign(new Error(msg), { code: '23505' });
25
+
26
+ async function run(sql, p = []) {
27
+ const q = sql.replace(/\s+/g, ' ').trim();
28
+ if (/FROM payout_approvals WHERE id = \$1/.test(q)) return { rows: s.approvals.filter((a) => String(a.id) === String(p[0])) };
29
+ if (/^SELECT id FROM tasks/.test(q)) return { rows: s.tasks.includes(String(p[0])) ? [{ id: p[0] }] : [] };
30
+ if (/^INSERT INTO builder_payouts_owed/.test(q)) {
31
+ const [builder_id, task_id, approval_id, amount_cents, currency, recorded_by] = p.map((v) => v);
32
+ const clash = s.owed.some((o) => String(o.approval_id) === String(approval_id)
33
+ || (String(o.builder_id) === String(builder_id) && String(o.task_id) === String(task_id)));
34
+ if (!clash) s.owed.push({ id: s.owed.length + 1, builder_id, task_id, approval_id, amount_cents, currency, recorded_by, created_at: new Date() });
35
+ return { rows: [] };
36
+ }
37
+ if (/FROM builder_payouts_owed WHERE approval_id = \$1/.test(q)) return { rows: s.owed.filter((o) => String(o.approval_id) === String(p[0])) };
38
+ if (/FROM builder_payouts_owed WHERE builder_id = \$1 AND task_id = \$2/.test(q)) {
39
+ return { rows: s.owed.filter((o) => String(o.builder_id) === String(p[0]) && String(o.task_id) === String(p[1])) };
40
+ }
41
+ if (/^INSERT INTO builder_payout_attempts/.test(q)) {
42
+ const [owed_id, outcome, processor_ref, reason] = p;
43
+ if (outcome === 'sent' && s.attempts.some((a) => String(a.owed_id) === String(owed_id) && a.outcome === 'sent')) throw unique('one sent per debt');
44
+ const row = { id: s.attempts.length + 1, owed_id, outcome, processor_ref, reason, created_at: new Date() };
45
+ s.attempts.push(row);
46
+ return { rows: [row] };
47
+ }
48
+ if (/FROM builder_payout_attempts WHERE owed_id = \$1 AND outcome = 'sent'/.test(q)) {
49
+ return { rows: s.attempts.filter((a) => String(a.owed_id) === String(p[0]) && a.outcome === 'sent') };
50
+ }
51
+ if (/^INSERT INTO economy_project_account_movements/.test(q)) {
52
+ const [kind, direction, amount_micro, builder_id, task_id, , processor_ref] = p;
53
+ if (s.movements.some((m) => m.kind === kind && m.processor_ref === processor_ref)) return { rows: [] };
54
+ const row = { id: s.movements.length + 1, kind, direction, amount_micro, builder_id, task_id, processor_ref };
55
+ s.movements.push(row);
56
+ return { rows: [row] };
57
+ }
58
+ if (/FROM economy_project_account_movements WHERE kind = \$1/.test(q)) return { rows: s.movements.filter((m) => m.kind === p[0] && m.processor_ref === p[1]) };
59
+ throw new Error(`fakeDb: unexpected SQL: ${q}`);
60
+ }
61
+
62
+ const pool = {
63
+ s,
64
+ query: run,
65
+ async connect() {
66
+ let release = null;
67
+ const client = {
68
+ async query(sql, p) {
69
+ const q = sql.trim();
70
+ if (q === 'BEGIN') return { rows: [] };
71
+ if (q === 'COMMIT' || q === 'ROLLBACK') { if (release) { release(); release = null; } return { rows: [] }; }
72
+ if (/pg_advisory_xact_lock/.test(q)) {
73
+ const prev = lockTail;
74
+ let done;
75
+ lockTail = new Promise((r) => { done = r; });
76
+ await prev;
77
+ release = done;
78
+ return { rows: [] };
79
+ }
80
+ return run(sql, p);
81
+ },
82
+ release() {},
83
+ };
84
+ return client;
85
+ },
86
+ };
87
+ return pool;
88
+ }
89
+
90
+ const APPROVAL = { id: 50, kind: 'payout_approved', recipient_id: 7, amount_cents: 12_500, currency: 'USD' };
91
+ const allow = { checkApproval: async () => ({ ok: true, pay_to: 'self' }), personalSession: () => ({ ok: true }) };
92
+
93
+ function fakeProcessor(behaviour = () => ({ ref: 'tr_1' })) {
94
+ const calls = [];
95
+ return { calls, async transfer(req) { calls.push(req); return behaviour(req, calls.length); } };
96
+ }
97
+
98
+ const press = (pool, extra = {}) => P.payForTask({ approvalId: 50, taskId: 1, actorId: 1 }, { pool, account: ACCOUNT, authority: allow, ...extra });
99
+
100
+ test('with no processor connected, the debt is recorded and held, and nothing moves', async () => {
101
+ const pool = fakeDb({ approvals: [APPROVAL] });
102
+ const out = await press(pool);
103
+ assert.equal(out.status, 'held');
104
+ assert.equal(out.reason, 'no_processor');
105
+ assert.ok(out.words, 'the owner is told why in plain words');
106
+ assert.equal(pool.s.owed.length, 1);
107
+ assert.equal(pool.s.owed[0].amount_cents, 12_500, 'the amount is the approval\'s, never the caller\'s');
108
+ assert.equal(pool.s.movements.length, 0);
109
+ });
110
+
111
+ test('an approved payout is sent once, booked as a payout movement in micro-dollars', async () => {
112
+ const pool = fakeDb({ approvals: [APPROVAL] });
113
+ const proc = fakeProcessor();
114
+ const out = await press(pool, { processor: proc });
115
+ assert.equal(out.status, 'paid');
116
+ assert.equal(out.processor_ref, 'tr_1');
117
+ assert.equal(proc.calls.length, 1);
118
+ assert.equal(proc.calls[0].idempotencyKey, P.idempotencyKey(pool.s.owed[0].id));
119
+ assert.equal(proc.calls[0].amountCents, 12_500);
120
+ assert.equal(pool.s.movements.length, 1);
121
+ assert.equal(pool.s.movements[0].kind, 'payout');
122
+ assert.equal(pool.s.movements[0].amount_micro, 125_000_000);
123
+ assert.equal(String(pool.s.movements[0].task_id), '1');
124
+ });
125
+
126
+ test('pressing pay again answers paid and sends nothing', async () => {
127
+ const pool = fakeDb({ approvals: [APPROVAL] });
128
+ const proc = fakeProcessor();
129
+ await press(pool, { processor: proc });
130
+ const again = await press(pool, { processor: proc });
131
+ assert.equal(again.status, 'paid');
132
+ assert.equal(again.already, true);
133
+ assert.equal(proc.calls.length, 1);
134
+ assert.equal(pool.s.movements.length, 1);
135
+ });
136
+
137
+ test('two presses at the same moment send once', async () => {
138
+ const pool = fakeDb({ approvals: [APPROVAL] });
139
+ const proc = fakeProcessor(async () => { await new Promise((r) => setTimeout(r, 20)); return { ref: 'tr_1' }; });
140
+ const [a, b] = await Promise.all([press(pool, { processor: proc }), press(pool, { processor: proc })]);
141
+ assert.deepEqual([a.status, b.status], ['paid', 'paid']);
142
+ assert.equal(proc.calls.length, 1, 'the second press waited on the lock and found it paid');
143
+ assert.equal(pool.s.attempts.filter((x) => x.outcome === 'sent').length, 1);
144
+ assert.equal(pool.s.movements.length, 1);
145
+ });
146
+
147
+ test('a failed transfer leaves the debt owed, and the retry reuses the same key', async () => {
148
+ const pool = fakeDb({ approvals: [APPROVAL] });
149
+ const proc = fakeProcessor((req, n) => { if (n === 1) throw Object.assign(new Error('card declined'), { code: 'declined' }); return { ref: 'tr_9' }; });
150
+ const first = await press(pool, { processor: proc });
151
+ assert.equal(first.status, 'failed');
152
+ assert.match(first.reason, /declined/);
153
+ assert.equal(pool.s.movements.length, 0);
154
+ assert.equal(pool.s.owed.length, 1, 'the debt is still on record');
155
+ const second = await press(pool, { processor: proc });
156
+ assert.equal(second.status, 'paid');
157
+ assert.equal(proc.calls[0].idempotencyKey, proc.calls[1].idempotencyKey);
158
+ assert.equal(pool.s.movements.length, 1);
159
+ });
160
+
161
+ test('a processor that never answers is recorded as a failed try, not a payment', async () => {
162
+ const pool = fakeDb({ approvals: [APPROVAL] });
163
+ const proc = fakeProcessor(() => new Promise(() => {}));
164
+ const out = await press(pool, { processor: proc, timeoutMs: 20 });
165
+ assert.equal(out.status, 'failed');
166
+ assert.match(out.reason, /timeout/);
167
+ assert.equal(pool.s.movements.length, 0);
168
+ });
169
+
170
+ test('a transfer with no reference is not booked as paid', async () => {
171
+ const pool = fakeDb({ approvals: [APPROVAL] });
172
+ const out = await press(pool, { processor: fakeProcessor(() => ({})) });
173
+ assert.equal(out.status, 'failed');
174
+ assert.equal(pool.s.attempts.filter((x) => x.outcome === 'sent').length, 0);
175
+ });
176
+
177
+ test('when the approval or the gates say no, the debt is held and the processor is never asked', async () => {
178
+ const pool = fakeDb({ approvals: [APPROVAL] });
179
+ const proc = fakeProcessor();
180
+ const gated = { checkApproval: async () => ({ ok: false, reason: 'recipient_gated' }) };
181
+ const out = await press(pool, { processor: proc, authority: gated });
182
+ assert.equal(out.status, 'held');
183
+ assert.equal(out.reason, 'recipient_gated');
184
+ assert.equal(proc.calls.length, 0);
185
+ assert.equal(pool.s.owed.length, 1);
186
+ });
187
+
188
+ test('the authority is asked about exactly the approval, person, amount and currency on record', async () => {
189
+ const pool = fakeDb({ approvals: [APPROVAL] });
190
+ let asked = null;
191
+ const spy = { checkApproval: async (q) => { asked = q; return { ok: true, pay_to: 'self' }; } };
192
+ await press(pool, { authority: spy });
193
+ assert.deepEqual(
194
+ { id: asked.id, kind: asked.kind, recipientId: asked.recipientId, amountCents: asked.amountCents, currency: asked.currency },
195
+ { id: '50', kind: 'payout_approved', recipientId: '7', amountCents: 12_500, currency: 'USD' });
196
+ });
197
+
198
+ test('a non-dollar approval is held, never converted', async () => {
199
+ const pool = fakeDb({ approvals: [{ ...APPROVAL, currency: 'EUR' }] });
200
+ const proc = fakeProcessor();
201
+ const out = await press(pool, { processor: proc });
202
+ assert.equal(out.reason, 'currency_not_supported');
203
+ assert.equal(proc.calls.length, 0);
204
+ });
205
+
206
+ test('one approval pays one task, and one task is paid once', async () => {
207
+ const pool = fakeDb({ approvals: [APPROVAL, { ...APPROVAL, id: 51 }] });
208
+ await press(pool);
209
+ await assert.rejects(() => P.payForTask({ approvalId: 50, taskId: 2, actorId: 1 }, { pool, account: ACCOUNT, authority: allow }),
210
+ (e) => e.code === 'approval_already_used' && e.status === 409);
211
+ await assert.rejects(() => P.payForTask({ approvalId: 51, taskId: 1, actorId: 1 }, { pool, account: ACCOUNT, authority: allow }),
212
+ (e) => e.code === 'task_already_owed' && e.status === 409);
213
+ assert.equal(pool.s.owed.length, 1);
214
+ });
215
+
216
+ test('a missing approval, a missing task or a bad id records nothing', async () => {
217
+ const pool = fakeDb({ approvals: [APPROVAL, { id: 60, kind: 'grant_signed', recipient_id: 7 }] });
218
+ const tries = [
219
+ [{ approvalId: 99, taskId: 1, actorId: 1 }, 'no_such_approval'],
220
+ [{ approvalId: 60, taskId: 1, actorId: 1 }, 'no_such_approval'],
221
+ [{ approvalId: 50, taskId: 9, actorId: 1 }, 'no_such_task'],
222
+ [{ approvalId: 'x', taskId: 1, actorId: 1 }, 'bad_approval_id'],
223
+ [{ approvalId: 50, taskId: 1, actorId: null }, 'no_actor'],
224
+ ];
225
+ for (const [input, code] of tries) {
226
+ await assert.rejects(() => P.payForTask(input, { pool, account: ACCOUNT, authority: allow }), (e) => e instanceof P.PayoutRefusal && e.code === code, code);
227
+ }
228
+ assert.equal(pool.s.owed.length, 0);
229
+ });
230
+
231
+ test('with no project-account record to book into, nothing is sent', async () => {
232
+ const pool = fakeDb({ approvals: [APPROVAL] });
233
+ const proc = fakeProcessor();
234
+ const out = await P.payForTask({ approvalId: 50, taskId: 1, actorId: 1 }, { pool, account: null, authority: allow, processor: proc });
235
+ assert.equal(out.status, 'held');
236
+ assert.equal(out.reason, 'no_account_record');
237
+ assert.equal(proc.calls.length, 0, 'a payment that could not be booked is never sent');
238
+ assert.equal(pool.s.owed.length, 1);
239
+ });
240
+
241
+ // ---- the routes, over real HTTP -------------------------------------------------
242
+
243
+ test('the pay route: a person on the website only, ids from the body, refusals in words', async () => {
244
+ const express = require('express');
245
+ const http = require('node:http');
246
+ const build = require('../src/bongos/routes/payout-approvals');
247
+ const asked = [];
248
+ const stubPayouts = {
249
+ PayoutRefusal: P.PayoutRefusal,
250
+ payForTask: async (input) => {
251
+ asked.push(input);
252
+ if (String(input.taskId) === '9') throw new P.PayoutRefusal('no_such_task', 'There is no task with that id.', 404);
253
+ return { status: 'held', reason: 'no_processor' };
254
+ },
255
+ listPayouts: async () => ({ total: 1, payouts: [{ id: '1', status: 'owed' }] }),
256
+ };
257
+ let who = { builder: { id: '7', rank: 'archon', system_role: null }, gdsSession: { source: 'web', session_id: '91' } };
258
+ const gated = [];
259
+ const app = express();
260
+ app.use(express.json());
261
+ app.use((req, res, next) => { res.fail = (code, { status = 400, message } = {}) => res.status(status).json({ error: code, message }); next(); });
262
+ app.use((req, res, next) => { req.builder = who.builder; req.gdsSession = who.gdsSession; next(); });
263
+ app.use('/', build({ payouts: stubPayouts, requireBuilder: (q, r, nx) => nx(), requirePermission: (...p) => { gated.push(p.join(',')); return (q, r, nx) => nx(); } }));
264
+ const server = http.createServer(app).listen(0);
265
+ const { port } = server.address();
266
+ const call = (method, path, body) => new Promise((resolve) => {
267
+ const r = http.request({ port, path, method, headers: { 'content-type': 'application/json' } }, (res) => {
268
+ let d = ''; res.on('data', (c) => (d += c)); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(d || 'null') }));
269
+ });
270
+ if (body) r.write(JSON.stringify(body));
271
+ r.end();
272
+ });
273
+ try {
274
+ assert.ok(gated.every((g) => g === 'payout.approve'), 'every payouts route gates on payout.approve');
275
+ let r = await call('POST', '/payouts/builder-payouts', { approval_id: 50, task_id: 1 });
276
+ assert.equal(r.status, 200);
277
+ assert.equal(r.body.status, 'held');
278
+ assert.deepEqual(asked[0], { approvalId: 50, taskId: 1, actorId: '7' }, 'the actor is the signed-in person, never the body');
279
+
280
+ r = await call('POST', '/payouts/builder-payouts', { approval_id: 50, task_id: 9 });
281
+ assert.equal(r.status, 404);
282
+ assert.equal(r.body.error, 'no_such_task');
283
+
284
+ r = await call('GET', '/payouts/builder-payouts');
285
+ assert.equal(r.status, 200);
286
+ assert.equal(r.body.payouts.length, 1);
287
+
288
+ who = { ...who, gdsSession: { source: 'cli', session_id: '92' } };
289
+ const before = asked.length;
290
+ r = await call('POST', '/payouts/builder-payouts', { approval_id: 50, task_id: 1 });
291
+ assert.equal(r.status, 403);
292
+ assert.equal(r.body.error, 'personal_approval_required');
293
+ assert.equal(asked.length, before, 'an AI (CLI) session cannot send a payment');
294
+ } finally { server.close(); }
295
+ });