@bongos/core 1.24.23 → 1.24.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.bongos-core.json +212 -57
  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/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +44 -0
  46. package/scripts/bongos/government-walk.js +1 -0
  47. package/scripts/bongos/plain-cards.js +18 -0
  48. package/scripts/bongos/run-unit-tests.js +28 -0
  49. package/src/bongos/builder-payouts.js +6 -1
  50. package/src/bongos/cash-outs.js +399 -0
  51. package/src/bongos/module-payouts.js +432 -0
  52. package/src/bongos/package-registry.js +3 -2
  53. package/src/bongos/payout-approvals.js +5 -2
  54. package/src/bongos/payout-gates.js +44 -7
  55. package/src/bongos/project-settings.js +2 -0
  56. package/src/bongos/routes/core-update.js +11 -3
  57. package/src/bongos/routes/module-payouts.js +114 -0
  58. package/src/bongos/routes/my-cash-outs.js +103 -0
  59. package/src/bongos/routes/payout-gates.js +42 -1
  60. package/src/bongos/routes.js +6 -0
  61. package/src/bongos/software-update.js +36 -16
  62. package/src/module-api.js +1 -1
  63. package/tests/builder_payouts_db.mjs +1 -0
  64. package/tests/cash_outs.mjs +96 -0
  65. package/tests/cash_outs_db.mjs +205 -0
  66. package/tests/funded_project_pays_proof_db.mjs +181 -0
  67. package/tests/funding_wall.mjs +205 -0
  68. package/tests/funding_wall_db.mjs +174 -0
  69. package/tests/government_act_alone_buildings.mjs +1 -0
  70. package/tests/ledger_check.mjs +189 -0
  71. package/tests/ledger_check_db.mjs +119 -0
  72. package/tests/module_payouts.mjs +295 -0
  73. package/tests/module_payouts_db.mjs +140 -0
  74. package/tests/own_scope_guard.mjs +5 -0
  75. package/tests/payout_gates.mjs +67 -6
  76. package/tests/payout_gates_db.mjs +23 -1
  77. package/tests/project_account_db.mjs +5 -2
  78. package/tests/project_outflow.mjs +103 -0
  79. package/tests/project_outflow_db.mjs +100 -0
  80. package/tests/software_update.mjs +61 -3
  81. package/tests/user_balances.mjs +241 -0
  82. package/tests/user_balances_db.mjs +137 -0
@@ -19,6 +19,7 @@ const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes, foldWorktreeName
19
19
  const { idKey, mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
20
20
  const { LIVE_STATUSES } = require('./dead-deps.js');
21
21
  const { isPageTweak } = require('./page-tweak-hold.js');
22
+ const { fundingWallOnResolve } = require('./funding-wall.js'); // task 1003185 / ADR 0386
22
23
  const pageTweakArtist = require('./page-tweak-artist.js');
23
24
 
24
25
  // shouldRefuseClaimResolve — may THIS caller resolve THIS claim? (task 1003767)
@@ -202,16 +203,15 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
202
203
  WHERE id = $1`,
203
204
  [claim.task_id, newTaskStatus, valueSummary ?? null]
204
205
  );
206
+ await fundingWallOnResolve(client, claim.task_id, outcome); // task 1003185 / ADR 0386
205
207
 
206
208
  // touches[] cleanup [7/8] (task 882): backfill touches[] from the REAL
207
209
  // committed diff at ship. The planning-time touches[] is now optional (a
208
210
  // prediction nobody has to get right up front); on a successful ship we
209
211
  // replace it with what git actually reports, so the declared set becomes
210
- // reality. This is the write path that makes the touches[]-keyed profile
211
- // counters (art_ships / pms_ships, see achievements + db leaderboard) and
212
- // the historical record diff-accurate — closing the loop left open in #878.
213
- // Only on a 'shipped' resolve with a non-empty list (never wipe touches[]
214
- // with an empty diff on a DB-only / no-op ship).
212
+ // reality. This is the write path that makes the touches[]-keyed profile counters (art_ships /
213
+ // pms_ships, see achievements + db leaderboard) and the record diff-accurate (loop left open in #878).
214
+ // Only on a 'shipped' resolve with a non-empty list (never wipe touches[] with an empty diff on a DB-only / no-op ship).
215
215
  if (outcome === 'shipped' && Array.isArray(committedFiles) && committedFiles.length > 0) {
216
216
  const cleaned = Array.from(
217
217
  new Set(committedFiles.map((p) => String(p || '').trim()).filter(Boolean))
@@ -0,0 +1,253 @@
1
+ // modules/lifecycle/funding-wall.js — AN UNDERFUNDED PROJECT CANNOT ACCRUE PAYABLE WORK
2
+ // (task 1003185, goal 1000092 M06; ADR 0386).
3
+ //
4
+ // WHY. A builder who finishes a task and only then learns the project cannot pay has
5
+ // carried the loss alone. ADR 0386 makes the rule the PROJECT's choice, with three
6
+ // options, and Bongos sets none of its own:
7
+ //
8
+ // hold_at_claim (the default) a claim is refused when the project's available
9
+ // balance cannot cover the task's cash pay; otherwise that pay is
10
+ // HELD against the balance while the task is worked, so several
11
+ // claims at once cannot over-commit one balance
12
+ // refuse_at_completion the task can be claimed and worked; declaring it done is
13
+ // refused while the balance is short
14
+ // carry_on nothing is refused; an unpaid amount is the project's own debt
15
+ //
16
+ // WHAT THE WALL READS. A task's price is its CASH pay terms (pay-terms.js; the task's own,
17
+ // else the project's standing terms), and the balance is the project account's record
18
+ // (the economy module's, through the `economy.account` port). A task with no cash term is
19
+ // never walled: credits, equity and the other pay types have no dollar balance here
20
+ // (ADR 0386: a non-cash wall reads the project's own terms for that pay type), and a
21
+ // self-managed term is money Bongos never sees. That is also why switching this on
22
+ // changes nothing for a project that pays no cash.
23
+ //
24
+ // EVERY REFUSAL STATES THE REASON AND THE SHORTFALL. "Insufficient balance" with no number
25
+ // is a dead end for the owner who has to act on it, so the refusal names the task's
26
+ // price, the balance, what is already held, and exactly how far short it is. A cash term
27
+ // whose amount is not a dollar figure is refused as unreadable, saying what it read: a
28
+ // guess at a number would be Bongos setting the pay.
29
+ //
30
+ // FAIL CLOSED. A cash-paying task under a rule that needs the account, with the account
31
+ // record switched off, is refused: the wall cannot be checked, and an unchecked wall is
32
+ // the loss this exists to prevent. A setting that cannot be read falls to the default.
33
+ //
34
+ // The check is the server's (ADR 0016). claimTask, claimTasksBatch and validateClaimBatch
35
+ // call fundingWallFailures (the taskNeedsFailures shape); resolveClaim calls
36
+ // fundingWallOnResolve. A hold is let go by STATE: the account counts it while its task is
37
+ // being worked or has shipped and is not yet paid, and the next claim or resolve writes the
38
+ // hold_release row (economy project-account.js), so no ship or pay path needs a hook.
39
+
40
+ 'use strict';
41
+
42
+ const api = require('../../src/module-api');
43
+ const { readPayTerms } = require('./pay-terms');
44
+
45
+ const log = api.logger('lifecycle');
46
+
47
+ const FUNDING_WALL_MODES = Object.freeze(['hold_at_claim', 'refuse_at_completion', 'carry_on']);
48
+ const DEFAULT_FUNDING_WALL = 'hold_at_claim';
49
+ const MODE_WORDS = Object.freeze({
50
+ hold_at_claim: 'Hold at claim: a task that pays cash can be claimed only when the project account can cover it, and its pay is set aside until the builder is paid or the task is let go.',
51
+ refuse_at_completion: 'Refuse at completion: a task can be claimed and worked, but it cannot be marked done while the project account cannot cover its cash pay.',
52
+ carry_on: 'Carry on: work is never stopped for money. Any unpaid amount is the project’s own debt; Bongos never pays it.',
53
+ });
54
+ const MICRO_PER_DOLLAR = 1000000;
55
+
56
+ // ── pure half ────────────────────────────────────────────────────────────────
57
+
58
+ function parseFundingWall(raw) {
59
+ if (raw == null || String(raw).trim() === '') return { reason: 'empty' };
60
+ const v = String(raw).trim().toLowerCase();
61
+ if (!FUNDING_WALL_MODES.includes(v)) return { reason: `must be one of ${FUNDING_WALL_MODES.join(' | ')}` };
62
+ return { mode: v };
63
+ }
64
+
65
+ // A cash amount as dollars, or null. Only a plain figure is read ("250", "$1,250.50",
66
+ // "USD 40"); a unit, when given, must say dollars. Anything else is not guessed at.
67
+ const DOLLAR_UNIT = /^(usd|us\$|\$|dollars?)$/i;
68
+ function dollarsMicro(amount, unit) {
69
+ if (unit != null && !DOLLAR_UNIT.test(String(unit).trim())) return null;
70
+ const s = String(amount == null ? '' : amount).trim().replace(/^(usd|us\$|\$)\s*/i, '').replace(/\s*(usd)$/i, '');
71
+ if (!/^\d{1,3}(,\d{3})*(\.\d{1,2})?$|^\d+(\.\d{1,2})?$/.test(s)) return null;
72
+ const [whole, cents = ''] = s.replace(/,/g, '').split('.');
73
+ const micro = Number(whole) * MICRO_PER_DOLLAR + Number((cents + '00').slice(0, 2)) * (MICRO_PER_DOLLAR / 100);
74
+ return Number.isSafeInteger(micro) ? micro : null;
75
+ }
76
+
77
+ // The task's cash price from its pay-terms view → { cash: false } | { cash: true, micro }
78
+ // | { cash: true, unreadable: '<what the project wrote>' }.
79
+ function cashPrice(view) {
80
+ const terms = ((view && view.terms) || []).filter((t) => t.pay_type === 'cash');
81
+ if (!terms.length) return { cash: false };
82
+ let micro = 0;
83
+ for (const t of terms) {
84
+ const m = dollarsMicro(t.amount, t.unit);
85
+ if (m == null) return { cash: true, unreadable: [t.amount, t.unit].filter(Boolean).join(' ') || 'no amount' };
86
+ micro += m;
87
+ }
88
+ if (!Number.isSafeInteger(micro)) return { cash: true, unreadable: 'a total too large to read' };
89
+ return { cash: true, micro };
90
+ }
91
+
92
+ function usd(micro) {
93
+ const cents = Math.round(Number(micro) / (MICRO_PER_DOLLAR / 100));
94
+ const sign = cents < 0 ? '-' : '';
95
+ const abs = Math.abs(cents);
96
+ return `${sign}$${Math.floor(abs / 100).toLocaleString('en-US')}.${String(abs % 100).padStart(2, '0')}`;
97
+ }
98
+
99
+ function shortfallRefusal(code, { taskId, priceMicro, account, mode }) {
100
+ const shortfall = priceMicro - account.available_micro;
101
+ const at = mode === 'refuse_at_completion' ? 'marked done' : 'claimed';
102
+ return {
103
+ code,
104
+ task_id: Number(taskId),
105
+ funding_rule: mode,
106
+ price_micro: priceMicro,
107
+ balance_micro: account.balance_micro,
108
+ held_micro: account.held_micro,
109
+ available_micro: account.available_micro,
110
+ shortfall_micro: shortfall,
111
+ message: `Task ${taskId} cannot be ${at} yet: the project account cannot cover its pay. `
112
+ + `The task pays ${usd(priceMicro)} in cash; the project has ${usd(account.available_micro)} available `
113
+ + `(${usd(account.balance_micro)} on record, ${usd(account.held_micro)} already held for work in progress). `
114
+ + `It is ${usd(shortfall)} short.`,
115
+ hint: 'The project owner can add money to the project account, or change the project’s funding rule on the settings page.',
116
+ };
117
+ }
118
+
119
+ function unreadableRefusal(taskId, wrote, mode) {
120
+ return {
121
+ code: 'FUNDING_PRICE_UNREADABLE',
122
+ task_id: Number(taskId),
123
+ funding_rule: mode,
124
+ message: `Task ${taskId} pays cash, stated as "${wrote}", which is not a dollar figure. `
125
+ + 'The project’s funding rule checks a task’s cash pay against the project account, so it cannot be checked.',
126
+ hint: 'Whoever can edit the task can state its cash pay as a dollar amount (for example 250 USD), or the project owner can change the funding rule.',
127
+ };
128
+ }
129
+
130
+ function noAccountRefusal(taskId, mode) {
131
+ return {
132
+ code: 'FUNDING_NO_ACCOUNT',
133
+ task_id: Number(taskId),
134
+ funding_rule: mode,
135
+ message: `Task ${taskId} pays cash, and the project account record is switched off on this instance, so the funding rule cannot be checked.`,
136
+ hint: 'The project owner can turn the economy module back on, or change the project’s funding rule.',
137
+ };
138
+ }
139
+
140
+ // ── read half ────────────────────────────────────────────────────────────────
141
+
142
+ // resolveFundingWall() → { mode, source, chosen, invalid } (resolveShipMode's shape). A
143
+ // database that cannot be read answers the default, which is the protective rule.
144
+ async function resolveFundingWall(deps = {}) {
145
+ const key = api.projectSettings.KEYS.FUNDING_WALL;
146
+ let row = null;
147
+ try {
148
+ row = await (deps.getSetting || api.projectSettings.get)(key);
149
+ } catch (err) {
150
+ log.error(`funding wall: project_settings read failed, using the default: ${err && err.message}`);
151
+ return { mode: DEFAULT_FUNDING_WALL, source: 'default', chosen: false, invalid: null };
152
+ }
153
+ if (row && row.value != null && row.value !== '') {
154
+ const got = parseFundingWall(row.value);
155
+ if (got.mode) return { mode: got.mode, source: 'project_settings', chosen: true, invalid: null };
156
+ return { mode: DEFAULT_FUNDING_WALL, source: 'default', chosen: false, invalid: { where: `project_settings['${key}']`, raw: String(row.value), reason: got.reason } };
157
+ }
158
+ return { mode: DEFAULT_FUNDING_WALL, source: 'default', chosen: false, invalid: null };
159
+ }
160
+
161
+ function describeFundingWall(resolved) {
162
+ const r = resolved || {};
163
+ return `${MODE_WORDS[r.mode] || MODE_WORDS[DEFAULT_FUNDING_WALL]}${r.chosen ? '' : ' (the default — the project has not chosen)'}`;
164
+ }
165
+
166
+ function accountPort(deps) {
167
+ return deps.account !== undefined ? deps.account : api.resolveOptional('economy.account');
168
+ }
169
+
170
+ // fundingWallFailures — the claim-time wall, called from all three claim surfaces.
171
+ // `client` is the claim transaction (claimTask, claimTasksBatch) or the pool (the
172
+ // validateClaimBatch dry run, `place: false`). With `place`, it takes the account lock,
173
+ // lets go of settled holds, and HOLDS each task's price when every task passes; the
174
+ // caller's rollback undoes the holds if a later gate refuses. Tasks are judged in the
175
+ // order given, each against what the ones before it would hold. Returns
176
+ // [{ task_id, refusal }] — empty for any task that pays no cash, without reading the
177
+ // rule or the account at all.
178
+ async function fundingWallFailures({ client, tasks, place = false, deps = {} }) {
179
+ const ids = (tasks || []).map((t) => String(t.id)).filter((s) => /^\d+$/.test(s));
180
+ if (!ids.length) return [];
181
+ const views = await (deps.readPayTerms || readPayTerms)(ids, { db: client });
182
+ const priced = ids.map((id) => ({ id, price: cashPrice(views.get(id)) })).filter((p) => p.price.cash);
183
+ if (!priced.length) return [];
184
+
185
+ const { mode } = await (deps.resolveFundingWall || resolveFundingWall)();
186
+ if (mode !== 'hold_at_claim') return [];
187
+
188
+ const account = accountPort(deps);
189
+ const failures = [];
190
+ if (!account) {
191
+ for (const p of priced) failures.push({ task_id: Number(p.id), refusal: noAccountRefusal(p.id, mode) });
192
+ return failures;
193
+ }
194
+ if (place) {
195
+ await account.lockAccount(client);
196
+ await account.releaseSettledHolds(client);
197
+ }
198
+ const snapshot = await account.readAvailable(client);
199
+ let committed = 0;
200
+ const holds = [];
201
+ for (const p of priced) {
202
+ if (p.price.unreadable) { failures.push({ task_id: Number(p.id), refusal: unreadableRefusal(p.id, p.price.unreadable, mode) }); continue; }
203
+ const now = { ...snapshot, held_micro: snapshot.held_micro + committed, available_micro: snapshot.available_micro - committed };
204
+ if (p.price.micro > now.available_micro) {
205
+ failures.push({ task_id: Number(p.id), refusal: shortfallRefusal('FUNDING_SHORTFALL', { taskId: p.id, priceMicro: p.price.micro, account: now, mode }) });
206
+ continue;
207
+ }
208
+ committed += p.price.micro;
209
+ holds.push(p);
210
+ }
211
+ if (place && !failures.length) {
212
+ for (const p of holds) {
213
+ await account.recordMovement(client, {
214
+ kind: 'hold', amountMicro: p.price.micro, taskId: p.id,
215
+ note: `Held at claim for task ${p.id}'s cash pay (${usd(p.price.micro)}), under the project's funding rule.`,
216
+ });
217
+ }
218
+ }
219
+ return failures;
220
+ }
221
+
222
+ // fundingWallOnResolve — inside resolveClaim's transaction, after the claim and task rows
223
+ // are updated. A task declared done under refuse_at_completion is refused (throws) when the
224
+ // account cannot cover it; its own hold, if it has one from an earlier rule, counts for
225
+ // it. A task let go (any other outcome) has its hold released now rather than at the next
226
+ // claim, so the owner's view of what is held is current.
227
+ async function fundingWallOnResolve(client, taskId, outcome, deps = {}) {
228
+ const account = accountPort(deps);
229
+ if (outcome !== 'shipped') {
230
+ if (account) await account.releaseSettledHolds(client);
231
+ return;
232
+ }
233
+ const views = await (deps.readPayTerms || readPayTerms)([String(taskId)], { db: client });
234
+ const price = cashPrice(views.get(String(taskId)));
235
+ if (!price.cash) return;
236
+ const { mode } = await (deps.resolveFundingWall || resolveFundingWall)();
237
+ if (mode !== 'refuse_at_completion') return;
238
+ if (!account) throw noAccountRefusal(taskId, mode);
239
+ if (price.unreadable) throw unreadableRefusal(taskId, price.unreadable, mode);
240
+ await account.lockAccount(client);
241
+ await account.releaseSettledHolds(client); // as at claim: the record is current before it is read
242
+ const snapshot = await account.readAvailable(client, { exceptTaskId: taskId });
243
+ if (price.micro > snapshot.available_micro) {
244
+ throw shortfallRefusal('FUNDING_SHORTFALL_AT_COMPLETION', { taskId, priceMicro: price.micro, account: snapshot, mode });
245
+ }
246
+ }
247
+
248
+ module.exports = {
249
+ FUNDING_WALL_MODES, DEFAULT_FUNDING_WALL,
250
+ parseFundingWall, dollarsMicro, cashPrice, usd,
251
+ resolveFundingWall, describeFundingWall,
252
+ fundingWallFailures, fundingWallOnResolve,
253
+ };
@@ -47,7 +47,8 @@
47
47
  "lifecycle.kickoffSeed",
48
48
  "lifecycle.removals",
49
49
  "lifecycle.workflowDispatch",
50
- "lifecycle.traffic"
50
+ "lifecycle.traffic",
51
+ "lifecycle.payTypeGates"
51
52
  ],
52
53
  "consumes": []
53
54
  }
@@ -398,6 +398,11 @@ module.exports = function buildClaimsRouter() {
398
398
  err.code === 'AUTO_PICKUP_OFF' ? 409 :
399
399
  err.code === 'AUTO_PICKUP_CAP_REACHED' ? 409 :
400
400
  err.code === 'AUTO_PICKUP_ROUND_LIMIT' ? 409 :
401
+ // task 1003185 / ADR 0386: the project's funding wall. The body names the
402
+ // price, the balance, what is held and the shortfall (funding-wall.js).
403
+ err.code === 'FUNDING_SHORTFALL' ? 409 :
404
+ err.code === 'FUNDING_PRICE_UNREADABLE' ? 409 :
405
+ err.code === 'FUNDING_NO_ACCOUNT' ? 409 :
401
406
  500;
402
407
  // V4.R14 (#952): a TASK_NOT_READY 409 names the way forward instead of
403
408
  // leaving the caller to discover the promote route by spelunking (#506).
@@ -645,6 +650,10 @@ module.exports = function buildClaimsRouter() {
645
650
  err.code === 'NOT_YOUR_CLAIM' ? 403 :
646
651
  err.code === 'CLAIM_SESSION_MISMATCH' ? 403 :
647
652
  err.code === 'ALREADY_RESOLVED' ? 409 :
653
+ // task 1003185 / ADR 0386: a project that refuses completion while it is short.
654
+ err.code === 'FUNDING_SHORTFALL_AT_COMPLETION' ? 409 :
655
+ err.code === 'FUNDING_PRICE_UNREADABLE' ? 409 :
656
+ err.code === 'FUNDING_NO_ACCOUNT' ? 409 :
648
657
  500;
649
658
  return res.fail(err.code, status, { ...err });
650
659
  }
@@ -21,6 +21,13 @@ const log = api.logger('lifecycle');
21
21
 
22
22
  module.exports = function buildPayTypeGatesRouter() {
23
23
  const router = express.Router();
24
+ // The `lifecycle.payTypeGates` port (task 1004910): core's module-sale payouts ask it
25
+ // whether module_share is cleared (licence, takedown contact, payee onboarding) before
26
+ // a rule, a sale or a share moves. hasProvider-guarded so a rebuilt router never
27
+ // registers it twice.
28
+ if (!api.hasProvider('lifecycle.payTypeGates')) {
29
+ api.registerProvider('lifecycle.payTypeGates', { refuseUnclearedGates: gates.refuseUnclearedGates });
30
+ }
24
31
 
25
32
  // rank: any-builder for met/missing (the same pay information `GET /pay-terms` shows, no person's
26
33
  // money); the evidence references and who cleared each requirement are the owner's records, so
@@ -36,6 +36,7 @@ const goalCategory = require('../goal-category');
36
36
  const autopickup = require('../autopickup');
37
37
  const payTermsLib = require('../pay-terms'); // task 1004990
38
38
  const payTypeGates = require('../pay-type-gates'); // task 1004991
39
+ const fundingWall = require('../funding-wall'); // task 1003185
39
40
 
40
41
  const log = api.logger('lifecycle');
41
42
 
@@ -530,8 +531,10 @@ module.exports = function buildProjectSettingsRouter() {
530
531
  return res.fail('bad_task_ids', { status: 400, message: 'task_ids is a comma list of 1 to 50 task ids.' });
531
532
  }
532
533
  try {
533
- const map = await payTermsLib.readPayTerms(ids);
534
- res.json({ pay_terms: Object.fromEntries(map) });
534
+ const [map, wall] = await Promise.all([payTermsLib.readPayTerms(ids), fundingWall.resolveFundingWall()]);
535
+ // task 1003185 / ADR 0386: the project's funding rule rides beside the pay, so a
536
+ // builder sees what happens when the project runs short BEFORE claiming.
537
+ res.json({ pay_terms: Object.fromEntries(map), funding_wall: { mode: wall.mode, chosen: wall.chosen, sentence: fundingWall.describeFundingWall(wall) } });
535
538
  } catch (err) {
536
539
  log.error(`GET /pay-terms: ${err && err.message}`);
537
540
  res.fail('pay_terms_read_failed', { status: 500, message: 'internal error' });
@@ -564,6 +567,60 @@ module.exports = function buildProjectSettingsRouter() {
564
567
  }
565
568
  });
566
569
 
570
+ // --- the funding wall (task 1003185, ADR 0386) -------------------------------
571
+ //
572
+ // GET /project-settings/funding-wall — the project's rule for running short, in one
573
+ // plain sentence, plus the three choices. rank: any signed-in builder — a builder is
574
+ // owed this before claiming (ADR 0386), and it carries no balance or amount.
575
+ function fundingWallBody(resolved) {
576
+ return {
577
+ mode: resolved.mode, chosen: resolved.chosen, source: resolved.source, invalid: resolved.invalid,
578
+ sentence: fundingWall.describeFundingWall(resolved),
579
+ choices: fundingWall.FUNDING_WALL_MODES.map((mode) => ({ mode, sentence: fundingWall.describeFundingWall({ mode, chosen: true }) })),
580
+ };
581
+ }
582
+ router.get('/project-settings/funding-wall', api.requireBuilder, async (req, res) => {
583
+ try {
584
+ res.set('Cache-Control', 'no-store');
585
+ res.json(fundingWallBody(await fundingWall.resolveFundingWall()));
586
+ } catch (err) {
587
+ log.error(`GET /project-settings/funding-wall: ${err && err.message}`);
588
+ res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
589
+ }
590
+ });
591
+
592
+ // PATCH /project-settings/funding-wall — body { mode: 'hold_at_claim' | 'refuse_at_completion' | 'carry_on' }.
593
+ //
594
+ // rank: project.settings.manage (metic+, government_010), the same atom as the pay-terms
595
+ // write: the funding rule is part of the project's pay policy (ADR 0385 amendment), so
596
+ // every change also lands in the audit log (who, when, from what to what).
597
+ router.patch('/project-settings/funding-wall', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('rules'), async (req, res) => {
598
+ if (api.validateOrRespond(req, res, { mode: { required: true, type: 'string' } })) return;
599
+ const parsed = fundingWall.parseFundingWall(req.body.mode);
600
+ if (!parsed.mode) return res.fail('bad_funding_wall', { status: 400, message: `mode: ${parsed.reason}` });
601
+ try {
602
+ const before = await fundingWall.resolveFundingWall();
603
+ await api.projectSettings.set(api.projectSettings.KEYS.FUNDING_WALL, parsed.mode, req.builder.id);
604
+ try {
605
+ await api.insertAuditLog({
606
+ builderId: req.builder.id,
607
+ route: 'project-settings:funding-wall',
608
+ method: 'PATCH',
609
+ requestBodyRedacted: JSON.stringify({ from: before.mode, to: parsed.mode }),
610
+ responseStatus: 200,
611
+ ip: req.ip || null,
612
+ userAgent: req.get ? req.get('user-agent') : null,
613
+ });
614
+ } catch (auditErr) {
615
+ log.error(`AUDIT GAP: funding wall set to ${parsed.mode} but its audit row failed: ${auditErr && auditErr.message}`);
616
+ }
617
+ res.json({ ok: true, ...fundingWallBody(await fundingWall.resolveFundingWall()) });
618
+ } catch (err) {
619
+ log.error(`PATCH /project-settings/funding-wall: ${err && err.message}`);
620
+ res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
621
+ }
622
+ });
623
+
567
624
  // --- tweak auto-pickup (task 1004677, ADR 0371 D3/D5) ----------------------
568
625
  //
569
626
  // GET /project-settings/tweak-autopickup — the switch, the monthly cap, and how
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.23",
3
+ "version": "1.24.25",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.23",
9
+ "version": "1.24.25",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.23",
3
+ "version": "1.24.25",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11027,5 +11027,49 @@
11027
11027
  "id": "1005035",
11028
11028
  "text": "Projects that were still in the guided setup now open as the full project instead, and no project starts the guided setup unless it is switched on for that project. Hosted projects pick this up when their version is moved forw"
11029
11029
  }
11030
+ ],
11031
+ "1.24.24": [
11032
+ {
11033
+ "id": "1005039",
11034
+ "text": "The automatic check that tax-detail records can never be deleted now tests real records, so it passes and still guards that rule."
11035
+ },
11036
+ {
11037
+ "id": "1004912",
11038
+ "text": "Every day the project now checks its money records against the payment processor's and alerts the owner if they differ. Until a payment processor is connected there is nothing to compare, so each day's check is recorded as not"
11039
+ },
11040
+ {
11041
+ "id": "1003186",
11042
+ "text": "Proved end to end that a funded project pays a builder exactly once and an underfunded one refuses with the shortfall, and closed a gap where money owed to a builder could be promised to a second task before they were paid."
11043
+ },
11044
+ {
11045
+ "id": "1004911",
11046
+ "text": "People can now take their own balance out to their bank, with a $10 minimum, holds that explain themselves, and a record of every failed try that can never pay twice."
11047
+ },
11048
+ {
11049
+ "id": "1004910",
11050
+ "text": "When a module sells, the money can now be split the way its project decides: the seller gets their share, and the people who built it share the rest by the work they recorded. Each person is paid once, only after a person appr"
11051
+ },
11052
+ {
11053
+ "id": "1003185",
11054
+ "text": "A project that cannot pay for a cash task now stops it being claimed, and says exactly how much money is missing; each project can choose that rule, stop work at completion instead, or carry on."
11055
+ },
11056
+ {
11057
+ "id": "1003184",
11058
+ "text": "The owner can now see where the project's money went: spending each month, what each builder was paid, and what is still owed but not yet paid, with the balance that is really left after those debts."
11059
+ },
11060
+ {
11061
+ "id": "1004907",
11062
+ "text": "Each person can now see their own balance and the project's, fund the project, and pay a teammate with no fee, and one project can never see or spend another's money. Nothing moves until the payment processor is connected."
11063
+ },
11064
+ {
11065
+ "id": "1004908",
11066
+ "text": "Contractors are now only paid once a signed NDA is on file and the payment processor has collected their tax details; if either is missing the payment waits and the person is told why. Bongos never stores tax numbers."
11067
+ }
11068
+ ],
11069
+ "1.24.25": [
11070
+ {
11071
+ "id": "1004593",
11072
+ "text": "A hosted project's Settings page no longer offers an unreleased version; it now offers exactly what the Upgrade page offers."
11073
+ }
11030
11074
  ]
11031
11075
  }
@@ -83,6 +83,7 @@ const REQUESTS = Object.freeze({
83
83
  'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
84
84
  'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
85
85
  'PATCH /project-settings/pay-terms': ['PATCH', '/project-settings/pay-terms', { terms: 'not a list' }],
86
+ 'PATCH /project-settings/funding-wall': ['PATCH', '/project-settings/funding-wall', { mode: 'sometimes' }],
86
87
  'PATCH /project-settings/spend-cap': ['PATCH', '/project-settings/spend-cap', { usd: 'lots' }],
87
88
  'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
88
89
  'POST /market-watch/proposals/:id/decide': ['POST', `/market-watch/proposals/${BAD}/decide`, { decision: 'approve' }],
@@ -86,6 +86,24 @@ const CARDS = Object.freeze({
86
86
  'This task needs server access, which this account does not have yet.',
87
87
  'I will pick a task that can be done from here instead.',
88
88
  'The project owner, if you should have server access.', RULE),
89
+ // task 1003185 / ADR 0386: the project's funding rule. The message above the card
90
+ // names the task's pay, the money available and how far short it is.
91
+ FUNDING_SHORTFALL: card(
92
+ 'This project does not have enough money set aside to pay for this task yet, and its rule is to stop new work until it does.',
93
+ 'I will show you how far short it is and pick a task that does not need the money.',
94
+ 'The project owner, who can add money or change the rule.', RULE),
95
+ FUNDING_SHORTFALL_AT_COMPLETION: card(
96
+ 'This project does not have enough money to pay for this task yet, and its rule is that work cannot be marked done until it does.',
97
+ 'Your work is kept as it is. I will show you how far short it is; it can be marked done once the money is there.',
98
+ 'The project owner, who can add money or change the rule.', RULE),
99
+ FUNDING_PRICE_UNREADABLE: card(
100
+ 'This task pays money, but its pay is not written as a dollar amount, so the project cannot check it can pay.',
101
+ 'I will show you what the pay says and pick another task meanwhile.',
102
+ 'Whoever looks after the task, to write its pay as a dollar amount.', RULE),
103
+ FUNDING_NO_ACCOUNT: card(
104
+ 'This task pays money, and the record of the project’s money is switched off, so nobody can check the project can pay.',
105
+ 'I will pick a task that does not pay money meanwhile.',
106
+ 'The project owner, who can switch the record back on or change the rule.', RULE),
89
107
  archon_approval_required: card(
90
108
  'This goal covers a protected part of the project, so joining it needs an Archon to say yes.',
91
109
  'I will file the request for you and we pick other work while it waits.',
@@ -285,10 +285,38 @@ const INTEGRATION = new Set([
285
285
  // causeless or wrongly-directed movement, any UPDATE/DELETE/TRUNCATE, and a second row
286
286
  // for one processor reference.
287
287
  'project_account_db',
288
+ // task 1003185 (ADR 0386): the funding wall on the real account and pay-terms migrations —
289
+ // a claim holds its cash pay, a short one is refused with the shortfall, a let-go hold is
290
+ // released by state, and two simultaneous claims cannot commit one balance twice.
291
+ 'funding_wall_db',
288
292
  // task 1003183: builder payouts from the real migrations — Postgres refuses a second debt
289
293
  // for one task or approval, a second sent attempt, a causeless attempt, and any rewrite;
290
294
  // the real payForTask pays once and books one payout movement.
291
295
  'builder_payouts_db',
296
+ // task 1003184: the owner's outflow view over the real project-account and payout
297
+ // migrations — paid vs owed vs approved-unbooked, gap-free months, after-commitments.
298
+ 'project_outflow_db',
299
+ // task 1003186 (M07): the whole goal on the migrated schema — fund, claim, the wall's refusal
300
+ // and shortfall, ship, an approved payout paid exactly once, the owner's outflow, then drain.
301
+ 'funded_project_pays_proof_db',
302
+ // task 1004907: user balances from the real migrations in TWO project schemas — Postgres
303
+ // refuses a platform fee, a self-payment, a shared account, a reused instruction, a second
304
+ // sent attempt and any rewrite; neither project sees or spends the other's accounts.
305
+ 'user_balances_db',
306
+ // task 1004911: cash-outs from the real migrations — Postgres refuses a cash-out approval by
307
+ // anyone but its payee, an owner's approval spent as one, a try number the processor has not
308
+ // earned, a second payout on one try, anything after a cancel, and any rewrite; the real
309
+ // flow retries a timeout on the SAME key and a refusal or bank return on a new one.
310
+ 'cash_outs_db',
311
+ // task 1004910: module-sale payouts from the real migrations — a split that does not add
312
+ // to the price, a second sale per reference, a second sent share, one approval paying two
313
+ // things are refused; the weights query and the real recordSale/payShare book once.
314
+ 'module_payouts_db',
315
+ // task 1004912: the daily ledger check on the real account, member-payment and check
316
+ // migrations — the real store reads every sent reference, a clean day then a drift filed
317
+ // once, and Postgres refuses an edited check, a drift with no fingerprint or a filed alarm
318
+ // with no blocker.
319
+ 'ledger_check_db',
292
320
  // task 1004408: migration platform_identity_028 deletes the old hub invite's
293
321
  // pending rows before a cutoff it reads from the core_upgrades ledger. Only a real
294
322
  // engine runs the DO block, the numeric version compare and the one-shot guard.
@@ -260,5 +260,10 @@ async function listPayouts(db = defaultPool(), { limit = 50, offset = 0 } = {})
260
260
  }
261
261
 
262
262
  // One object, as payout-gates.js does: the router reaches it through an injectable default.
263
- const api = { PayoutRefusal, idempotencyKey, payForTask, listPayouts };
263
+ // The helpers and constants after listPayouts are shared with module-payouts.js (task 1004910),
264
+ // which pays a module-sale share down this same path.
265
+ const api = {
266
+ PayoutRefusal, HELD_WORDS, idempotencyKey, payForTask, listPayouts,
267
+ inTx, withTimeout, clip, MICRO_PER_CENT, CURRENCY, TRANSFER_TIMEOUT_MS,
268
+ };
264
269
  module.exports = api;