@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
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/ledger-check.js — EVERY DAY, OUR RECORD AGAINST THE PROCESSOR'S, WITH AN
|
|
4
|
+
// ALARM TO THE OWNER ON ANY DIFFERENCE (task 1004912, goal 1000092 N8; ADR 0397 decision 7).
|
|
5
|
+
//
|
|
6
|
+
// WHY. Bongos holds no money (ADR 0385, ADR 0397): the processor does, and what Bongos shows
|
|
7
|
+
// is its own record of each movement and its cause. That record is only worth showing if it
|
|
8
|
+
// is TRUE, and the way to prove that without holding the money is to set it beside the
|
|
9
|
+
// processor's figures every day and say so loudly when they differ.
|
|
10
|
+
//
|
|
11
|
+
// THE PRODUCT GATE (legal framework, by Article only; a product constraint, not advice).
|
|
12
|
+
// actor nobody moves anything. The server's own daily timer reads two records and
|
|
13
|
+
// writes one row saying whether they agree.
|
|
14
|
+
// custody the processor, throughout. Nothing here holds, moves, nets or corrects money;
|
|
15
|
+
// a difference is raised to a person, never "fixed" by a booked figure.
|
|
16
|
+
// words record, balance, difference, check. Never deposit, wallet, interest or yield.
|
|
17
|
+
// control read only, once a day. The one write is this check's own row (and the alarm).
|
|
18
|
+
// advice none is given.
|
|
19
|
+
// primitive the Stripe Connect balance read of the project's connected account, and the
|
|
20
|
+
// list of its balance transactions / transfers / payouts (ADR 0397 D3 table).
|
|
21
|
+
// Only the processor adapter names Stripe; here it is a port.
|
|
22
|
+
//
|
|
23
|
+
// WHAT IS COMPARED.
|
|
24
|
+
// 1. The project's balance: the sum of our record (project-account.js) against the
|
|
25
|
+
// processor's own figure for the project's connected account. Any difference is drift.
|
|
26
|
+
// 2. Every movement, by its processor reference, over the last WINDOW_DAYS: each one our
|
|
27
|
+
// records say happened (project-account movements, member payments, cash-outs) must be
|
|
28
|
+
// one the processor has, for the same amount; and each one the processor has must be one
|
|
29
|
+
// we recorded. This is what proves each USER's side: by ADR 0397 a member's balance is
|
|
30
|
+
// the processor's figure and is never summed from our rows, so what is checked for a
|
|
31
|
+
// member is that every payment to or from them that we show is one the processor made,
|
|
32
|
+
// for the amount we show.
|
|
33
|
+
//
|
|
34
|
+
// THE PROCESSOR PORT (all optional):
|
|
35
|
+
// balanceMicro() the project's account balance (the existing read)
|
|
36
|
+
// listMovements({ since, until }) → [{ ref, amountMicro, createdAt }] every movement
|
|
37
|
+
// the processor made for this project's connection in
|
|
38
|
+
// that window, by the same reference Bongos stored
|
|
39
|
+
// With no port, or neither method, the check is recorded `unchecked` (no_processor) and no
|
|
40
|
+
// alarm is raised: there is nothing yet to compare with. That is the state of every
|
|
41
|
+
// instance until a processor adapter lands (task 1005040).
|
|
42
|
+
//
|
|
43
|
+
// THE ALARM. A difference, or a processor that could not be read, opens a blocker for the
|
|
44
|
+
// owner. The blocker queue is PUBLIC (the status dashboard shows it), so the blocker carries
|
|
45
|
+
// no amounts and no references: it says a difference was found and where to read it. The
|
|
46
|
+
// numbers are in this check's row, read through GET /project-account/ledger-checks behind
|
|
47
|
+
// the payout authority (payout.approve), and shown on the owner's Project settings page.
|
|
48
|
+
// The same set of differences is raised once, not every day; a changed set is raised again,
|
|
49
|
+
// and a clean day clears it, so a difference that comes back is a fresh alarm.
|
|
50
|
+
|
|
51
|
+
const crypto = require('node:crypto');
|
|
52
|
+
|
|
53
|
+
const WINDOW_DAYS = 7;
|
|
54
|
+
// A movement made in the last hour may not be in the processor's list yet, and the two
|
|
55
|
+
// clocks are not the same clock, so the newest hour is left to tomorrow's check.
|
|
56
|
+
const SETTLE_MS = 60 * 60_000;
|
|
57
|
+
// The processor's list is asked for from a day before our window starts up to now, so a
|
|
58
|
+
// movement whose two timestamps fall either side of an edge is still in their list and is not
|
|
59
|
+
// reported missing. (Our window ends SETTLE_MS before now; theirs runs to now.)
|
|
60
|
+
const EDGE_MS = 24 * 60 * 60_000;
|
|
61
|
+
const READ_TIMEOUT_MS = 15_000;
|
|
62
|
+
const MICRO_PER_CENT = 10_000;
|
|
63
|
+
const FINDINGS_MAX = 200;
|
|
64
|
+
const LOCK = [1004912, 1];
|
|
65
|
+
|
|
66
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
67
|
+
|
|
68
|
+
const KIND_WORDS = Object.freeze({
|
|
69
|
+
balance: 'The balance differs',
|
|
70
|
+
missing_at_processor: 'We recorded it, the processor has no such movement',
|
|
71
|
+
unknown_to_us: 'The processor made it, we have no record of it',
|
|
72
|
+
amount_differs: 'Both have it, for different amounts',
|
|
73
|
+
records_disagree: 'Two of our own records give this reference different amounts',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function withTimeout(p, ms) {
|
|
77
|
+
let timer;
|
|
78
|
+
return Promise.race([
|
|
79
|
+
p,
|
|
80
|
+
new Promise((_, rej) => { timer = setTimeout(() => rej(Object.assign(new Error('the processor did not answer in time'), { code: 'timeout' })), ms); }),
|
|
81
|
+
]).finally(() => clearTimeout(timer));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Every movement our records say went through the processor, as { ref, amount_micro,
|
|
85
|
+
// created_at, source }. One reference can appear twice (a member funding the project is both
|
|
86
|
+
// a member payment and a project `funding` movement), so the caller folds by reference.
|
|
87
|
+
const OUR_REFS_SQL = `
|
|
88
|
+
SELECT processor_ref AS ref, amount_micro, created_at, 'project_account:' || kind AS source
|
|
89
|
+
FROM economy_project_account_movements
|
|
90
|
+
WHERE processor_ref IS NOT NULL AND kind IN ('funding', 'payout', 'payout_return', 'fee') AND ($1::text[] IS NULL OR processor_ref = ANY($1))
|
|
91
|
+
AND ($2::timestamptz IS NULL OR created_at >= $2) AND ($3::timestamptz IS NULL OR created_at < $3)
|
|
92
|
+
UNION ALL
|
|
93
|
+
SELECT a.processor_ref, t.amount_cents * ${MICRO_PER_CENT}, a.created_at, 'member_payment'
|
|
94
|
+
FROM economy_user_transfer_attempts a JOIN economy_user_transfers t ON t.id = a.transfer_id
|
|
95
|
+
WHERE a.outcome = 'sent' AND ($1::text[] IS NULL OR a.processor_ref = ANY($1))
|
|
96
|
+
AND ($2::timestamptz IS NULL OR a.created_at >= $2) AND ($3::timestamptz IS NULL OR a.created_at < $3)
|
|
97
|
+
UNION ALL
|
|
98
|
+
SELECT a.processor_ref, c.amount_cents * ${MICRO_PER_CENT}, a.created_at, 'cash_out'
|
|
99
|
+
FROM cash_out_attempts a JOIN cash_outs c ON c.id = a.cash_out_id
|
|
100
|
+
WHERE a.outcome = 'sent' AND ($1::text[] IS NULL OR a.processor_ref = ANY($1))
|
|
101
|
+
AND ($2::timestamptz IS NULL OR a.created_at >= $2) AND ($3::timestamptz IS NULL OR a.created_at < $3)`;
|
|
102
|
+
|
|
103
|
+
const RECORDED_BALANCE_SQL = `
|
|
104
|
+
SELECT COALESCE(SUM(CASE direction WHEN 'in' THEN amount_micro WHEN 'out' THEN -amount_micro ELSE 0 END), 0) AS micro
|
|
105
|
+
FROM economy_project_account_movements`;
|
|
106
|
+
|
|
107
|
+
// The store: the SQL, kept apart so the comparison tests without a database.
|
|
108
|
+
function createStore(db) {
|
|
109
|
+
return {
|
|
110
|
+
async recordedBalanceMicro() { return Number((await db.query(RECORDED_BALANCE_SQL)).rows[0].micro); },
|
|
111
|
+
async ourMovements({ since = null, until = null, refs = null } = {}) {
|
|
112
|
+
const { rows } = await db.query(OUR_REFS_SQL, [refs, since, until]);
|
|
113
|
+
return rows.map((r) => ({ ref: r.ref, amount_micro: Number(r.amount_micro), created_at: r.created_at, source: r.source }));
|
|
114
|
+
},
|
|
115
|
+
async recentChecks(limit = 10) {
|
|
116
|
+
const { rows } = await db.query('SELECT * FROM economy_ledger_checks ORDER BY created_at DESC, id DESC LIMIT $1', [limit]);
|
|
117
|
+
return rows;
|
|
118
|
+
},
|
|
119
|
+
async insertCheck(row) {
|
|
120
|
+
const { rows } = await db.query(
|
|
121
|
+
`INSERT INTO economy_ledger_checks
|
|
122
|
+
(outcome, reason, recorded_balance_micro, processor_balance_micro, drift_micro, window_from, window_to,
|
|
123
|
+
refs_compared, findings, fingerprint, alarm, blocker_id)
|
|
124
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12) RETURNING *`,
|
|
125
|
+
[row.outcome, row.reason ?? null, row.recorded_balance_micro ?? null, row.processor_balance_micro ?? null,
|
|
126
|
+
row.drift_micro ?? null, row.window_from ?? null, row.window_to ?? null, row.refs_compared || 0,
|
|
127
|
+
JSON.stringify(row.findings || []), row.fingerprint ?? null, row.alarm || 'none', row.blocker_id ?? null]);
|
|
128
|
+
return rows[0];
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// foldOurs(rows) — pure. One entry per reference; two of our records that give one
|
|
134
|
+
// reference different amounts are themselves a finding.
|
|
135
|
+
function foldOurs(rows) {
|
|
136
|
+
const byRef = new Map();
|
|
137
|
+
const findings = [];
|
|
138
|
+
for (const r of rows) {
|
|
139
|
+
const had = byRef.get(r.ref);
|
|
140
|
+
if (!had) { byRef.set(r.ref, { ref: r.ref, amount_micro: r.amount_micro, sources: [r.source] }); continue; }
|
|
141
|
+
had.sources.push(r.source);
|
|
142
|
+
if (had.amount_micro !== r.amount_micro && !findings.some((f) => f.ref === r.ref)) {
|
|
143
|
+
findings.push({ kind: 'records_disagree', ref: r.ref, ours_micro: had.amount_micro, other_micro: r.amount_micro, sources: had.sources.slice() });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { byRef, findings };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// compareMovements({ ours, theirs, theirsInWindow, oursAnyTime }) — pure.
|
|
150
|
+
// ours our movements inside the window (rows)
|
|
151
|
+
// theirs the processor's movements over the wider window ({ ref, amountMicro, createdAt })
|
|
152
|
+
// inWindow(t) whether one of theirs falls inside our window
|
|
153
|
+
// oursAnyTime Map ref → our entry, for the processor's movements we did not find in-window
|
|
154
|
+
// → findings (each names its reference and both amounts).
|
|
155
|
+
function compareMovements({ ours, theirs, inWindow, oursAnyTime = new Map() }) {
|
|
156
|
+
const { byRef, findings } = foldOurs(ours);
|
|
157
|
+
const theirsByRef = new Map();
|
|
158
|
+
for (const t of theirs) {
|
|
159
|
+
const ref = typeof t.ref === 'string' ? t.ref.trim() : '';
|
|
160
|
+
if (!ref) continue;
|
|
161
|
+
const had = theirsByRef.get(ref);
|
|
162
|
+
// The processor may list one movement as several lines (a transfer and its reversal);
|
|
163
|
+
// what is compared is the movement's own amount, its first line.
|
|
164
|
+
if (!had) theirsByRef.set(ref, { ref, amount_micro: Number(t.amountMicro), createdAt: t.createdAt });
|
|
165
|
+
}
|
|
166
|
+
for (const o of byRef.values()) {
|
|
167
|
+
const t = theirsByRef.get(o.ref);
|
|
168
|
+
if (!t) findings.push({ kind: 'missing_at_processor', ref: o.ref, ours_micro: o.amount_micro, theirs_micro: null, sources: o.sources });
|
|
169
|
+
else if (t.amount_micro !== o.amount_micro) findings.push({ kind: 'amount_differs', ref: o.ref, ours_micro: o.amount_micro, theirs_micro: t.amount_micro, sources: o.sources });
|
|
170
|
+
}
|
|
171
|
+
for (const t of theirsByRef.values()) {
|
|
172
|
+
if (byRef.has(t.ref) || !inWindow(t)) continue;
|
|
173
|
+
const o = oursAnyTime.get(t.ref);
|
|
174
|
+
if (!o) findings.push({ kind: 'unknown_to_us', ref: t.ref, ours_micro: null, theirs_micro: t.amount_micro, sources: [] });
|
|
175
|
+
else if (o.amount_micro !== t.amount_micro) findings.push({ kind: 'amount_differs', ref: t.ref, ours_micro: o.amount_micro, theirs_micro: t.amount_micro, sources: o.sources });
|
|
176
|
+
}
|
|
177
|
+
return { findings, compared: new Set([...byRef.keys(), ...theirsByRef.keys()]).size };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const fingerprintOf = (keys) => crypto.createHash('sha1').update([...keys].sort().join('\n')).digest('hex').slice(0, 16);
|
|
181
|
+
const findingKey = (f) => `${f.kind}:${f.ref || ''}:${f.ours_micro ?? ''}:${f.theirs_micro ?? ''}`;
|
|
182
|
+
|
|
183
|
+
// Whether to raise this set. The last check that could say something (clean, drift or
|
|
184
|
+
// error) decides: the same set it already raised is `unchanged`; anything else is new.
|
|
185
|
+
function alarmDecision(recent, fingerprint) {
|
|
186
|
+
const last = (recent || []).find((r) => r.outcome === 'clean' || r.outcome === 'drift' || r.outcome === 'error');
|
|
187
|
+
if (last && last.outcome !== 'clean' && last.fingerprint === fingerprint && (last.alarm === 'filed' || last.alarm === 'unchanged')) return 'unchanged';
|
|
188
|
+
return 'file';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isDue(recent, now, slackMs = 30 * 60_000) {
|
|
192
|
+
const last = (recent || [])[0];
|
|
193
|
+
if (!last) return true;
|
|
194
|
+
const at = new Date(last.created_at).getTime();
|
|
195
|
+
return !Number.isFinite(at) || now - at >= DAY_MS - slackMs;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The blocker: public, so no amounts and no references (see THE ALARM above).
|
|
199
|
+
function blockerFor(row) {
|
|
200
|
+
const day = new Date(row.created_at || Date.now()).toISOString().slice(0, 10);
|
|
201
|
+
if (row.outcome === 'error') {
|
|
202
|
+
return {
|
|
203
|
+
title: "Ledger check: the payment processor's records could not be read",
|
|
204
|
+
bodyMd: [
|
|
205
|
+
`The daily check of this project's money records against the payment processor (${day}) could not read the processor, so it could not confirm the records are right.`,
|
|
206
|
+
'',
|
|
207
|
+
"The details are on [Project settings](/project-settings), under *Where the money went*, for the project's owner.",
|
|
208
|
+
'',
|
|
209
|
+
'Filed by modules/economy/ledger-check.js (task 1004912). Raised once; a different result is raised again.',
|
|
210
|
+
].join('\n'),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const n = row.findings.length;
|
|
214
|
+
return {
|
|
215
|
+
title: `Ledger check: our money records and the payment processor's differ (${n} ${n === 1 ? 'difference' : 'differences'})`,
|
|
216
|
+
bodyMd: [
|
|
217
|
+
`The daily check of this project's money records against the payment processor (${day}) found ${n} ${n === 1 ? 'difference' : 'differences'}. Until it is explained, a balance shown in Bongos may not match the money the processor holds.`,
|
|
218
|
+
'',
|
|
219
|
+
"The amounts and the processor's references for each difference are on [Project settings](/project-settings), under *Where the money went*, for the project's owner. They are not repeated here because this list is public: anyone can read it, and who was paid what is the project's own business.",
|
|
220
|
+
'',
|
|
221
|
+
'Nothing was changed or moved by the check. A wrong record is corrected by a new adjustment, never an edit.',
|
|
222
|
+
'',
|
|
223
|
+
'Filed by modules/economy/ledger-check.js (task 1004912). The same differences are not re-filed tomorrow; a changed set is.',
|
|
224
|
+
].join('\n'),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// runLedgerCheck(deps) — one check, if one is due. Never throws for a processor failure (that
|
|
229
|
+
// is recorded as `error`); a database failure is the caller's to log.
|
|
230
|
+
// deps: { pool, store, processor, fileBlocker, now, force, log }
|
|
231
|
+
async function runLedgerCheck(deps = {}) {
|
|
232
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
233
|
+
const pool = deps.pool;
|
|
234
|
+
const client = deps.client || (pool && typeof pool.connect === 'function' ? await pool.connect() : null);
|
|
235
|
+
const db = client || pool;
|
|
236
|
+
const store = deps.store || createStore(db);
|
|
237
|
+
try {
|
|
238
|
+
// One check at a time across every server process sharing this database.
|
|
239
|
+
if (client) {
|
|
240
|
+
const { rows } = await client.query('SELECT pg_try_advisory_lock($1, $2) AS got', LOCK);
|
|
241
|
+
if (!rows[0].got) return { skipped: 'busy' };
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const recent = await store.recentChecks(10);
|
|
245
|
+
if (!deps.force && !isDue(recent, now)) return { skipped: 'not_due' };
|
|
246
|
+
const row = await check({ store, processor: deps.processor, now, log: deps.log });
|
|
247
|
+
if (row.outcome === 'drift' || row.outcome === 'error') {
|
|
248
|
+
const decision = alarmDecision(recent, row.fingerprint);
|
|
249
|
+
if (decision === 'unchanged') row.alarm = 'unchanged';
|
|
250
|
+
else if (typeof deps.fileBlocker !== 'function') row.alarm = 'undelivered';
|
|
251
|
+
else {
|
|
252
|
+
try {
|
|
253
|
+
const b = await deps.fileBlocker({
|
|
254
|
+
...blockerFor({ ...row, created_at: new Date(now) }),
|
|
255
|
+
source: 'ledger-check', sourceRef: `ledger-check-${row.fingerprint}-${new Date(now).toISOString().slice(0, 10)}`,
|
|
256
|
+
});
|
|
257
|
+
if (b && b.id) { row.alarm = 'filed'; row.blocker_id = String(b.id); } else row.alarm = 'unchanged';
|
|
258
|
+
} catch (e) {
|
|
259
|
+
if (deps.log) deps.log.error({ err: e.message }, 'ledger check: filing the alarm failed');
|
|
260
|
+
row.alarm = 'undelivered';
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const saved = await store.insertCheck(row);
|
|
265
|
+
return { outcome: row.outcome, alarm: row.alarm, check: saved };
|
|
266
|
+
} finally {
|
|
267
|
+
if (client) await client.query('SELECT pg_advisory_unlock($1, $2)', LOCK).catch(() => {});
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
if (client && !deps.client) client.release();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// check — the comparison itself, as the row to write.
|
|
275
|
+
async function check({ store, processor, now, log }) {
|
|
276
|
+
const hasBalance = !!processor && typeof processor.balanceMicro === 'function';
|
|
277
|
+
const hasList = !!processor && typeof processor.listMovements === 'function';
|
|
278
|
+
const recorded = await store.recordedBalanceMicro();
|
|
279
|
+
if (!hasBalance && !hasList) {
|
|
280
|
+
return { outcome: 'unchecked', reason: 'no_processor', recorded_balance_micro: recorded, alarm: 'none' };
|
|
281
|
+
}
|
|
282
|
+
const windowTo = new Date(now - SETTLE_MS);
|
|
283
|
+
const windowFrom = new Date(windowTo.getTime() - WINDOW_DAYS * DAY_MS);
|
|
284
|
+
const findings = [];
|
|
285
|
+
const failed = [];
|
|
286
|
+
let processorMicro = null;
|
|
287
|
+
let drift = null;
|
|
288
|
+
let compared = 0;
|
|
289
|
+
|
|
290
|
+
if (hasBalance) {
|
|
291
|
+
try {
|
|
292
|
+
const v = Number(await withTimeout(Promise.resolve(processor.balanceMicro()), READ_TIMEOUT_MS));
|
|
293
|
+
if (!Number.isSafeInteger(v)) throw new Error('the processor gave no whole-number balance');
|
|
294
|
+
processorMicro = v;
|
|
295
|
+
drift = v - recorded;
|
|
296
|
+
if (drift !== 0) findings.push({ kind: 'balance', ref: null, ours_micro: recorded, theirs_micro: v });
|
|
297
|
+
} catch (e) {
|
|
298
|
+
failed.push('balance');
|
|
299
|
+
if (log) log.error({ err: e.message }, 'ledger check: processor balance unreadable');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (hasList) {
|
|
303
|
+
try {
|
|
304
|
+
const theirs = await withTimeout(Promise.resolve(processor.listMovements({
|
|
305
|
+
since: new Date(windowFrom.getTime() - EDGE_MS), until: new Date(now),
|
|
306
|
+
})), READ_TIMEOUT_MS);
|
|
307
|
+
if (!Array.isArray(theirs)) throw new Error('the processor gave no list of movements');
|
|
308
|
+
const ours = await store.ourMovements({ since: windowFrom, until: windowTo });
|
|
309
|
+
const ourRefs = new Set(ours.map((o) => o.ref));
|
|
310
|
+
const inWindow = (t) => {
|
|
311
|
+
const at = new Date(t.createdAt).getTime();
|
|
312
|
+
return Number.isFinite(at) && at >= windowFrom.getTime() && at < windowTo.getTime();
|
|
313
|
+
};
|
|
314
|
+
const outside = [...new Set(theirs.filter((t) => t && inWindow(t) && !ourRefs.has(t.ref)).map((t) => t.ref))];
|
|
315
|
+
const oursAnyTime = outside.length ? foldOurs(await store.ourMovements({ refs: outside })).byRef : new Map();
|
|
316
|
+
const out = compareMovements({ ours, theirs: theirs.filter(Boolean), inWindow, oursAnyTime });
|
|
317
|
+
findings.push(...out.findings);
|
|
318
|
+
compared = out.compared;
|
|
319
|
+
} catch (e) {
|
|
320
|
+
failed.push('movements');
|
|
321
|
+
if (log) log.error({ err: e.message }, 'ledger check: processor movements unreadable');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const base = {
|
|
326
|
+
recorded_balance_micro: recorded, processor_balance_micro: processorMicro, drift_micro: drift,
|
|
327
|
+
window_from: hasList ? windowFrom : null, window_to: hasList ? windowTo : null, refs_compared: compared,
|
|
328
|
+
};
|
|
329
|
+
if (failed.length) {
|
|
330
|
+
// "Could not check" is never clean, even when the part that was read agreed.
|
|
331
|
+
return {
|
|
332
|
+
...base, outcome: 'error', reason: `unreadable: ${failed.join(', ')}`,
|
|
333
|
+
findings: findings.slice(0, FINDINGS_MAX), fingerprint: fingerprintOf([`error:${failed.join(',')}`, ...findings.map(findingKey)]), alarm: 'none',
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (!findings.length) return { ...base, outcome: 'clean', findings: [], fingerprint: null, alarm: 'none' };
|
|
337
|
+
return { ...base, outcome: 'drift', findings: findings.slice(0, FINDINGS_MAX), fingerprint: fingerprintOf(findings.map(findingKey)), alarm: 'none' };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// readLedgerChecks(db, { limit }) — the owner's view: the newest checks, numbers included.
|
|
341
|
+
async function readLedgerChecks(db, { limit = 10 } = {}) {
|
|
342
|
+
const lim = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 60);
|
|
343
|
+
const rows = await createStore(db).recentChecks(lim);
|
|
344
|
+
const n = (v) => (v === null || v === undefined ? null : Number(v));
|
|
345
|
+
return {
|
|
346
|
+
unit: 'micro_usd', holds_money: false, kind_words: KIND_WORDS,
|
|
347
|
+
checks: rows.map((r) => ({
|
|
348
|
+
id: String(r.id), created_at: r.created_at, outcome: r.outcome, reason: r.reason,
|
|
349
|
+
recorded_balance_micro: n(r.recorded_balance_micro), processor_balance_micro: n(r.processor_balance_micro),
|
|
350
|
+
drift_micro: n(r.drift_micro), window_from: r.window_from, window_to: r.window_to,
|
|
351
|
+
refs_compared: Number(r.refs_compared), findings: r.findings || [], alarm: r.alarm,
|
|
352
|
+
blocker_id: r.blocker_id === null ? null : String(r.blocker_id),
|
|
353
|
+
})),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
WINDOW_DAYS, SETTLE_MS, KIND_WORDS,
|
|
359
|
+
createStore, isDue, runLedgerCheck, readLedgerChecks,
|
|
360
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
-- economy_003_user_balances.sql — a user's money in this project, and the payments
|
|
2
|
+
-- they make on their own instruction: funding the project, paying another user. No
|
|
3
|
+
-- platform fee (task 1004907, goal 1000092 N3; ADR 0385, ADR 0387, ADR 0397 D2/D3).
|
|
4
|
+
--
|
|
5
|
+
-- NO BALANCE IS HERE. Each user's money sits in their own processor-held connected
|
|
6
|
+
-- account, the project's in the project's (ADR 0397 D2). A balance on screen is a read
|
|
7
|
+
-- of the processor's figure; nothing in these tables is ever summed into one. These
|
|
8
|
+
-- tables record which account is whose, what a person asked to move, and what the
|
|
9
|
+
-- processor said, so "why did my balance change" always has an answer.
|
|
10
|
+
--
|
|
11
|
+
-- economy_member_accounts which processor account belongs to which user, in
|
|
12
|
+
-- THIS project. One account never belongs to two
|
|
13
|
+
-- people. The latest row for a user is their account.
|
|
14
|
+
-- economy_user_transfers one row per instruction a person gave: fund the
|
|
15
|
+
-- project, or pay another user. Written before the
|
|
16
|
+
-- processor is asked, naming both accounts used.
|
|
17
|
+
-- economy_user_transfer_attempts every try at carrying one instruction out: sent
|
|
18
|
+
-- (with the processor's transfer reference), failed
|
|
19
|
+
-- or held (with the reason). At most one sent.
|
|
20
|
+
--
|
|
21
|
+
-- ISOLATION. Every project runs its own database (ADR 0100), so another project's users,
|
|
22
|
+
-- accounts and transfers are not in these tables at all. A transfer may only name
|
|
23
|
+
-- accounts linked here, and a link is only written once this project's own processor
|
|
24
|
+
-- connection confirms the account is one it opened. So one project can neither see nor
|
|
25
|
+
-- spend another's balance (tests/user_balances_db.mjs proves it with two projects).
|
|
26
|
+
--
|
|
27
|
+
-- FEE-FREE is a column, not a promise: platform_fee_cents must be 0. Charging a fee on
|
|
28
|
+
-- these transfers is a migration and a decision, not a quiet code change.
|
|
29
|
+
--
|
|
30
|
+
-- All three tables are append-only: a wrong row is answered by a new one.
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS economy_member_accounts (
|
|
33
|
+
id BIGSERIAL PRIMARY KEY,
|
|
34
|
+
builder_id BIGINT NOT NULL REFERENCES builders(id),
|
|
35
|
+
processor_account TEXT NOT NULL,
|
|
36
|
+
linked_by BIGINT REFERENCES builders(id),
|
|
37
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
38
|
+
CONSTRAINT economy_member_accounts_ref_chk CHECK (processor_account ~ '^[A-Za-z0-9_.:-]{3,200}$')
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
-- one account, one person
|
|
42
|
+
CREATE UNIQUE INDEX IF NOT EXISTS economy_member_accounts_one_owner
|
|
43
|
+
ON economy_member_accounts (processor_account);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS economy_member_accounts_by_builder
|
|
45
|
+
ON economy_member_accounts (builder_id, id DESC);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS economy_user_transfers (
|
|
48
|
+
id BIGSERIAL PRIMARY KEY,
|
|
49
|
+
kind TEXT NOT NULL,
|
|
50
|
+
from_builder_id BIGINT NOT NULL REFERENCES builders(id),
|
|
51
|
+
to_builder_id BIGINT REFERENCES builders(id),
|
|
52
|
+
from_account TEXT NOT NULL,
|
|
53
|
+
to_account TEXT,
|
|
54
|
+
amount_cents BIGINT NOT NULL,
|
|
55
|
+
currency TEXT NOT NULL DEFAULT 'USD',
|
|
56
|
+
platform_fee_cents BIGINT NOT NULL DEFAULT 0,
|
|
57
|
+
instruction_id TEXT NOT NULL,
|
|
58
|
+
note TEXT NOT NULL DEFAULT '',
|
|
59
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
60
|
+
CONSTRAINT economy_user_transfers_kind_chk CHECK (kind IN ('fund_project', 'pay_user')),
|
|
61
|
+
CONSTRAINT economy_user_transfers_amount_chk CHECK (amount_cents > 0),
|
|
62
|
+
CONSTRAINT economy_user_transfers_currency_chk CHECK (currency = 'USD'),
|
|
63
|
+
CONSTRAINT economy_user_transfers_fee_free_chk CHECK (platform_fee_cents = 0),
|
|
64
|
+
CONSTRAINT economy_user_transfers_note_chk CHECK (length(note) <= 300),
|
|
65
|
+
CONSTRAINT economy_user_transfers_instruction_chk CHECK (instruction_id ~ '^[A-Za-z0-9_-]{8,80}$'),
|
|
66
|
+
-- funding goes to the project's own account; a payment names a different person
|
|
67
|
+
CONSTRAINT economy_user_transfers_target_chk CHECK (
|
|
68
|
+
(kind = 'fund_project' AND to_builder_id IS NULL AND to_account IS NULL) OR
|
|
69
|
+
(kind = 'pay_user' AND to_builder_id IS NOT NULL AND to_account IS NOT NULL
|
|
70
|
+
AND to_builder_id <> from_builder_id AND to_account <> from_account))
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
-- one instruction is carried out once, however often the button is pressed
|
|
74
|
+
CREATE UNIQUE INDEX IF NOT EXISTS economy_user_transfers_one_per_instruction
|
|
75
|
+
ON economy_user_transfers (from_builder_id, instruction_id);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS economy_user_transfers_by_from ON economy_user_transfers (from_builder_id, id DESC);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS economy_user_transfers_by_to ON economy_user_transfers (to_builder_id, id DESC) WHERE to_builder_id IS NOT NULL;
|
|
78
|
+
|
|
79
|
+
CREATE TABLE IF NOT EXISTS economy_user_transfer_attempts (
|
|
80
|
+
id BIGSERIAL PRIMARY KEY,
|
|
81
|
+
transfer_id BIGINT NOT NULL REFERENCES economy_user_transfers(id),
|
|
82
|
+
outcome TEXT NOT NULL,
|
|
83
|
+
processor_ref TEXT,
|
|
84
|
+
reason TEXT,
|
|
85
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
86
|
+
CONSTRAINT economy_user_transfer_attempts_outcome_chk CHECK (outcome IN ('sent', 'failed', 'held')),
|
|
87
|
+
CONSTRAINT economy_user_transfer_attempts_reason_len_chk CHECK (reason IS NULL OR length(reason) <= 300),
|
|
88
|
+
CONSTRAINT economy_user_transfer_attempts_cause_chk CHECK (
|
|
89
|
+
(outcome = 'sent' AND processor_ref IS NOT NULL) OR
|
|
90
|
+
(outcome IN ('failed', 'held') AND reason IS NOT NULL AND length(reason) > 0))
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
-- sent at most once
|
|
94
|
+
CREATE UNIQUE INDEX IF NOT EXISTS economy_user_transfer_attempts_one_sent
|
|
95
|
+
ON economy_user_transfer_attempts (transfer_id) WHERE outcome = 'sent';
|
|
96
|
+
CREATE INDEX IF NOT EXISTS economy_user_transfer_attempts_by_transfer
|
|
97
|
+
ON economy_user_transfer_attempts (transfer_id, id DESC);
|
|
98
|
+
|
|
99
|
+
CREATE OR REPLACE FUNCTION economy_user_balances_append_only() RETURNS trigger AS $$
|
|
100
|
+
BEGIN
|
|
101
|
+
RAISE EXCEPTION '% is append-only: record a new row instead', TG_TABLE_NAME;
|
|
102
|
+
END;
|
|
103
|
+
$$ LANGUAGE plpgsql;
|
|
104
|
+
|
|
105
|
+
DROP TRIGGER IF EXISTS economy_member_accounts_no_rewrite ON economy_member_accounts;
|
|
106
|
+
CREATE TRIGGER economy_member_accounts_no_rewrite BEFORE UPDATE OR DELETE ON economy_member_accounts
|
|
107
|
+
FOR EACH ROW EXECUTE FUNCTION economy_user_balances_append_only();
|
|
108
|
+
DROP TRIGGER IF EXISTS economy_user_transfers_no_rewrite ON economy_user_transfers;
|
|
109
|
+
CREATE TRIGGER economy_user_transfers_no_rewrite BEFORE UPDATE OR DELETE ON economy_user_transfers
|
|
110
|
+
FOR EACH ROW EXECUTE FUNCTION economy_user_balances_append_only();
|
|
111
|
+
DROP TRIGGER IF EXISTS economy_user_transfer_attempts_no_rewrite ON economy_user_transfer_attempts;
|
|
112
|
+
CREATE TRIGGER economy_user_transfer_attempts_no_rewrite BEFORE UPDATE OR DELETE ON economy_user_transfer_attempts
|
|
113
|
+
FOR EACH ROW EXECUTE FUNCTION economy_user_balances_append_only();
|
|
114
|
+
|
|
115
|
+
INSERT INTO schema_migrations (version) VALUES ('economy_003_user_balances')
|
|
116
|
+
ON CONFLICT DO NOTHING;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- economy_004_module_share_payouts.sql — a payout out of the project's account may be a
|
|
2
|
+
-- module-sale share instead of a task (task 1004910, goal 1000092 N6; ADR 0388 D5).
|
|
3
|
+
--
|
|
4
|
+
-- A module sale pays its seller and contributors by transfer from the project's account,
|
|
5
|
+
-- and none of those transfers is "for a task". The cause rule still holds: a payout names
|
|
6
|
+
-- the person paid, the processor transfer, and EITHER the task OR the module-sale share
|
|
7
|
+
-- it was for (core_292's module_sale_shares_owed). No foreign key: the share table is
|
|
8
|
+
-- core's, and a module migration does not reach into core's schema.
|
|
9
|
+
|
|
10
|
+
ALTER TABLE economy_project_account_movements ADD COLUMN IF NOT EXISTS module_share_id BIGINT;
|
|
11
|
+
|
|
12
|
+
ALTER TABLE economy_project_account_movements DROP CONSTRAINT IF EXISTS economy_project_account_cause_chk;
|
|
13
|
+
ALTER TABLE economy_project_account_movements ADD CONSTRAINT economy_project_account_cause_chk CHECK (
|
|
14
|
+
(kind = 'payout' AND builder_id IS NOT NULL AND processor_ref IS NOT NULL
|
|
15
|
+
AND (task_id IS NOT NULL OR module_share_id IS NOT NULL)) OR
|
|
16
|
+
(kind = 'payout_return' AND builder_id IS NOT NULL AND processor_ref IS NOT NULL) OR
|
|
17
|
+
(kind IN ('funding', 'fee') AND processor_ref IS NOT NULL) OR
|
|
18
|
+
(kind = 'adjustment' AND corrects_id IS NOT NULL AND actor_id IS NOT NULL AND length(note) > 0) OR
|
|
19
|
+
(kind IN ('hold', 'hold_release') AND task_id IS NOT NULL));
|
|
20
|
+
|
|
21
|
+
INSERT INTO schema_migrations (version) VALUES ('economy_004_module_share_payouts')
|
|
22
|
+
ON CONFLICT DO NOTHING;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- economy_005_project_account_holds_index.sql — the funding wall's hold read stays cheap
|
|
2
|
+
-- (task 1003185, goal 1000092 M06; ADR 0386).
|
|
3
|
+
--
|
|
4
|
+
-- Every claim of a cash-paying task under the default rule (hold at claim) nets each
|
|
5
|
+
-- task's holds against its releases (modules/economy/project-account.js, NET_HOLDS_SQL).
|
|
6
|
+
-- The record is append-only and keeps every row forever, so without this the read
|
|
7
|
+
-- scans every movement the project has ever booked. A partial index over just the hold
|
|
8
|
+
-- and release rows, carrying the two columns the read sums, lets it read those alone.
|
|
9
|
+
--
|
|
10
|
+
-- Numbered 005 to stay clear of the economy migrations other tasks of this goal are
|
|
11
|
+
-- adding at the same time.
|
|
12
|
+
--
|
|
13
|
+
-- Additive: one index on this module's own table, nothing else.
|
|
14
|
+
|
|
15
|
+
CREATE INDEX IF NOT EXISTS economy_project_account_holds
|
|
16
|
+
ON economy_project_account_movements (task_id) INCLUDE (kind, amount_micro)
|
|
17
|
+
WHERE kind IN ('hold', 'hold_release');
|
|
18
|
+
|
|
19
|
+
INSERT INTO schema_migrations (version) VALUES ('economy_005_project_account_holds_index')
|
|
20
|
+
ON CONFLICT DO NOTHING;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
-- economy_006_ledger_checks.sql — one row per daily check of our record against the
|
|
2
|
+
-- processor's (task 1004912, goal 1000092 N8; ADR 0397 decision 7).
|
|
3
|
+
--
|
|
4
|
+
-- The check itself is modules/economy/ledger-check.js. Each run writes one row saying what it
|
|
5
|
+
-- compared and what it found, so "did yesterday's check run, and did the figures agree" is a
|
|
6
|
+
-- read of this table, and an alarm is raised only when the set of differences changes.
|
|
7
|
+
--
|
|
8
|
+
-- outcome clean both sides were read and agree
|
|
9
|
+
-- drift both sides were read and differ; findings names each difference
|
|
10
|
+
-- unchecked no processor is connected, so there was nothing to compare with
|
|
11
|
+
-- error the processor could not be read; never recorded as clean
|
|
12
|
+
-- alarm filed a blocker was opened for the owner (blocker_id)
|
|
13
|
+
-- unchanged the same differences as the last alarm, so none was re-filed
|
|
14
|
+
-- undelivered there was something to raise and nowhere it could be filed
|
|
15
|
+
-- none nothing to raise
|
|
16
|
+
--
|
|
17
|
+
-- Holds no money and no balance of anyone's: the figures are copies of what was compared that
|
|
18
|
+
-- day. Append-only, like the account it checks.
|
|
19
|
+
--
|
|
20
|
+
-- Also one partial index on this module's economy_user_transfer_attempts: the check reads the
|
|
21
|
+
-- last week's `sent` member payments by time, and the table's only indexes are by transfer, so
|
|
22
|
+
-- without it every daily check scans every attempt ever made. (The same index on core's
|
|
23
|
+
-- cash_out_attempts is core_294, since a module migration may only touch its own tables.)
|
|
24
|
+
--
|
|
25
|
+
-- Additive: one new table and one index, both on this module's own side, nothing else.
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS economy_ledger_checks (
|
|
28
|
+
id BIGSERIAL PRIMARY KEY,
|
|
29
|
+
outcome TEXT NOT NULL,
|
|
30
|
+
reason TEXT,
|
|
31
|
+
recorded_balance_micro BIGINT,
|
|
32
|
+
processor_balance_micro BIGINT,
|
|
33
|
+
drift_micro BIGINT,
|
|
34
|
+
window_from TIMESTAMPTZ,
|
|
35
|
+
window_to TIMESTAMPTZ,
|
|
36
|
+
refs_compared INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
findings JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
38
|
+
fingerprint TEXT,
|
|
39
|
+
alarm TEXT NOT NULL DEFAULT 'none',
|
|
40
|
+
blocker_id BIGINT,
|
|
41
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
42
|
+
CONSTRAINT economy_ledger_checks_outcome_chk CHECK (outcome IN ('clean', 'drift', 'unchecked', 'error')),
|
|
43
|
+
CONSTRAINT economy_ledger_checks_alarm_chk CHECK (alarm IN ('filed', 'unchanged', 'undelivered', 'none')),
|
|
44
|
+
CONSTRAINT economy_ledger_checks_shape CHECK (
|
|
45
|
+
(outcome = 'clean' AND fingerprint IS NULL AND alarm = 'none') OR
|
|
46
|
+
(outcome = 'drift' AND fingerprint IS NOT NULL AND alarm <> 'none') OR
|
|
47
|
+
(outcome = 'unchecked' AND reason IS NOT NULL AND alarm = 'none') OR
|
|
48
|
+
(outcome = 'error' AND reason IS NOT NULL AND fingerprint IS NOT NULL AND alarm <> 'none')),
|
|
49
|
+
CONSTRAINT economy_ledger_checks_blocker CHECK ((alarm = 'filed') = (blocker_id IS NOT NULL))
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE INDEX IF NOT EXISTS economy_ledger_checks_by_time ON economy_ledger_checks (created_at DESC, id DESC);
|
|
53
|
+
|
|
54
|
+
-- Append-only: a check that was wrong is followed by a new check, never edited.
|
|
55
|
+
CREATE OR REPLACE FUNCTION economy_ledger_checks_append_only() RETURNS trigger AS $$
|
|
56
|
+
BEGIN
|
|
57
|
+
RAISE EXCEPTION 'economy_ledger_checks is append-only' USING ERRCODE = '42501';
|
|
58
|
+
END $$ LANGUAGE plpgsql;
|
|
59
|
+
|
|
60
|
+
DROP TRIGGER IF EXISTS economy_ledger_checks_no_change ON economy_ledger_checks;
|
|
61
|
+
CREATE TRIGGER economy_ledger_checks_no_change BEFORE UPDATE OR DELETE ON economy_ledger_checks
|
|
62
|
+
FOR EACH ROW EXECUTE FUNCTION economy_ledger_checks_append_only();
|
|
63
|
+
|
|
64
|
+
CREATE INDEX IF NOT EXISTS economy_user_transfer_attempts_sent_by_time
|
|
65
|
+
ON economy_user_transfer_attempts (created_at) WHERE outcome = 'sent';
|
|
66
|
+
|
|
67
|
+
INSERT INTO schema_migrations (version) VALUES ('economy_006_ledger_checks')
|
|
68
|
+
ON CONFLICT DO NOTHING;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"category": { "parent": "money" },
|
|
8
8
|
"default": true,
|
|
9
9
|
"maintenance": { "status": "core-maintained" },
|
|
10
|
-
"contributes": { "migrations": true, "routes": ["cost", "credits", "earnings", "leaderboard", "project-account", "reward"] },
|
|
10
|
+
"contributes": { "migrations": true, "routes": ["cost", "credits", "earnings", "leaderboard", "project-account", "balances", "reward"], "pollers": ["ledger-check"] },
|
|
11
11
|
"provides": ["reward", "economy.account"],
|
|
12
12
|
"consumes": []
|
|
13
13
|
}
|