@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.
- package/.bongos-core.json +212 -57
- 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 +4 -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 +44 -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/package-registry.js +3 -2
- 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/core-update.js +11 -3
- 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/bongos/software-update.js +36 -16
- 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/software_update.mjs +61 -3
- package/tests/user_balances.mjs +241 -0
- package/tests/user_balances_db.mjs +137 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/outflow.js — WHERE THE MONEY WENT: the owner's outflow view (task 1003184,
|
|
4
|
+
// goal 1000092 M05; criterion money-outflow-visible).
|
|
5
|
+
//
|
|
6
|
+
// WHY. A balance that looks healthy while it already owes three payouts is a trap. So this
|
|
7
|
+
// view puts three things beside the project account's balance: what the project spent each
|
|
8
|
+
// month, what each builder has been paid, and — the one people forget — what is COMMITTED
|
|
9
|
+
// BUT NOT YET PAID, with the balance that is left once those commitments are met.
|
|
10
|
+
//
|
|
11
|
+
// A READ, NOT A SECOND CAPTURE PATH. Every figure is summed from a record something else
|
|
12
|
+
// already writes: cost_log (the spend /cost and /public/cost-summary already draw, ADR 0028),
|
|
13
|
+
// the project-account movements (project-account.js), and core's payout records
|
|
14
|
+
// (builder_payouts_owed + builder_payout_attempts, task 1003183; payout_approvals). Nothing
|
|
15
|
+
// here writes. It reads core's tables by SQL the way credits.js reads credit_log.
|
|
16
|
+
//
|
|
17
|
+
// WHAT "COMMITTED" COUNTS, and why each:
|
|
18
|
+
// * owed — a payout recorded as owed (builder_payouts_owed) with no `sent` attempt.
|
|
19
|
+
// The debt is on record; it has not left the account.
|
|
20
|
+
// * approved — a person's `payout_approved` approval no payout has been booked against
|
|
21
|
+
// yet. The owner has authorised it; nothing is owed on record until someone
|
|
22
|
+
// presses pay, so leaving it out would understate what the balance must cover.
|
|
23
|
+
// * held — the account's own holds (project-account.js `held_micro`), already taken
|
|
24
|
+
// out of `available_micro`, so they are shown and never subtracted twice. A
|
|
25
|
+
// shipped task keeps its hold until it is paid, so an owed debt counts only
|
|
26
|
+
// the part its task's hold does not already cover (task 1003186).
|
|
27
|
+
// Only US-dollar commitments are summed: the account is kept in USD (builder-payouts.js), so
|
|
28
|
+
// a commitment in another currency is counted separately and never added to a dollar figure.
|
|
29
|
+
//
|
|
30
|
+
// Units: every figure is integer micro-dollars, the project account's own unit. cost_log is
|
|
31
|
+
// numeric USD and the payout records are cents; both are converted in SQL.
|
|
32
|
+
|
|
33
|
+
const { accountTotals, readLiveHolds } = require('./project-account');
|
|
34
|
+
|
|
35
|
+
const MICRO_PER_CENT = 10_000;
|
|
36
|
+
const MONTHS_DEFAULT = 12;
|
|
37
|
+
const MONTHS_MAX = 36;
|
|
38
|
+
const OWED_LIST_MAX = 50;
|
|
39
|
+
|
|
40
|
+
const n = (v) => Number(v || 0);
|
|
41
|
+
|
|
42
|
+
// Spend and money out of the account per calendar month, every month in the window present
|
|
43
|
+
// (a quiet month is a real zero, not a gap). to_char labels the month in SQL so no driver
|
|
44
|
+
// timezone can move a row into its neighbour (the cost.js by_month precedent).
|
|
45
|
+
const BURN_SQL = `
|
|
46
|
+
WITH months AS (
|
|
47
|
+
SELECT to_char(m, 'YYYY-MM') AS month
|
|
48
|
+
FROM generate_series(date_trunc('month', now()) - (($1::int - 1) * interval '1 month'),
|
|
49
|
+
date_trunc('month', now()), interval '1 month') AS m
|
|
50
|
+
),
|
|
51
|
+
spend AS (
|
|
52
|
+
SELECT to_char(recorded_at, 'YYYY-MM') AS month, SUM(amount_usd) * 1000000 AS micro
|
|
53
|
+
FROM cost_log
|
|
54
|
+
WHERE recorded_at >= date_trunc('month', now()) - (($1::int - 1) * interval '1 month')
|
|
55
|
+
GROUP BY 1
|
|
56
|
+
),
|
|
57
|
+
paid AS (
|
|
58
|
+
SELECT to_char(created_at, 'YYYY-MM') AS month,
|
|
59
|
+
SUM(CASE WHEN kind = 'payout' THEN amount_micro ELSE 0 END) AS payouts_micro,
|
|
60
|
+
SUM(CASE WHEN direction = 'out' AND kind <> 'payout' THEN amount_micro ELSE 0 END) AS other_out_micro
|
|
61
|
+
FROM economy_project_account_movements
|
|
62
|
+
WHERE created_at >= date_trunc('month', now()) - (($1::int - 1) * interval '1 month')
|
|
63
|
+
GROUP BY 1
|
|
64
|
+
)
|
|
65
|
+
SELECT m.month,
|
|
66
|
+
COALESCE(round(s.micro), 0) AS spend_micro,
|
|
67
|
+
COALESCE(p.payouts_micro, 0) AS payouts_micro,
|
|
68
|
+
COALESCE(p.other_out_micro, 0) AS other_out_micro
|
|
69
|
+
FROM months m
|
|
70
|
+
LEFT JOIN spend s ON s.month = m.month
|
|
71
|
+
LEFT JOIN paid p ON p.month = m.month
|
|
72
|
+
ORDER BY m.month`;
|
|
73
|
+
|
|
74
|
+
// One row per payout debt with where it stands, plus every approval nothing has been booked
|
|
75
|
+
// against. The two halves are the per-builder totals and the commitments, read once.
|
|
76
|
+
const PAYOUTS_SQL = `
|
|
77
|
+
SELECT o.id, o.builder_id, o.task_id, t.title AS task_title, o.amount_cents, o.currency,
|
|
78
|
+
o.created_at, (s.id IS NOT NULL) AS paid,
|
|
79
|
+
l.outcome AS last_outcome, l.reason AS last_reason
|
|
80
|
+
FROM builder_payouts_owed o
|
|
81
|
+
LEFT JOIN tasks t ON t.id = o.task_id
|
|
82
|
+
LEFT JOIN builder_payout_attempts s ON s.owed_id = o.id AND s.outcome = 'sent'
|
|
83
|
+
LEFT JOIN LATERAL (
|
|
84
|
+
SELECT outcome, reason FROM builder_payout_attempts a
|
|
85
|
+
WHERE a.owed_id = o.id ORDER BY a.id DESC LIMIT 1) l ON true`;
|
|
86
|
+
|
|
87
|
+
const APPROVED_SQL = `
|
|
88
|
+
SELECT a.id, a.recipient_id AS builder_id, a.amount_cents, a.currency, a.created_at
|
|
89
|
+
FROM payout_approvals a
|
|
90
|
+
WHERE a.kind = 'payout_approved'
|
|
91
|
+
AND NOT EXISTS (SELECT 1 FROM builder_payouts_owed o WHERE o.approval_id = a.id)
|
|
92
|
+
-- an approval bound to a module-sale share is spent too (task 1004910, core_292)
|
|
93
|
+
AND NOT EXISTS (SELECT 1 FROM module_share_approvals m WHERE m.approval_id = a.id)`;
|
|
94
|
+
|
|
95
|
+
const SPEND_BY_BUILDER_SQL = `
|
|
96
|
+
SELECT builder_id, round(SUM(amount_usd) * 1000000) AS spend_micro
|
|
97
|
+
FROM cost_log WHERE builder_id IS NOT NULL GROUP BY builder_id`;
|
|
98
|
+
|
|
99
|
+
const SPEND_TOTAL_SQL = 'SELECT round(COALESCE(SUM(amount_usd), 0) * 1000000) AS micro FROM cost_log';
|
|
100
|
+
|
|
101
|
+
const NAMES_SQL = 'SELECT id, display_name, github_login FROM builders WHERE id = ANY($1::bigint[])';
|
|
102
|
+
|
|
103
|
+
// shapeOutflow — pure. Everything the route returns is decided here, so the sums are tested
|
|
104
|
+
// without a database. `account` is accountTotals' row (recorded/held/available).
|
|
105
|
+
function shapeOutflow({ burn, payouts, approved, spendByBuilder, spendTotal, account, names, liveHolds }) {
|
|
106
|
+
const usd = (r) => r.currency === 'USD';
|
|
107
|
+
const micro = (r) => n(r.amount_cents) * MICRO_PER_CENT;
|
|
108
|
+
const nameOf = new Map((names || []).map((b) => [String(b.id), b.display_name || b.github_login || null]));
|
|
109
|
+
|
|
110
|
+
const builders = new Map();
|
|
111
|
+
const row = (id) => {
|
|
112
|
+
const key = String(id);
|
|
113
|
+
if (!builders.has(key)) {
|
|
114
|
+
builders.set(key, { builder_id: key, builder_name: nameOf.get(key) || null, spend_micro: 0, paid_micro: 0, owed_micro: 0, approved_micro: 0 });
|
|
115
|
+
}
|
|
116
|
+
return builders.get(key);
|
|
117
|
+
};
|
|
118
|
+
for (const s of spendByBuilder || []) row(s.builder_id).spend_micro += n(s.spend_micro);
|
|
119
|
+
|
|
120
|
+
const owed = [];
|
|
121
|
+
const heldFor = liveHolds instanceof Map ? liveHolds : new Map();
|
|
122
|
+
let owedMicro = 0;
|
|
123
|
+
let coveredMicro = 0;
|
|
124
|
+
let paidMicro = 0;
|
|
125
|
+
let otherCurrency = 0;
|
|
126
|
+
for (const p of payouts || []) {
|
|
127
|
+
if (!usd(p)) { if (!p.paid) otherCurrency += 1; continue; }
|
|
128
|
+
if (p.paid) { row(p.builder_id).paid_micro += micro(p); paidMicro += micro(p); continue; }
|
|
129
|
+
row(p.builder_id).owed_micro += micro(p);
|
|
130
|
+
owedMicro += micro(p);
|
|
131
|
+
const covered = Math.min(micro(p), n(heldFor.get(String(p.task_id))));
|
|
132
|
+
coveredMicro += covered;
|
|
133
|
+
owed.push({
|
|
134
|
+
owed_id: String(p.id), builder_id: String(p.builder_id), builder_name: nameOf.get(String(p.builder_id)) || null,
|
|
135
|
+
task_id: String(p.task_id), task_title: p.task_title || null, amount_micro: micro(p), held_micro: covered,
|
|
136
|
+
since: p.created_at, last_outcome: p.last_outcome || null, last_reason: p.last_reason || null,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
let approvedMicro = 0;
|
|
140
|
+
let approvedCount = 0;
|
|
141
|
+
for (const a of approved || []) {
|
|
142
|
+
if (!usd(a)) { otherCurrency += 1; continue; }
|
|
143
|
+
row(a.builder_id).approved_micro += micro(a);
|
|
144
|
+
approvedMicro += micro(a);
|
|
145
|
+
approvedCount += 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Oldest debt first: the one that has waited longest is the one to act on.
|
|
149
|
+
owed.sort((a, b) => new Date(a.since) - new Date(b.since) || Number(a.owed_id) - Number(b.owed_id));
|
|
150
|
+
const byBuilder = [...builders.values()]
|
|
151
|
+
.map((b) => ({ ...b, total_micro: b.spend_micro + b.paid_micro + b.owed_micro + b.approved_micro }))
|
|
152
|
+
.filter((b) => b.total_micro > 0)
|
|
153
|
+
.sort((a, b) => b.total_micro - a.total_micro || Number(a.builder_id) - Number(b.builder_id));
|
|
154
|
+
|
|
155
|
+
const months = (burn || []).map((m) => {
|
|
156
|
+
const spend = n(m.spend_micro);
|
|
157
|
+
const payoutsOut = n(m.payouts_micro);
|
|
158
|
+
const other = n(m.other_out_micro);
|
|
159
|
+
return { month: m.month, spend_micro: spend, payouts_micro: payoutsOut, other_out_micro: other, total_micro: spend + payoutsOut + other };
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const recorded = n(account.recorded_balance_micro);
|
|
163
|
+
const held = n(account.held_micro);
|
|
164
|
+
const available = recorded - held;
|
|
165
|
+
const committed = owedMicro + approvedMicro;
|
|
166
|
+
const notYetOut = committed - coveredMicro; // the covered part is already out of available
|
|
167
|
+
return {
|
|
168
|
+
unit: 'micro_usd',
|
|
169
|
+
holds_money: false,
|
|
170
|
+
burn: { months, window_total_micro: months.reduce((s, m) => s + m.total_micro, 0) },
|
|
171
|
+
totals: { spend_micro: n(spendTotal), paid_micro: paidMicro },
|
|
172
|
+
by_builder: byBuilder,
|
|
173
|
+
committed: {
|
|
174
|
+
owed_micro: owedMicro,
|
|
175
|
+
owed_count: owed.length,
|
|
176
|
+
approved_micro: approvedMicro,
|
|
177
|
+
approved_count: approvedCount,
|
|
178
|
+
held_micro: held,
|
|
179
|
+
total_micro: committed,
|
|
180
|
+
other_currency_count: otherCurrency,
|
|
181
|
+
owed: owed.slice(0, OWED_LIST_MAX),
|
|
182
|
+
},
|
|
183
|
+
balance: {
|
|
184
|
+
recorded_micro: recorded,
|
|
185
|
+
available_micro: available,
|
|
186
|
+
// The figure the page leads with: what is left once every commitment above is met.
|
|
187
|
+
// Negative means the project already owes more than it holds.
|
|
188
|
+
after_commitments_micro: available - notYetOut,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// readOutflow(db, { months }) — the owner's view. db is a pool or a client.
|
|
194
|
+
async function readOutflow(db, { months = MONTHS_DEFAULT } = {}) {
|
|
195
|
+
const win = Math.min(Math.max(parseInt(months, 10) || MONTHS_DEFAULT, 1), MONTHS_MAX);
|
|
196
|
+
const [burn, payouts, approved, spendByBuilder, spendTotal, account, liveHolds] = await Promise.all([
|
|
197
|
+
db.query(BURN_SQL, [win]), db.query(PAYOUTS_SQL), db.query(APPROVED_SQL),
|
|
198
|
+
db.query(SPEND_BY_BUILDER_SQL), db.query(SPEND_TOTAL_SQL), accountTotals(db), readLiveHolds(db),
|
|
199
|
+
]);
|
|
200
|
+
const ids = new Set();
|
|
201
|
+
for (const r of [...payouts.rows, ...approved.rows, ...spendByBuilder.rows]) ids.add(String(r.builder_id));
|
|
202
|
+
const names = ids.size ? (await db.query(NAMES_SQL, [[...ids]])).rows : [];
|
|
203
|
+
return shapeOutflow({
|
|
204
|
+
burn: burn.rows, payouts: payouts.rows, approved: approved.rows, spendByBuilder: spendByBuilder.rows,
|
|
205
|
+
spendTotal: spendTotal.rows[0].micro, account, names, liveHolds,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = { shapeOutflow, readOutflow };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/pollers/ledger-check.js — fire the daily ledger check from the server itself
|
|
4
|
+
// (task 1004912, goal 1000092 N8). The check is ../ledger-check.js.
|
|
5
|
+
//
|
|
6
|
+
// A COPY OF THE daily-checks SHAPE (modules/autonomy/pollers/daily-checks.js), not an import of
|
|
7
|
+
// it (modules may not require each other): one unref'd HOURLY tick, the first a few minutes
|
|
8
|
+
// after boot plus jitter, kill-switched by ROUTINE_TIMERS_DISABLED=1, and it never throws into
|
|
9
|
+
// the server. DUE BY THE LEDGER, NOT BY THE BOOT: each tick asks economy_ledger_checks whether
|
|
10
|
+
// the newest check is a day old, so a deploy restarts the timer and changes nothing about when
|
|
11
|
+
// the next check runs.
|
|
12
|
+
//
|
|
13
|
+
// THE TWO PORTS, both resolved optionally and neither declared in `consumes` (declaring one
|
|
14
|
+
// would make "module off" into "instance will not start"; the ideas.descent precedent):
|
|
15
|
+
// economy.processor the processor adapter, when one lands (task 1005040). Absent today, so
|
|
16
|
+
// every check is recorded `unchecked` (no_processor) and raises nothing.
|
|
17
|
+
// ideas.fileBlocker where the alarm goes: the owner's blocker queue (ideas module).
|
|
18
|
+
|
|
19
|
+
const api = require('../../../src/module-api');
|
|
20
|
+
const { runLedgerCheck } = require('../ledger-check');
|
|
21
|
+
|
|
22
|
+
const log = api.logger('economy');
|
|
23
|
+
|
|
24
|
+
const TICK_MS = Number(process.env.LEDGER_CHECK_TICK_MS) || 60 * 60_000;
|
|
25
|
+
// After daily-checks' 3 minutes, so the two first ticks never land together.
|
|
26
|
+
const FIRST_MS = Number(process.env.LEDGER_CHECK_FIRST_MS) || 5 * 60_000;
|
|
27
|
+
const JITTER_MS = 60_000;
|
|
28
|
+
|
|
29
|
+
let running = false;
|
|
30
|
+
let warnedUnreachable = false;
|
|
31
|
+
|
|
32
|
+
// tick(deps) — one look at the ledger, and a check if one is due. NEVER throws.
|
|
33
|
+
async function tick(deps = {}) {
|
|
34
|
+
if (running) return { skipped: 'busy' };
|
|
35
|
+
running = true;
|
|
36
|
+
try {
|
|
37
|
+
const r = await runLedgerCheck({
|
|
38
|
+
pool: deps.pool || api.pool,
|
|
39
|
+
processor: deps.processor !== undefined ? deps.processor : api.resolveOptional('economy.processor'),
|
|
40
|
+
fileBlocker: deps.fileBlocker !== undefined ? deps.fileBlocker : api.resolveOptional('ideas.fileBlocker'),
|
|
41
|
+
now: deps.now,
|
|
42
|
+
log,
|
|
43
|
+
});
|
|
44
|
+
if (r.outcome) log.info(`[ledger-check] ${r.outcome}${r.alarm && r.alarm !== 'none' ? ` (alarm ${r.alarm})` : ''}`);
|
|
45
|
+
return r;
|
|
46
|
+
} catch (e) {
|
|
47
|
+
const msg = (e && e.message) || String(e);
|
|
48
|
+
if (/ECONNREFUSED|ENOTFOUND|does not exist|connect/i.test(msg)) {
|
|
49
|
+
if (!warnedUnreachable) { warnedUnreachable = true; log.info(`[ledger-check] skipped — database unreachable (${msg})`); }
|
|
50
|
+
return { skipped: 'db_unreachable' };
|
|
51
|
+
}
|
|
52
|
+
log.error(`[ledger-check] tick failed: ${msg}`);
|
|
53
|
+
if (typeof deps.reportError === 'function') deps.reportError();
|
|
54
|
+
return { skipped: 'error', error: msg };
|
|
55
|
+
} finally {
|
|
56
|
+
running = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let firstTimer = null;
|
|
61
|
+
let interval = null;
|
|
62
|
+
|
|
63
|
+
// Only reportError is taken from the loader's deps, so a loader dep can never be read as a
|
|
64
|
+
// check seam (pool, processor, fileBlocker).
|
|
65
|
+
function start({ reportError } = {}) {
|
|
66
|
+
if (process.env.ROUTINE_TIMERS_DISABLED === '1') {
|
|
67
|
+
log.info('[ledger-check] disabled (ROUTINE_TIMERS_DISABLED=1)');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (firstTimer || interval) return;
|
|
71
|
+
const fire = () => tick({ reportError });
|
|
72
|
+
firstTimer = setTimeout(() => {
|
|
73
|
+
firstTimer = null;
|
|
74
|
+
fire();
|
|
75
|
+
interval = setInterval(fire, TICK_MS);
|
|
76
|
+
if (interval.unref) interval.unref();
|
|
77
|
+
}, FIRST_MS + Math.floor(Math.random() * JITTER_MS));
|
|
78
|
+
if (firstTimer.unref) firstTimer.unref();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stop() {
|
|
82
|
+
if (firstTimer) clearTimeout(firstTimer);
|
|
83
|
+
if (interval) clearInterval(interval);
|
|
84
|
+
firstTimer = interval = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { start, stop, tick, TICK_MS };
|
|
@@ -50,7 +50,8 @@ function checkMovement(input) {
|
|
|
50
50
|
}
|
|
51
51
|
const need = (cond, what) => { if (!cond) throw new MovementRefusal('cause_missing', `a ${i.kind} must name ${what}`); };
|
|
52
52
|
if (i.kind === 'payout') {
|
|
53
|
-
need(isId(i.builderId), 'the builder paid'); need(isId(i.taskId), 'the task it was for');
|
|
53
|
+
need(isId(i.builderId), 'the builder paid'); need(isId(i.taskId) || isId(i.moduleShareId), 'the task or module-sale share it was for');
|
|
54
|
+
need(present(i.processorRef), 'the processor transfer');
|
|
54
55
|
} else if (i.kind === 'payout_return') {
|
|
55
56
|
need(isId(i.builderId), 'the builder it came back from'); need(present(i.processorRef), 'the processor reference');
|
|
56
57
|
} else if (i.kind === 'funding' || i.kind === 'fee') {
|
|
@@ -64,6 +65,7 @@ function checkMovement(input) {
|
|
|
64
65
|
kind: i.kind, direction, amount_micro: amount,
|
|
65
66
|
builder_id: isId(i.builderId) ? String(i.builderId) : null,
|
|
66
67
|
task_id: isId(i.taskId) ? String(i.taskId) : null,
|
|
68
|
+
module_share_id: isId(i.moduleShareId) ? String(i.moduleShareId) : null,
|
|
67
69
|
corrects_id: isId(i.correctsId) ? String(i.correctsId) : null,
|
|
68
70
|
processor_ref: present(i.processorRef) ? i.processorRef.trim() : null,
|
|
69
71
|
actor_id: isId(i.actorId) ? String(i.actorId) : null,
|
|
@@ -78,11 +80,11 @@ async function recordMovement(db, input) {
|
|
|
78
80
|
const r = checkMovement(input);
|
|
79
81
|
const ins = await db.query(
|
|
80
82
|
`INSERT INTO economy_project_account_movements
|
|
81
|
-
(kind, direction, amount_micro, builder_id, task_id, corrects_id, processor_ref, actor_id, note)
|
|
82
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
83
|
+
(kind, direction, amount_micro, builder_id, task_id, corrects_id, processor_ref, actor_id, note, module_share_id)
|
|
84
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
83
85
|
ON CONFLICT (kind, processor_ref) WHERE processor_ref IS NOT NULL DO NOTHING
|
|
84
86
|
RETURNING *`,
|
|
85
|
-
[r.kind, r.direction, r.amount_micro, r.builder_id, r.task_id, r.corrects_id, r.processor_ref, r.actor_id, r.note]);
|
|
87
|
+
[r.kind, r.direction, r.amount_micro, r.builder_id, r.task_id, r.corrects_id, r.processor_ref, r.actor_id, r.note, r.module_share_id]);
|
|
86
88
|
if (ins.rows[0]) return { recorded: true, row: ins.rows[0] };
|
|
87
89
|
// Only the one-per-reference rule is absorbed above, so a skipped insert always has a reference.
|
|
88
90
|
const had = await db.query(
|
|
@@ -108,6 +110,16 @@ const RECENT_SQL = `
|
|
|
108
110
|
ORDER BY m.created_at DESC, m.id DESC
|
|
109
111
|
LIMIT $1 OFFSET $2`;
|
|
110
112
|
|
|
113
|
+
// accountTotals(db) → the one aggregate row (recorded balance, held, in, out, count). The
|
|
114
|
+
// outflow view (outflow.js) reads the same sums, so they live in one statement.
|
|
115
|
+
// What is HELD is what the funding wall holds: only holds for work still in flight
|
|
116
|
+
// (task 1003185, readAvailable below). A hold whose task shipped or was let go before its
|
|
117
|
+
// hold_release row was written is not counted, so neither view shows money reserved for nothing.
|
|
118
|
+
async function accountTotals(db) {
|
|
119
|
+
const [totals, live] = await Promise.all([db.query(TOTALS_SQL), readAvailable(db)]);
|
|
120
|
+
return { ...totals.rows[0], held_micro: live.held_micro };
|
|
121
|
+
}
|
|
122
|
+
|
|
111
123
|
// shapeAccount — pure. totals is the one aggregate row, processorMicro the processor's own
|
|
112
124
|
// balance or null when no processor is connected. available = what the project could still
|
|
113
125
|
// commit: the recorded balance less what is held. drift is only named when both figures exist.
|
|
@@ -156,11 +168,84 @@ async function readAccount(db, { limit = 50, offset = 0, processor = null, log =
|
|
|
156
168
|
const lim = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
|
|
157
169
|
const off = Math.max(parseInt(offset, 10) || 0, 0);
|
|
158
170
|
const [totals, recent, read] = await Promise.all([
|
|
159
|
-
db
|
|
171
|
+
accountTotals(db), db.query(RECENT_SQL, [lim, off]), readProcessor(processor, log),
|
|
160
172
|
]);
|
|
161
|
-
const out = shapeAccount(totals
|
|
173
|
+
const out = shapeAccount(totals, recent.rows, read.micro);
|
|
162
174
|
if (read.failed) out.processor_error = 'The processor balance could not be read just now.';
|
|
163
175
|
return out;
|
|
164
176
|
}
|
|
165
177
|
|
|
166
|
-
|
|
178
|
+
// ── the funding wall's half (task 1003185, ADR 0386) ────────────────────────
|
|
179
|
+
//
|
|
180
|
+
// The wall itself (which rule, which task, the refusal) is the lifecycle module's: it owns
|
|
181
|
+
// the claim transaction. What it needs from the account is three things, reached through
|
|
182
|
+
// the `economy.account` port with the caller's own transaction client:
|
|
183
|
+
//
|
|
184
|
+
// lockAccount one advisory lock for the whole account, held to commit, so two
|
|
185
|
+
// claims cannot both read the same available figure and both hold it
|
|
186
|
+
// releaseSettledHolds a `hold_release` row for every hold whose task is no longer in
|
|
187
|
+
// work and owes nothing (paid, released back to the queue, abandoned). Holds are
|
|
188
|
+
// let go by STATE, not by hooking each of the paths that release a
|
|
189
|
+
// claim, so none of them can leave a hold behind for good
|
|
190
|
+
// readAvailable recorded balance, what is held for work in flight or shipped and not
|
|
191
|
+
// yet paid, and the difference. `exceptTaskId` leaves one task's own hold
|
|
192
|
+
// out, for the completion-time check of a task that was held at claim
|
|
193
|
+
// readLiveHolds the same holds task by task, so the outflow view can tell a debt the
|
|
194
|
+
// balance already covers from one it does not (outflow.js)
|
|
195
|
+
|
|
196
|
+
// A hold counts while its task is being worked: claimed, declared done, or graded. It also
|
|
197
|
+
// counts once the task has SHIPPED, until a `payout` movement for that task is booked: the
|
|
198
|
+
// builder is paid after the ship, by a person's approval (builder-payouts.js), and letting
|
|
199
|
+
// the hold go at the ship left that pay unreserved, so a second claim could be promised the
|
|
200
|
+
// same money (found by the end-to-end proof, task 1003186).
|
|
201
|
+
const HELD_STATUSES = Object.freeze(['active', 'completed', 'confirmed']);
|
|
202
|
+
const LIVE_HOLD_SQL = `(t.status = ANY($1::text[]) OR (t.status = 'shipped' AND NOT EXISTS (
|
|
203
|
+
SELECT 1 FROM economy_project_account_movements p WHERE p.kind = 'payout' AND p.task_id = h.task_id)))`;
|
|
204
|
+
const ACCOUNT_LOCK = [1003185, 1];
|
|
205
|
+
|
|
206
|
+
const NET_HOLDS_SQL = `
|
|
207
|
+
SELECT m.task_id, SUM(CASE m.kind WHEN 'hold' THEN m.amount_micro ELSE -m.amount_micro END) AS net
|
|
208
|
+
FROM economy_project_account_movements m
|
|
209
|
+
WHERE m.kind IN ('hold', 'hold_release')
|
|
210
|
+
GROUP BY m.task_id`;
|
|
211
|
+
|
|
212
|
+
async function lockAccount(db) {
|
|
213
|
+
await db.query('SELECT pg_advisory_xact_lock($1, $2)', ACCOUNT_LOCK);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// → [{ task_id, amount_micro }] for each hold released.
|
|
217
|
+
async function releaseSettledHolds(db) {
|
|
218
|
+
const { rows } = await db.query(
|
|
219
|
+
`INSERT INTO economy_project_account_movements (kind, direction, amount_micro, task_id, note)
|
|
220
|
+
SELECT 'hold_release', 'none', h.net, h.task_id, 'The task is no longer being worked and owes nothing, so its hold is let go.'
|
|
221
|
+
FROM (${NET_HOLDS_SQL}) h JOIN tasks t ON t.id = h.task_id
|
|
222
|
+
WHERE h.net > 0 AND NOT ${LIVE_HOLD_SQL}
|
|
223
|
+
RETURNING task_id, amount_micro`, [HELD_STATUSES]);
|
|
224
|
+
return rows.map((r) => ({ task_id: String(r.task_id), amount_micro: Number(r.amount_micro) }));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function readAvailable(db, { exceptTaskId = null } = {}) {
|
|
228
|
+
const { rows: [r] } = await db.query(
|
|
229
|
+
`SELECT
|
|
230
|
+
(SELECT COALESCE(SUM(CASE direction WHEN 'in' THEN amount_micro WHEN 'out' THEN -amount_micro ELSE 0 END), 0)
|
|
231
|
+
FROM economy_project_account_movements) AS balance_micro,
|
|
232
|
+
(SELECT COALESCE(SUM(h.net), 0) FROM (${NET_HOLDS_SQL}) h JOIN tasks t ON t.id = h.task_id
|
|
233
|
+
WHERE h.net > 0 AND ${LIVE_HOLD_SQL} AND ($2::bigint IS NULL OR h.task_id <> $2::bigint)) AS held_micro`,
|
|
234
|
+
[HELD_STATUSES, exceptTaskId == null ? null : String(exceptTaskId)]);
|
|
235
|
+
const balance = Number(r.balance_micro);
|
|
236
|
+
const held = Number(r.held_micro);
|
|
237
|
+
return { balance_micro: balance, held_micro: held, available_micro: balance - held };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// → Map<task id, micro-dollars held> for every hold readAvailable counts.
|
|
241
|
+
async function readLiveHolds(db) {
|
|
242
|
+
const { rows } = await db.query(
|
|
243
|
+
`SELECT h.task_id, h.net FROM (${NET_HOLDS_SQL}) h JOIN tasks t ON t.id = h.task_id
|
|
244
|
+
WHERE h.net > 0 AND ${LIVE_HOLD_SQL}`, [HELD_STATUSES]);
|
|
245
|
+
return new Map(rows.map((r) => [String(r.task_id), Number(r.net)]));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
MovementRefusal, checkMovement, recordMovement, accountTotals, shapeAccount, readAccount,
|
|
250
|
+
HELD_STATUSES, lockAccount, releaseSettledHolds, readAvailable, readLiveHolds,
|
|
251
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// modules/economy/routes/balances.js — a user's balance, the project's balance, and the
|
|
2
|
+
// two payments a person may make on their own instruction (task 1004907, goal 1000092 N3;
|
|
3
|
+
// ADR 0385, ADR 0397). No platform fee on either.
|
|
4
|
+
//
|
|
5
|
+
// GET /balances/me → { connected, balance_micro|null, holds_money:false, transfers:[…] }
|
|
6
|
+
// GET /balances/project → { balance_micro|null, holds_money:false } (the figure only)
|
|
7
|
+
// POST /balances/fund-project { amount_cents, instruction_id }
|
|
8
|
+
// POST /balances/pay { to_builder_id, amount_cents, instruction_id, note? }
|
|
9
|
+
// → { transfer_id, status: 'sent'|'held'|'failed', platform_fee_cents: 0, … }
|
|
10
|
+
//
|
|
11
|
+
// Balances are the processor's figures; `../user-balances.js` owns the SQL and the order.
|
|
12
|
+
// A payment needs the payer's own signed-in WEB session: a CLI token, an agent or a
|
|
13
|
+
// machine principal never moves a person's money (the payout route's rule).
|
|
14
|
+
//
|
|
15
|
+
// MODULE BOUNDARY: reaches core only through `../../../src/module-api`.
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const express = require('express');
|
|
20
|
+
const api = require('../../../src/module-api');
|
|
21
|
+
const { TransferRefusal, readMyBalance, readProjectBalance, transfer } = require('../user-balances');
|
|
22
|
+
|
|
23
|
+
const log = api.logger('economy');
|
|
24
|
+
|
|
25
|
+
// The person themself, in a browser, signed in: never a CLI token, an agent or a machine.
|
|
26
|
+
function personalWebSession(req) {
|
|
27
|
+
const s = req.gdsSession;
|
|
28
|
+
const b = req.builder;
|
|
29
|
+
return Boolean(b && s && !b.system_role && s.source === 'web' && s.session_id !== undefined && s.session_id !== null);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = function buildBalancesRouter({ processor = null } = {}) {
|
|
33
|
+
const router = express.Router();
|
|
34
|
+
|
|
35
|
+
const fail = (res, err, what) => {
|
|
36
|
+
if (err instanceof TransferRefusal) return res.fail(err.code, { status: err.status, message: err.message });
|
|
37
|
+
log.error({ err: err.message }, `${what} failed`);
|
|
38
|
+
if (!res.headersSent) res.fail('balance_failed', { status: 500, message: 'internal error' });
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// rank: any-builder — the signed-in person's own balance and their own payments.
|
|
42
|
+
router.get('/balances/me', api.requireBuilder, async (req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
const out = await readMyBalance(api.pool, req.builder.id, { processor, limit: req.query.limit, log });
|
|
45
|
+
res.set('Cache-Control', 'no-store');
|
|
46
|
+
res.json(out);
|
|
47
|
+
} catch (err) { fail(res, err, 'GET /balances/me'); }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// rank: any-builder — the project's balance figure, so a member can see whether it needs funding.
|
|
51
|
+
router.get('/balances/project', api.requireBuilder, async (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
const out = await readProjectBalance({ processor, log });
|
|
54
|
+
res.set('Cache-Control', 'no-store');
|
|
55
|
+
res.json(out);
|
|
56
|
+
} catch (err) { fail(res, err, 'GET /balances/project'); }
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const pay = (kind) => async (req, res) => {
|
|
60
|
+
if (!personalWebSession(req)) {
|
|
61
|
+
return res.fail('personal_session_required', { status: 403, message: 'A payment is made by the person themself, signed in on the web.' });
|
|
62
|
+
}
|
|
63
|
+
const b = req.body || {};
|
|
64
|
+
try {
|
|
65
|
+
const out = await transfer({
|
|
66
|
+
kind, fromBuilderId: String(req.builder.id), toBuilderId: b.to_builder_id,
|
|
67
|
+
amountCents: b.amount_cents, instructionId: b.instruction_id, note: b.note,
|
|
68
|
+
}, { pool: api.pool, processor });
|
|
69
|
+
res.status(out.status === 'sent' ? 200 : 202).json(out);
|
|
70
|
+
} catch (err) { fail(res, err, `POST /balances/${kind}`); }
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// rank: any-builder — a person funds this project from their own account, on their own web session.
|
|
74
|
+
router.post('/balances/fund-project', api.requireBuilder, pay('fund_project'));
|
|
75
|
+
// rank: any-builder — a person pays another member of this project from their own account.
|
|
76
|
+
router.post('/balances/pay', api.requireBuilder, pay('pay_user'));
|
|
77
|
+
|
|
78
|
+
return router;
|
|
79
|
+
};
|
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
const express = require('express');
|
|
17
17
|
const api = require('../../../src/module-api');
|
|
18
|
-
const { readAccount, recordMovement } = require('../project-account');
|
|
18
|
+
const { readAccount, recordMovement, lockAccount, releaseSettledHolds, readAvailable } = require('../project-account');
|
|
19
|
+
const { readOutflow } = require('../outflow');
|
|
20
|
+
const { readLedgerChecks } = require('../ledger-check');
|
|
21
|
+
const { memberAccount } = require('../user-balances');
|
|
19
22
|
|
|
20
23
|
const log = api.logger('economy');
|
|
21
24
|
|
|
@@ -23,8 +26,13 @@ const log = api.logger('economy');
|
|
|
23
26
|
// books its `payout` movement through this, inside its own transaction, without importing the
|
|
24
27
|
// module. Registered here for the reason routes/reward.js gives: the loader runs this factory in
|
|
25
28
|
// the live server and whenever a test rebuilds the router. hasProvider-guarded for re-runs.
|
|
29
|
+
// The lifecycle's funding wall (task 1003185, ADR 0386) uses the other three, the same way.
|
|
30
|
+
// Core's cash-out (task 1004911) reads a person's processor account in this project through
|
|
31
|
+
// memberAccount, so the account a withdrawal pays from is never one the request names.
|
|
26
32
|
function registerSeams() {
|
|
27
|
-
if (!api.hasProvider('economy.account'))
|
|
33
|
+
if (!api.hasProvider('economy.account')) {
|
|
34
|
+
api.registerProvider('economy.account', { recordMovement, lockAccount, releaseSettledHolds, readAvailable, memberAccount });
|
|
35
|
+
}
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
module.exports = function buildProjectAccountRouter({ processor = null } = {}) {
|
|
@@ -45,5 +53,35 @@ module.exports = function buildProjectAccountRouter({ processor = null } = {}) {
|
|
|
45
53
|
}
|
|
46
54
|
});
|
|
47
55
|
|
|
56
|
+
// GET /project-account/outflow?months=12 — where the money went (task 1003184, criterion
|
|
57
|
+
// money-outflow-visible): spend and money out per month, per-builder totals, and what is
|
|
58
|
+
// committed but not yet paid beside the balance left after it. Same gate as the account:
|
|
59
|
+
// who was paid what is the project's own money. Read only, summed from existing records.
|
|
60
|
+
router.get('/project-account/outflow', api.requireBuilder, api.requirePermission('payout.approve'), async (req, res) => {
|
|
61
|
+
try {
|
|
62
|
+
const out = await readOutflow(api.pool, { months: req.query.months });
|
|
63
|
+
res.set('Cache-Control', 'no-store');
|
|
64
|
+
res.json(out);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
log.error({ err: err.message }, 'GET /project-account/outflow failed');
|
|
67
|
+
if (!res.headersSent) res.fail('project_outflow_read_failed', { status: 500, message: 'internal error' });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// GET /project-account/ledger-checks?limit=10 — the daily check of our record against the
|
|
72
|
+
// processor's (task 1004912, ADR 0397 D7): each check's outcome and, on a difference, the
|
|
73
|
+
// amounts and processor references. Same gate as the account; the alarm it raises is a
|
|
74
|
+
// public blocker with no numbers in it, so the numbers are read here.
|
|
75
|
+
router.get('/project-account/ledger-checks', api.requireBuilder, api.requirePermission('payout.approve'), async (req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
const out = await readLedgerChecks(api.pool, { limit: req.query.limit });
|
|
78
|
+
res.set('Cache-Control', 'no-store');
|
|
79
|
+
res.json(out);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
log.error({ err: err.message }, 'GET /project-account/ledger-checks failed');
|
|
82
|
+
if (!res.headersSent) res.fail('ledger_checks_read_failed', { status: 500, message: 'internal error' });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
48
86
|
return router;
|
|
49
87
|
};
|