@bongos/core 1.24.22 → 1.24.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.bongos-core.json +218 -63
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +42 -0
  4. package/clients/bongos-client/index.cjs +42 -0
  5. package/clients/bongos-client/index.d.ts +55 -1
  6. package/clients/bongos-client/index.mjs +42 -0
  7. package/docs/api/openapi.json +1181 -207
  8. package/docs/api-reference.md +30 -5
  9. package/docs/architecture.md +42 -0
  10. package/docs/copy-inventory.md +18 -8
  11. package/docs/copy-registry.json +99 -9
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-readings.json +64 -25
  14. package/migrations/core_291_payout_nda_tax.sql +75 -0
  15. package/migrations/core_292_module_sale_payouts.sql +170 -0
  16. package/migrations/core_293_cash_outs.sql +184 -0
  17. package/migrations/core_294_cash_out_attempts_sent_by_time.sql +21 -0
  18. package/modules/economy/ledger-check.js +360 -0
  19. package/modules/economy/migrations/economy_003_user_balances.sql +116 -0
  20. package/modules/economy/migrations/economy_004_module_share_payouts.sql +22 -0
  21. package/modules/economy/migrations/economy_005_project_account_holds_index.sql +20 -0
  22. package/modules/economy/migrations/economy_006_ledger_checks.sql +68 -0
  23. package/modules/economy/module.json +1 -1
  24. package/modules/economy/outflow.js +209 -0
  25. package/modules/economy/pollers/ledger-check.js +87 -0
  26. package/modules/economy/project-account.js +92 -7
  27. package/modules/economy/routes/balances.js +79 -0
  28. package/modules/economy/routes/project-account.js +40 -2
  29. package/modules/economy/user-balances.js +308 -0
  30. package/modules/government/decision-rules.js +1 -0
  31. package/modules/hall-ui/public/project-settings.css +18 -0
  32. package/modules/hall-ui/public/project-settings.html +15 -2
  33. package/modules/hall-ui/public/project-settings.js +142 -0
  34. package/modules/ideas/module.json +2 -1
  35. package/modules/ideas/routes/blockers.js +26 -0
  36. package/modules/lifecycle/db-claims.js +22 -0
  37. package/modules/lifecycle/db-ship.js +5 -5
  38. package/modules/lifecycle/funding-wall.js +253 -0
  39. package/modules/lifecycle/module.json +2 -1
  40. package/modules/lifecycle/routes/claims.js +9 -0
  41. package/modules/lifecycle/routes/pay-type-gates.js +7 -0
  42. package/modules/lifecycle/routes/project-settings.js +59 -2
  43. package/modules/onboarding/founding-birth.js +5 -2
  44. package/modules/onboarding/founding-mode.js +10 -0
  45. package/modules/onboarding/genesis-home.js +3 -0
  46. package/modules/provisioning/birth.js +11 -2
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +44 -0
  50. package/scripts/bongos/government-walk.js +1 -0
  51. package/scripts/bongos/plain-cards.js +18 -0
  52. package/scripts/bongos/run-unit-tests.js +28 -0
  53. package/src/bongos/builder-payouts.js +6 -1
  54. package/src/bongos/cash-outs.js +399 -0
  55. package/src/bongos/module-payouts.js +432 -0
  56. package/src/bongos/payout-approvals.js +5 -2
  57. package/src/bongos/payout-gates.js +44 -7
  58. package/src/bongos/project-settings.js +2 -0
  59. package/src/bongos/routes/module-payouts.js +114 -0
  60. package/src/bongos/routes/my-cash-outs.js +103 -0
  61. package/src/bongos/routes/payout-gates.js +42 -1
  62. package/src/bongos/routes.js +6 -0
  63. package/src/branding.js +3 -0
  64. package/src/module-api.js +1 -1
  65. package/tests/builder_payouts_db.mjs +1 -0
  66. package/tests/cash_outs.mjs +96 -0
  67. package/tests/cash_outs_db.mjs +205 -0
  68. package/tests/founding_birth.mjs +52 -12
  69. package/tests/founding_mode.mjs +5 -5
  70. package/tests/funded_project_pays_proof_db.mjs +181 -0
  71. package/tests/funding_wall.mjs +205 -0
  72. package/tests/funding_wall_db.mjs +174 -0
  73. package/tests/genesis_home.mjs +1 -1
  74. package/tests/government_act_alone_buildings.mjs +1 -0
  75. package/tests/ledger_check.mjs +189 -0
  76. package/tests/ledger_check_db.mjs +119 -0
  77. package/tests/module_payouts.mjs +295 -0
  78. package/tests/module_payouts_db.mjs +140 -0
  79. package/tests/own_scope_guard.mjs +5 -0
  80. package/tests/payout_gates.mjs +67 -6
  81. package/tests/payout_gates_db.mjs +23 -1
  82. package/tests/project_account_db.mjs +5 -2
  83. package/tests/project_outflow.mjs +103 -0
  84. package/tests/project_outflow_db.mjs +100 -0
  85. package/tests/provision_settings_apply.mjs +2 -1
  86. package/tests/provisioning_settings_env.mjs +7 -3
  87. package/tests/user_balances.mjs +241 -0
  88. package/tests/user_balances_db.mjs +137 -0
@@ -0,0 +1,308 @@
1
+ 'use strict';
2
+
3
+ // modules/economy/user-balances.js — A USER'S BALANCE, AND THE PAYMENTS THEY MAKE ON THEIR
4
+ // OWN INSTRUCTION, IN THIS PROJECT ONLY (task 1004907, goal 1000092 N3; ADR 0385, ADR 0387,
5
+ // ADR 0397 D2/D3).
6
+ //
7
+ // WHY. A builder who is paid needs to see what they have, fund the project they work on,
8
+ // and pay a teammate, without Bongos ever holding the money or taking a cut.
9
+ //
10
+ // THE PRODUCT GATE (legal framework, by Article only; a product constraint, not advice).
11
+ // actor the paying person, on their own signed-in web session; never a CLI, an agent
12
+ // or a standing rule (the route refuses every other session).
13
+ // custody the processor. Each user's money is in their own connected account, the
14
+ // project's in the project's (ADR 0397 D2). Bongos holds none and nets none:
15
+ // no balance is ever summed from these tables, a balance is the processor's figure.
16
+ // words balance, payment, transfer. Never deposit, wallet, interest, return or yield.
17
+ // control one instruction moves one amount once. Nothing repeats on its own.
18
+ // advice none is given.
19
+ // primitive the Stripe Connect transfer between connected accounts, and the connected
20
+ // account balance read (ADR 0397 D3 table). Only the processor adapter names
21
+ // Stripe; here it is a port.
22
+ //
23
+ // THE PROCESSOR PORT (all optional; with none connected nothing moves and every
24
+ // instruction is recorded and held `no_processor`):
25
+ // balanceMicro() the project's own account balance
26
+ // memberBalanceMicro(accountRef) one member's account balance
27
+ // confirmMemberAccount({ accountRef, builderId }) → true only for an account THIS
28
+ // project's connection opened for that person
29
+ // memberTransfer({ idempotencyKey, amountCents, currency, fromAccount, toAccount, toProject })
30
+ // → { ref }; toProject:true means this project's account
31
+ //
32
+ // ISOLATION. Each project has its own database (ADR 0100) and its own processor port. Every
33
+ // account a transfer names is read from this project's economy_member_accounts, never from
34
+ // the request, and a link is written only when this project's port confirms it. So a person
35
+ // cannot point a payment at, or read, an account another project opened.
36
+ //
37
+ // THE ORDER (the builder-payouts idiom): refuse what is malformed; record the instruction,
38
+ // once per instruction id; hold it if the processor is missing; otherwise send it under a
39
+ // lock with an idempotency key from the instruction's id, booking the `sent` attempt (and,
40
+ // for funding, the project-account `funding` movement) in ONE transaction. A failure is a
41
+ // recorded attempt with its reason and is never re-sent on its own (ADR 0397 D7).
42
+
43
+ const { recordMovement } = require('./project-account');
44
+
45
+ const LOCK_SPACE = 1004907;
46
+ const PROCESSOR_TIMEOUT_MS = 15_000;
47
+ const READ_TIMEOUT_MS = 5000;
48
+ const MICRO_PER_CENT = 10_000;
49
+ const CURRENCY = 'USD';
50
+ const NOTE_MAX = 300;
51
+ const REASON_MAX = 300;
52
+ const INSTRUCTION_RE = /^[A-Za-z0-9_-]{8,80}$/;
53
+ const ACCOUNT_RE = /^[A-Za-z0-9_.:-]{3,200}$/;
54
+
55
+ const isId = (v) => (typeof v === 'number' && Number.isSafeInteger(v) && v > 0) || (typeof v === 'string' && /^[1-9][0-9]{0,18}$/.test(v));
56
+ const clip = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, REASON_MAX) || 'unknown';
57
+ const idempotencyKey = (transferId) => `bongos-user-transfer-${transferId}`;
58
+
59
+ class TransferRefusal extends Error {
60
+ constructor(code, message, status = 400) { super(message); this.code = code; this.status = status; }
61
+ }
62
+
63
+ // Plain words for each reason an instruction waits.
64
+ const HELD_WORDS = Object.freeze({
65
+ no_processor: 'No payment processor is connected to this project yet, so nothing was sent.',
66
+ });
67
+
68
+ function withTimeout(promise, ms) {
69
+ let timer;
70
+ return Promise.race([
71
+ promise,
72
+ new Promise((_, rej) => { timer = setTimeout(() => rej(Object.assign(new Error('the processor did not answer in time'), { code: 'timeout' })), ms); }),
73
+ ]).finally(() => clearTimeout(timer));
74
+ }
75
+
76
+ // A transaction on a checked-out client when the pool can give one; a single connection
77
+ // (a test stand-in, or a caller's client) runs the statements as they are.
78
+ async function inTx(pool, fn) {
79
+ if (typeof pool.connect !== 'function' || typeof pool.release === 'function') return fn(pool);
80
+ const client = await pool.connect();
81
+ try {
82
+ await client.query('BEGIN');
83
+ const out = await fn(client);
84
+ await client.query('COMMIT');
85
+ return out;
86
+ } catch (err) {
87
+ await client.query('ROLLBACK').catch(() => {});
88
+ throw err;
89
+ } finally {
90
+ client.release();
91
+ }
92
+ }
93
+
94
+ // ── accounts ───────────────────────────────────────────────────────────────
95
+
96
+ async function memberAccount(db, builderId) {
97
+ const { rows } = await db.query(
98
+ 'SELECT processor_account FROM economy_member_accounts WHERE builder_id = $1 ORDER BY id DESC LIMIT 1', [builderId]);
99
+ return rows[0] ? rows[0].processor_account : null;
100
+ }
101
+
102
+ // linkMemberAccount(db, { builderId, accountRef, actorId }, { processor }) — called by the
103
+ // processor adapter when a person finishes the processor's own onboarding. There is no
104
+ // route that takes an account reference typed by a person: that is how one project's
105
+ // account would be pointed at another's.
106
+ async function linkMemberAccount(db, { builderId, accountRef, actorId = null }, { processor = null } = {}) {
107
+ if (!isId(builderId)) throw new TransferRefusal('bad_builder_id', 'builder_id must be a number.');
108
+ const ref = typeof accountRef === 'string' ? accountRef.trim() : '';
109
+ if (!ACCOUNT_RE.test(ref)) throw new TransferRefusal('bad_account', 'That is not a processor account reference.');
110
+ if (!processor || typeof processor.confirmMemberAccount !== 'function') {
111
+ throw new TransferRefusal('no_processor', HELD_WORDS.no_processor, 409);
112
+ }
113
+ const confirmed = await withTimeout(Promise.resolve(processor.confirmMemberAccount({ accountRef: ref, builderId: String(builderId) })), READ_TIMEOUT_MS);
114
+ if (confirmed !== true) {
115
+ throw new TransferRefusal('not_this_projects_account', "This project's processor connection did not open that account for this person.", 403);
116
+ }
117
+ const owner = await db.query('SELECT builder_id FROM economy_member_accounts WHERE processor_account = $1 LIMIT 1', [ref]);
118
+ if (owner.rows[0]) {
119
+ if (String(owner.rows[0].builder_id) !== String(builderId)) {
120
+ throw new TransferRefusal('account_belongs_to_someone_else', 'That account already belongs to someone else in this project.', 409);
121
+ }
122
+ if (await memberAccount(db, builderId) === ref) return { linked: false, account: ref };
123
+ throw new TransferRefusal('account_already_used', 'That account was linked to this person before and replaced; it cannot be linked again.', 409);
124
+ }
125
+ await db.query('INSERT INTO economy_member_accounts (builder_id, processor_account, linked_by) VALUES ($1,$2,$3)',
126
+ [builderId, ref, isId(actorId) ? actorId : null]);
127
+ return { linked: true, account: ref };
128
+ }
129
+
130
+ // ── reads: the processor's figure, never a sum of ours ─────────────────────
131
+
132
+ async function readFigure(fn, log, what) {
133
+ try {
134
+ const v = Number(await withTimeout(Promise.resolve(fn()), READ_TIMEOUT_MS));
135
+ if (!Number.isSafeInteger(v)) throw new Error('not a whole number');
136
+ return { micro: v, failed: false };
137
+ } catch (e) {
138
+ if (log) log.error({ err: e.message }, `user balances: ${what} unreadable`);
139
+ return { micro: null, failed: true };
140
+ }
141
+ }
142
+
143
+ const TRANSFER_SQL = `
144
+ SELECT t.id, t.kind, t.from_builder_id, fb.display_name AS from_name, t.to_builder_id, tb.display_name AS to_name,
145
+ t.amount_cents, t.currency, t.platform_fee_cents, t.note, t.created_at,
146
+ s.processor_ref AS sent_ref, l.outcome AS last_outcome, l.reason AS last_reason
147
+ FROM economy_user_transfers t
148
+ LEFT JOIN builders fb ON fb.id = t.from_builder_id
149
+ LEFT JOIN builders tb ON tb.id = t.to_builder_id
150
+ LEFT JOIN economy_user_transfer_attempts s ON s.transfer_id = t.id AND s.outcome = 'sent'
151
+ LEFT JOIN LATERAL (
152
+ SELECT outcome, reason FROM economy_user_transfer_attempts a
153
+ WHERE a.transfer_id = t.id ORDER BY a.id DESC LIMIT 1) l ON true`;
154
+
155
+ function shapeTransfer(r, viewerId) {
156
+ const status = r.sent_ref ? 'sent' : (r.last_outcome === 'failed' ? 'failed' : 'held');
157
+ return {
158
+ id: String(r.id), kind: r.kind,
159
+ direction: String(r.from_builder_id) === String(viewerId) ? 'out' : 'in',
160
+ from_builder_id: String(r.from_builder_id), from_name: r.from_name,
161
+ to_builder_id: r.to_builder_id === null || r.to_builder_id === undefined ? null : String(r.to_builder_id), to_name: r.to_name || null,
162
+ amount_cents: Number(r.amount_cents), currency: r.currency, platform_fee_cents: Number(r.platform_fee_cents),
163
+ note: r.note, created_at: r.created_at, status, processor_ref: r.sent_ref || null,
164
+ reason: status === 'sent' ? null : (r.last_reason || null),
165
+ words: status === 'held' ? (HELD_WORDS[r.last_reason] || null) : null,
166
+ };
167
+ }
168
+
169
+ // readMyBalance(db, builderId, { processor, limit, log }) — the person's balance as the
170
+ // processor reports it, and the payments they made or received in this project.
171
+ async function readMyBalance(db, builderId, { processor = null, limit = 20, log = null } = {}) {
172
+ const lim = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
173
+ const account = await memberAccount(db, builderId);
174
+ const recent = await db.query(
175
+ `${TRANSFER_SQL} WHERE t.from_builder_id = $1 OR t.to_builder_id = $1 ORDER BY t.id DESC LIMIT $2`, [builderId, lim]);
176
+ const out = {
177
+ unit: 'micro_usd', holds_money: false, source: 'processor',
178
+ connected: Boolean(account), balance_micro: null,
179
+ transfers: recent.rows.map((r) => shapeTransfer(r, builderId)),
180
+ };
181
+ if (account && processor && typeof processor.memberBalanceMicro === 'function') {
182
+ const read = await readFigure(() => processor.memberBalanceMicro(account), log, 'member balance');
183
+ out.balance_micro = read.micro;
184
+ if (read.failed) out.processor_error = 'Your balance could not be read from the processor just now.';
185
+ }
186
+ return out;
187
+ }
188
+
189
+ // readProjectBalance({ processor, log }) — the project's balance as the processor reports
190
+ // it. Only the figure: who was paid what stays on the owner's GET /project-account.
191
+ async function readProjectBalance({ processor = null, log = null } = {}) {
192
+ const out = { unit: 'micro_usd', holds_money: false, source: 'processor', balance_micro: null };
193
+ if (processor && typeof processor.balanceMicro === 'function') {
194
+ const read = await readFigure(() => processor.balanceMicro(), log, 'project balance');
195
+ out.balance_micro = read.micro;
196
+ if (read.failed) out.processor_error = "The project's balance could not be read from the processor just now.";
197
+ }
198
+ return out;
199
+ }
200
+
201
+ // ── the instruction ────────────────────────────────────────────────────────
202
+
203
+ function checkInstruction(i) {
204
+ if (i.kind !== 'fund_project' && i.kind !== 'pay_user') throw new TransferRefusal('bad_kind', 'A transfer either funds the project or pays a person.');
205
+ if (!isId(i.fromBuilderId)) throw new TransferRefusal('no_actor', 'A transfer is made by a signed-in person.', 403);
206
+ const amount = Number(i.amountCents);
207
+ if (!Number.isSafeInteger(amount) || amount <= 0) throw new TransferRefusal('bad_amount', 'amount_cents must be a positive whole number of cents.');
208
+ if (typeof i.instructionId !== 'string' || !INSTRUCTION_RE.test(i.instructionId)) {
209
+ throw new TransferRefusal('bad_instruction_id', 'instruction_id must be 8 to 80 letters, digits, dashes or underscores, one per payment.');
210
+ }
211
+ const note = String(i.note || '').trim();
212
+ if (note.length > NOTE_MAX) throw new TransferRefusal('note_too_long', `note is at most ${NOTE_MAX} characters`);
213
+ if (i.kind === 'pay_user') {
214
+ if (!isId(i.toBuilderId)) throw new TransferRefusal('bad_recipient', 'to_builder_id must be a number.');
215
+ if (String(i.toBuilderId) === String(i.fromBuilderId)) throw new TransferRefusal('self_payment', 'You cannot pay yourself.');
216
+ }
217
+ return { amount, note };
218
+ }
219
+
220
+ async function sentAttempt(db, transferId) {
221
+ const { rows } = await db.query(
222
+ "SELECT processor_ref FROM economy_user_transfer_attempts WHERE transfer_id = $1 AND outcome = 'sent'", [transferId]);
223
+ return rows[0] || null;
224
+ }
225
+
226
+ async function insertAttempt(db, transferId, { outcome, processorRef = null, reason = null }) {
227
+ await db.query('INSERT INTO economy_user_transfer_attempts (transfer_id, outcome, processor_ref, reason) VALUES ($1,$2,$3,$4)',
228
+ [transferId, outcome, processorRef, reason === null ? null : clip(reason)]);
229
+ }
230
+
231
+ // Record the instruction once. A repeat of the same instruction id returns the row already
232
+ // there, and is refused if it now asks for something different.
233
+ async function recordInstruction(db, row) {
234
+ await db.query(
235
+ `INSERT INTO economy_user_transfers (kind, from_builder_id, to_builder_id, from_account, to_account, amount_cents, currency, instruction_id, note)
236
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (from_builder_id, instruction_id) DO NOTHING`,
237
+ [row.kind, row.from_builder_id, row.to_builder_id, row.from_account, row.to_account, row.amount_cents, CURRENCY, row.instruction_id, row.note]);
238
+ const { rows } = await db.query(
239
+ 'SELECT * FROM economy_user_transfers WHERE from_builder_id = $1 AND instruction_id = $2', [row.from_builder_id, row.instruction_id]);
240
+ const had = rows[0];
241
+ const same = had && had.kind === row.kind && Number(had.amount_cents) === row.amount_cents
242
+ && String(had.to_builder_id ?? '') === String(row.to_builder_id ?? '');
243
+ if (!same) throw new TransferRefusal('instruction_reused', 'That instruction id was already used for a different payment.', 409);
244
+ return had;
245
+ }
246
+
247
+ // transfer(input, { pool, processor }) — fund the project or pay a person, on the payer's
248
+ // own instruction. input: { kind, fromBuilderId, toBuilderId?, amountCents, instructionId, note? }.
249
+ // Throws TransferRefusal only BEFORE the instruction is recorded; afterwards every outcome is
250
+ // a returned status: 'sent' | 'held' | 'failed'.
251
+ async function transfer(input, { pool, processor = null, timeoutMs = PROCESSOR_TIMEOUT_MS } = {}) {
252
+ const i = input || {};
253
+ const { amount, note } = checkInstruction(i);
254
+
255
+ const fromAccount = await memberAccount(pool, i.fromBuilderId);
256
+ if (!fromAccount) throw new TransferRefusal('no_account', 'You have no processor account in this project yet, so there is nothing to pay from.', 409);
257
+ let toAccount = null;
258
+ if (i.kind === 'pay_user') {
259
+ // only a person with an account linked in THIS project can be paid
260
+ toAccount = await memberAccount(pool, i.toBuilderId);
261
+ if (!toAccount) throw new TransferRefusal('recipient_has_no_account', 'That person has no processor account in this project, so they cannot be paid here.', 409);
262
+ }
263
+
264
+ const row = await recordInstruction(pool, {
265
+ kind: i.kind, from_builder_id: String(i.fromBuilderId), to_builder_id: i.kind === 'pay_user' ? String(i.toBuilderId) : null,
266
+ from_account: fromAccount, to_account: toAccount, amount_cents: amount, instruction_id: i.instructionId, note,
267
+ });
268
+ const transferId = String(row.id);
269
+ const base = { transfer_id: transferId, kind: row.kind, amount_cents: Number(row.amount_cents), platform_fee_cents: 0 };
270
+
271
+ const paid = await sentAttempt(pool, transferId);
272
+ if (paid) return { ...base, status: 'sent', already: true, processor_ref: paid.processor_ref };
273
+ if (!processor || typeof processor.memberTransfer !== 'function') {
274
+ await insertAttempt(pool, transferId, { outcome: 'held', reason: 'no_processor' });
275
+ return { ...base, status: 'held', reason: 'no_processor', words: HELD_WORDS.no_processor };
276
+ }
277
+
278
+ return inTx(pool, async (db) => {
279
+ await db.query('SELECT pg_advisory_xact_lock($1::int, hashtext($2::text))', [LOCK_SPACE, transferId]);
280
+ const already = await sentAttempt(db, transferId);
281
+ if (already) return { ...base, status: 'sent', already: true, processor_ref: already.processor_ref };
282
+ let ref;
283
+ try {
284
+ // the accounts are the ones recorded with the instruction, never re-read from the request
285
+ const out = await withTimeout(Promise.resolve(processor.memberTransfer({
286
+ idempotencyKey: idempotencyKey(transferId),
287
+ amountCents: Number(row.amount_cents), currency: CURRENCY,
288
+ fromAccount: row.from_account, toAccount: row.to_account, toProject: row.kind === 'fund_project',
289
+ })), timeoutMs);
290
+ ref = out && typeof out.ref === 'string' && out.ref.trim() ? out.ref.trim() : null;
291
+ if (!ref) throw Object.assign(new Error('the processor gave no transfer reference'), { code: 'no_reference' });
292
+ } catch (err) {
293
+ const reason = clip(`${err && err.code ? err.code + ': ' : ''}${err && err.message ? err.message : 'transfer failed'}`);
294
+ await insertAttempt(db, transferId, { outcome: 'failed', reason });
295
+ return { ...base, status: 'failed', reason };
296
+ }
297
+ await insertAttempt(db, transferId, { outcome: 'sent', processorRef: ref });
298
+ if (row.kind === 'fund_project') {
299
+ await recordMovement(db, {
300
+ kind: 'funding', amountMicro: Number(row.amount_cents) * MICRO_PER_CENT,
301
+ builderId: row.from_builder_id, processorRef: ref, note: `funded by a member (transfer ${transferId})`,
302
+ });
303
+ }
304
+ return { ...base, status: 'sent', already: false, processor_ref: ref };
305
+ });
306
+ }
307
+
308
+ module.exports = { TransferRefusal, HELD_WORDS, memberAccount, linkMemberAccount, readMyBalance, readProjectBalance, transfer };
@@ -68,6 +68,7 @@ const AREAS = Object.freeze([
68
68
  'PATCH /project-settings/ship-mode',
69
69
  'PATCH /project-settings/tweak-autopickup',
70
70
  'PATCH /project-settings/pay-terms', // task 1004990: the project's standing pay terms
71
+ 'PATCH /project-settings/funding-wall', // task 1003185: the project's funding rule (ADR 0386)
71
72
  'PATCH /work-categories/:id',
72
73
  // task 1004941: the owner approving a Market Watch proposal (a passed
73
74
  // matter approves it through the market_watch_proposal act instead)
@@ -246,3 +246,21 @@
246
246
  cursor: pointer;
247
247
  }
248
248
  .ps-ship__choice input { margin-top: 4px; flex: 0 0 auto; }
249
+
250
+ /* Where the money went (task 1003184). Four lead figures that wrap two-by-two on a
251
+ phone; the tables scroll inside their own strip, never the page (hall-ui/CLAUDE.md). */
252
+ .ps-money__figs {
253
+ display: grid;
254
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
255
+ gap: 12px;
256
+ margin-top: 6px;
257
+ }
258
+ .ps-money__fig { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
259
+ .ps-money__label { font-size: 12px; color: var(--ink-soft); }
260
+ .ps-money__value { font-size: 20px; font-variant-numeric: tabular-nums; }
261
+ .ps-money__note { font-size: 12px; color: var(--ink-soft); }
262
+ .ps-money__fig--warn .ps-money__value { color: var(--warn); }
263
+ .ps-money__fig--bad .ps-money__value { color: var(--danger); }
264
+ .ps-money__h { margin: 20px 0 8px; font-size: 14px; }
265
+ .ps-money__wrap { overflow-x: auto; }
266
+ .ps-money__wrap .ledger td { font-variant-numeric: tabular-nums; }
@@ -10,7 +10,7 @@
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
11
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
12
  <link rel="stylesheet" href="/builders/oversight.css">
13
- <link rel="stylesheet" href="/builders/project-settings.css">
13
+ <link rel="stylesheet" href="/builders/project-settings.css?v=2026-11-02-outflow">
14
14
  </head>
15
15
  <body data-page="project-settings" data-page-title="Project settings">
16
16
 
@@ -48,6 +48,19 @@
48
48
  thing this page exists to end. -->
49
49
  <div id="ps-rows"><span class="kit-skel"></span></div>
50
50
  </section>
51
+ <!-- Where the money went (task 1003184, criterion money-outflow-visible). Hidden
52
+ unless GET /project-account/outflow answers — it is the payout authority's
53
+ read (payout.approve, the owner), so every other reader of this page never
54
+ sees an empty panel. Written by project-settings.js; every figure is the
55
+ server's, summed from records that already exist. -->
56
+ <section class="scroll" id="ps-money" aria-labelledby="ps-money-h" hidden>
57
+ <h2 class="scroll__h" id="ps-money-h">Where the money went</h2>
58
+ <p class="scroll__lede">
59
+ What this project has spent, what each builder has been paid, and what it has
60
+ promised but not paid yet. A balance only means something after those promises.
61
+ </p>
62
+ <div id="ps-money-body"></div>
63
+ </section>
51
64
  <!-- Danger zone (task 1004570): where this project's OWNER takes it offline or
52
65
  disconnects it. Both controls live on the platform that hosts the project,
53
66
  so this only links there. Hidden unless GET /project/manage-link answers
@@ -76,6 +89,6 @@
76
89
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
77
90
  <script src="/builders/palette.js" defer></script>
78
91
  <script src="/builders/hall-kit.js"></script>
79
- <script src="/builders/project-settings.js?v=2026-10-29-tweak-cap"></script>
92
+ <script src="/builders/project-settings.js?v=2026-11-02-outflow"></script>
80
93
  </body>
81
94
  </html>
@@ -417,6 +417,148 @@
417
417
  $('ps-danger').hidden = false;
418
418
  }
419
419
 
420
+ // ---- where the money went (task 1003184, criterion money-outflow-visible) --------
421
+ //
422
+ // The owner's outflow view. The server sums every figure (modules/economy/outflow.js);
423
+ // this only draws it. It leads with what is left AFTER what is owed, because a balance
424
+ // that looks healthy while it owes three payouts is the trap the view exists to show.
425
+ // Any outcome but a 200 leaves the section hidden: the read is the payout authority's,
426
+ // and everyone else on this page has nothing to see here, not an error.
427
+
428
+ const money = (micro) => {
429
+ const usd = Number(micro || 0) / 1e6;
430
+ const abs = Math.abs(usd).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
431
+ return `${usd < 0 ? '−' : ''}$${abs}`;
432
+ };
433
+ const monthLabel = (ym) => {
434
+ const [y, m] = String(ym).split('-').map(Number);
435
+ return new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString(undefined, { month: 'short', year: 'numeric', timeZone: 'UTC' });
436
+ };
437
+ const dayLabel = (iso) => (iso ? new Date(iso).toLocaleDateString() : '');
438
+
439
+ function moneyFiguresHtml(out) {
440
+ const c = out.committed;
441
+ const after = out.balance.after_commitments_micro;
442
+ const fig = (label, value, note, tone) => `<div class="ps-money__fig${tone ? ` ps-money__fig--${tone}` : ''}">
443
+ <span class="ps-money__label">${escapeHtml(label)}</span>
444
+ <strong class="ps-money__value">${escapeHtml(value)}</strong>
445
+ <span class="ps-money__note">${escapeHtml(note)}</span>
446
+ </div>`;
447
+ const owedNote = c.owed_count === 1 ? '1 payment' : `${c.owed_count} payments`;
448
+ const apprNote = c.approved_count === 1 ? '1 approval' : `${c.approved_count} approvals`;
449
+ return `<div class="ps-money__figs">
450
+ ${fig('Balance', money(out.balance.available_micro), c.held_micro ? `after ${money(c.held_micro)} held` : 'on our record')}
451
+ ${fig('Owed but not paid', money(c.owed_micro), owedNote, c.owed_micro ? 'warn' : '')}
452
+ ${fig('Approved, not started', money(c.approved_micro), apprNote, c.approved_micro ? 'warn' : '')}
453
+ ${fig('Left after all of that', money(after), after < 0 ? 'the project owes more than it holds' : 'what is really free', after < 0 ? 'bad' : '')}
454
+ </div>${c.other_currency_count ? `<p class="ps-prov">${c.other_currency_count} more in a currency other than US dollars, not added in.</p>` : ''}`;
455
+ }
456
+
457
+ // The payout's latest try in words. A reason is either the processor's own sentence, shown
458
+ // as it is, or one of core's held codes (builder-payouts.js HELD_WORDS). The codes an owner
459
+ // can act on get a short phrase here; any other code is left out rather than shown as a slug.
460
+ const TRY_WORDS = { held: 'Waiting', failed: 'Did not go through' };
461
+ const REASON_WORDS = {
462
+ no_processor: 'no payment processor connected yet',
463
+ recipient_gated: 'this person cannot be paid right now',
464
+ guardian_cosign_missing: 'a guardian has not co-signed yet',
465
+ currency_not_supported: 'not in US dollars',
466
+ };
467
+ function lastTryWords(r) {
468
+ if (!r.last_outcome) return 'Not tried yet';
469
+ const reason = String(r.last_reason || '');
470
+ const why = REASON_WORDS[reason] || (/^[a-z_]+$/.test(reason) ? '' : reason);
471
+ return `${TRY_WORDS[r.last_outcome] || r.last_outcome}${why ? ` — ${why}` : ''}`;
472
+ }
473
+
474
+ function owedHtml(out) {
475
+ const rows = out.committed.owed;
476
+ if (!rows.length) return '';
477
+ return `<h3 class="ps-money__h">Owed but not paid</h3>
478
+ <div class="ps-money__wrap"><table class="ledger">
479
+ <thead><tr><th>Builder</th><th>Task</th><th>Amount</th><th>Owed since</th><th>Last try</th></tr></thead>
480
+ <tbody>${rows.map((r) => `<tr>
481
+ <td>${escapeHtml(r.builder_name || `builder ${r.builder_id}`)}</td>
482
+ <td><a href="/builders/task/${encodeURIComponent(r.task_id)}">${escapeHtml(r.task_title || `task ${r.task_id}`)}</a></td>
483
+ <td>${escapeHtml(money(r.amount_micro))}</td>
484
+ <td>${escapeHtml(dayLabel(r.since))}</td>
485
+ <td>${escapeHtml(lastTryWords(r))}</td>
486
+ </tr>`).join('')}</tbody>
487
+ </table></div>`;
488
+ }
489
+
490
+ function buildersHtml(out) {
491
+ const rows = out.by_builder;
492
+ if (!rows.length) return '';
493
+ return `<h3 class="ps-money__h">By builder</h3>
494
+ <div class="ps-money__wrap"><table class="ledger">
495
+ <thead><tr><th>Builder</th><th>Spent</th><th>Paid</th><th>Owed</th><th>Approved</th></tr></thead>
496
+ <tbody>${rows.map((b) => `<tr>
497
+ <td>${escapeHtml(b.builder_name || `builder ${b.builder_id}`)}</td>
498
+ <td>${escapeHtml(money(b.spend_micro))}</td>
499
+ <td>${escapeHtml(money(b.paid_micro))}</td>
500
+ <td>${escapeHtml(money(b.owed_micro))}</td>
501
+ <td>${escapeHtml(money(b.approved_micro))}</td>
502
+ </tr>`).join('')}</tbody>
503
+ </table></div>
504
+ <p class="ps-prov">Spent is the recorded cost of each builder’s sessions (API, compute). Paid, owed and approved are payouts.</p>`;
505
+ }
506
+
507
+ function burnHtml(out) {
508
+ const months = out.burn.months;
509
+ if (!months.some((m) => m.total_micro > 0)) return '';
510
+ const bars = window.OTBKit.barSeriesHtml(
511
+ months.map((m) => ({ label: monthLabel(m.month), value: m.total_micro, valueLabel: money(m.total_micro) })),
512
+ { list: true, ariaLabel: 'Money out by month', valueWidth: '96px' });
513
+ return `<h3 class="ps-money__h">Money out, by month</h3>${bars}
514
+ <p class="ps-prov">Each month is recorded spend (${escapeHtml(money(months.reduce((s, m) => s + m.spend_micro, 0)))} over these ${months.length} months) plus payouts and fees out of the project account. Read from the cost log and the account’s own record.</p>`;
515
+ }
516
+
517
+ // The daily check of our record against the processor's (task 1004912). The newest check
518
+ // in words, and on a difference each one with both amounts. The blocker it raises is public
519
+ // and carries no numbers, so this is where the owner reads them.
520
+ const CHECK_WORDS = {
521
+ clean: 'Our records and the payment processor’s agree.',
522
+ drift: 'Our records and the payment processor’s differ.',
523
+ unchecked: 'Not checked: no payment processor is connected yet, so there is nothing to compare with.',
524
+ error: 'Could not check: the payment processor could not be read.',
525
+ };
526
+ function ledgerCheckHtml(out) {
527
+ const c = out && out.checks && out.checks[0];
528
+ if (!c) return '';
529
+ const words = out.kind_words || {};
530
+ const amt = (v) => (v === null || v === undefined ? '—' : money(v));
531
+ const rows = (c.findings || []).map((f) => `<tr>
532
+ <td>${escapeHtml(words[f.kind] || f.kind)}</td>
533
+ <td>${escapeHtml(f.ref || 'the project balance')}</td>
534
+ <td>${escapeHtml(amt(f.ours_micro))}</td>
535
+ <td>${escapeHtml(amt(f.theirs_micro))}</td>
536
+ </tr>`).join('');
537
+ return `<h3 class="ps-money__h">Daily check against the payment processor</h3>
538
+ <p class="ps-prov">${escapeHtml(dayLabel(c.created_at))}: ${escapeHtml(CHECK_WORDS[c.outcome] || c.outcome)}${c.outcome === 'clean' || c.outcome === 'drift' ? ` ${c.refs_compared} ${c.refs_compared === 1 ? 'movement' : 'movements'} compared.` : ''}</p>
539
+ ${rows ? `<div class="ps-money__wrap"><table class="ledger">
540
+ <thead><tr><th>Difference</th><th>Reference</th><th>Our record</th><th>Processor</th></tr></thead>
541
+ <tbody>${rows}</tbody>
542
+ </table></div>` : ''}`;
543
+ }
544
+
545
+ async function loadMoney() {
546
+ let out = null;
547
+ try {
548
+ const res = await api.request('GET', `${API}/project-account/outflow`);
549
+ out = res.ok ? res.data : null;
550
+ } catch (_) { out = null; }
551
+ if (!out || !out.balance || !out.committed) return;
552
+ let checks = null;
553
+ try {
554
+ const res = await api.request('GET', `${API}/project-account/ledger-checks?limit=1`);
555
+ checks = res.ok ? res.data : null;
556
+ } catch (_) { checks = null; }
557
+ $('ps-money-body').innerHTML = moneyFiguresHtml(out) + ledgerCheckHtml(checks) + owedHtml(out) + buildersHtml(out) + burnHtml(out);
558
+ $('ps-money').hidden = false;
559
+ }
560
+
420
561
  load();
562
+ loadMoney();
421
563
  loadDanger();
422
564
  })();
@@ -23,7 +23,8 @@
23
23
  "provides": [
24
24
  "ideas.capture",
25
25
  "ideas.descent",
26
- "ideas.ideatorStats"
26
+ "ideas.ideatorStats",
27
+ "ideas.fileBlocker"
27
28
  ],
28
29
  "consumes": [
29
30
  "reward",
@@ -79,8 +79,33 @@ function askRequired(req, body) {
79
79
  return !TEMPLATED_SOURCES.includes(body.source);
80
80
  }
81
81
 
82
+ // The `ideas.fileBlocker` port's writer: POST /blockers' shape and length rules, for a caller
83
+ // inside the server, which has no request to validate. A refusal throws with code BAD_BLOCKER.
84
+ function fileBlockerFromServer({ title, bodyMd = '', source, sourceRef } = {}) {
85
+ const bad = (why) => { throw Object.assign(new Error(`ideas.fileBlocker: ${why}`), { code: 'BAD_BLOCKER' }); };
86
+ const str = (v, max, name, required) => {
87
+ if (v === undefined || v === null) { if (required) bad(`${name} is required`); return; }
88
+ if (typeof v !== 'string') bad(`${name} must be a string`);
89
+ if (required && !v.trim()) bad(`${name} is required`);
90
+ if (v.length > max) bad(`${name} is at most ${max} characters`);
91
+ };
92
+ str(title, LIMITS.TITLE, 'title', true);
93
+ str(bodyMd, LIMITS.DESCRIPTION, 'bodyMd', false);
94
+ str(source, LIMITS.SOURCE, 'source', true);
95
+ str(sourceRef, LIMITS.SOURCE_REF, 'sourceRef', true);
96
+ return blockers.createBlocker({ title, bodyMd, source, sourceRef });
97
+ }
98
+
82
99
  module.exports = function buildBlockersRouter() {
83
100
  governorDocket.contributeIdeasToDocket();
101
+ // The `ideas.fileBlocker` PORT (task 1004912): the server's own checks raise a blocker for
102
+ // the owner without importing this module (the economy module's daily ledger check). Same
103
+ // idempotent writer as POST /blockers, keyed on (source, source_ref), held to the same
104
+ // length rules the route enforces. Registered here for the reason routes/inbox.js gives for
105
+ // `ideas.capture`; hasProvider-guarded for re-runs.
106
+ if (!api.hasProvider('ideas.fileBlocker')) {
107
+ api.registerProvider('ideas.fileBlocker', fileBlockerFromServer);
108
+ }
84
109
  const router = express.Router();
85
110
 
86
111
  // List currently-open blockers + the tasks each is blocking.
@@ -282,3 +307,4 @@ module.exports = function buildBlockersRouter() {
282
307
 
283
308
  // The scripts whose templated blockers file with or without a card (task 1004704).
284
309
  module.exports.TEMPLATED_SOURCES = TEMPLATED_SOURCES;
310
+ module.exports.fileBlockerFromServer = fileBlockerFromServer;
@@ -32,6 +32,8 @@ const { relevanceFrictionFailures } = require('./db-relevance-flags.js');
32
32
  // task 1004950 / ADR 0395: a task that needs server access refuses a claimer
33
33
  // without the grant — the third extracted gate in the same shape.
34
34
  const { taskNeedsFailures } = require('./task-needs-gate.js');
35
+ // task 1003185 / ADR 0386: the funding wall — the same shape, and it PLACES the hold.
36
+ const { fundingWallFailures } = require('./funding-wall.js');
35
37
  const { unsatisfiedDeps } = require('./db-deps-criteria.js');
36
38
  // depRefusalRow: the ONE shape a DEPS_NOT_SHIPPED refusal carries. All three
37
39
  // claim paths below use it — hand-building the object here is what made task
@@ -378,6 +380,11 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
378
380
  if (refusal) throw refusal;
379
381
  }
380
382
 
383
+ // task 1003185 / ADR 0386: the funding wall, last before the insert, so a task's
384
+ // cash pay is held only once every other gate has passed (a later rollback undoes it).
385
+ const fundingRefusals = await fundingWallFailures({ client, tasks: [task], place: true });
386
+ if (fundingRefusals.length > 0) throw fundingRefusals[0].refusal;
387
+
381
388
  // Pre-allocate a migration number if the task signals it'll add one.
382
389
  // Trigger: the tasks.needs_migration boolean (migration 081, touches[]
383
390
  // cleanup [1/8] / task 876). Replaced the old touches[]-contains-'migrations/'
@@ -777,6 +784,15 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
777
784
  }
778
785
  }
779
786
 
787
+ // task 1003185: the funding wall, judged only for a batch every other gate passed,
788
+ // because it holds the whole batch's cash pay at once (each task against the ones before it).
789
+ if (failures.length === 0) {
790
+ for (const f of await fundingWallFailures({ client, tasks: taskRows, place: true })) {
791
+ const { code, ...rest } = f.refusal;
792
+ failures.push({ task_id: f.task_id, reason: code, ...rest });
793
+ }
794
+ }
795
+
780
796
  if (failures.length > 0) {
781
797
  throw { code: 'BATCH_FAILED', failures };
782
798
  }
@@ -1042,6 +1058,12 @@ async function validateClaimBatch({ taskIds, builderId }) {
1042
1058
  push(f.task_id, { reason: code, ...rest });
1043
1059
  }
1044
1060
 
1061
+ // task 1003185: the funding wall as a read (no lock, no hold), kept in sync with the two above.
1062
+ for (const f of await fundingWallFailures({ client: pool, tasks: taskRows })) {
1063
+ const { code, ...rest } = f.refusal;
1064
+ push(f.task_id, { reason: code, ...rest });
1065
+ }
1066
+
1045
1067
  // Dependencies — external to the batch only (a batch-internal dep is fine;
1046
1068
  // the batch is worked serially, same rule as claimTasksBatch).
1047
1069
  const batchIdSet = new Set(uniqIds);