@bongos/core 1.24.23 → 1.24.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +208 -53
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +42 -0
- package/clients/bongos-client/index.cjs +42 -0
- package/clients/bongos-client/index.d.ts +55 -1
- package/clients/bongos-client/index.mjs +42 -0
- package/docs/api/openapi.json +1181 -207
- package/docs/api-reference.md +30 -5
- package/docs/architecture.md +42 -0
- package/docs/copy-inventory.md +18 -8
- package/docs/copy-registry.json +99 -9
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +64 -25
- package/migrations/core_291_payout_nda_tax.sql +75 -0
- package/migrations/core_292_module_sale_payouts.sql +170 -0
- package/migrations/core_293_cash_outs.sql +184 -0
- package/migrations/core_294_cash_out_attempts_sent_by_time.sql +21 -0
- package/modules/economy/ledger-check.js +360 -0
- package/modules/economy/migrations/economy_003_user_balances.sql +116 -0
- package/modules/economy/migrations/economy_004_module_share_payouts.sql +22 -0
- package/modules/economy/migrations/economy_005_project_account_holds_index.sql +20 -0
- package/modules/economy/migrations/economy_006_ledger_checks.sql +68 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/outflow.js +209 -0
- package/modules/economy/pollers/ledger-check.js +87 -0
- package/modules/economy/project-account.js +92 -7
- package/modules/economy/routes/balances.js +79 -0
- package/modules/economy/routes/project-account.js +40 -2
- package/modules/economy/user-balances.js +308 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/hall-ui/public/project-settings.css +18 -0
- package/modules/hall-ui/public/project-settings.html +15 -2
- package/modules/hall-ui/public/project-settings.js +142 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/blockers.js +26 -0
- package/modules/lifecycle/db-claims.js +22 -0
- package/modules/lifecycle/db-ship.js +5 -5
- package/modules/lifecycle/funding-wall.js +253 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +9 -0
- package/modules/lifecycle/routes/pay-type-gates.js +7 -0
- package/modules/lifecycle/routes/project-settings.js +59 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +38 -0
- package/scripts/bongos/government-walk.js +1 -0
- package/scripts/bongos/plain-cards.js +18 -0
- package/scripts/bongos/run-unit-tests.js +28 -0
- package/src/bongos/builder-payouts.js +6 -1
- package/src/bongos/cash-outs.js +399 -0
- package/src/bongos/module-payouts.js +432 -0
- package/src/bongos/payout-approvals.js +5 -2
- package/src/bongos/payout-gates.js +44 -7
- package/src/bongos/project-settings.js +2 -0
- package/src/bongos/routes/module-payouts.js +114 -0
- package/src/bongos/routes/my-cash-outs.js +103 -0
- package/src/bongos/routes/payout-gates.js +42 -1
- package/src/bongos/routes.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/builder_payouts_db.mjs +1 -0
- package/tests/cash_outs.mjs +96 -0
- package/tests/cash_outs_db.mjs +205 -0
- package/tests/funded_project_pays_proof_db.mjs +181 -0
- package/tests/funding_wall.mjs +205 -0
- package/tests/funding_wall_db.mjs +174 -0
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/ledger_check.mjs +189 -0
- package/tests/ledger_check_db.mjs +119 -0
- package/tests/module_payouts.mjs +295 -0
- package/tests/module_payouts_db.mjs +140 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/payout_gates.mjs +67 -6
- package/tests/payout_gates_db.mjs +23 -1
- package/tests/project_account_db.mjs +5 -2
- package/tests/project_outflow.mjs +103 -0
- package/tests/project_outflow_db.mjs +100 -0
- package/tests/user_balances.mjs +241 -0
- package/tests/user_balances_db.mjs +137 -0
|
@@ -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
|
-
//
|
|
212
|
-
//
|
|
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
|
+
};
|
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "1.24.24",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.24",
|
|
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.
|
|
3
|
+
"version": "1.24.24",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -11027,5 +11027,43 @@
|
|
|
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
|
+
}
|
|
11030
11068
|
]
|
|
11031
11069
|
}
|
|
@@ -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
|
-
|
|
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;
|