@bongos/core 1.24.22 → 1.24.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +218 -63
- 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/modules/onboarding/founding-birth.js +5 -2
- package/modules/onboarding/founding-mode.js +10 -0
- package/modules/onboarding/genesis-home.js +3 -0
- package/modules/provisioning/birth.js +11 -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/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/branding.js +3 -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/founding_birth.mjs +52 -12
- package/tests/founding_mode.mjs +5 -5
- 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/genesis_home.mjs +1 -1
- 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/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_settings_env.mjs +7 -3
- package/tests/user_balances.mjs +241 -0
- package/tests/user_balances_db.mjs +137 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/module-payouts.js — A MODULE SALE IS SPLIT BY THE PROJECT'S RULE, AND EACH
|
|
4
|
+
// CONTRIBUTOR AND THE SELLER IS PAID THEIR SHARE, EXACTLY ONCE (task 1004910, goal 1000092
|
|
5
|
+
// N6; ADR 0388, ADR 0385 D4, ADR 0387 D5, ADR 0397 D7).
|
|
6
|
+
//
|
|
7
|
+
// THE MONEY PATH (ADR 0388 D5). The buyer pays the price plus the platform fee on top; the
|
|
8
|
+
// fee goes to the platform as the processor's application fee and is never in the split
|
|
9
|
+
// (D1). The price lands in the project's processor-held account, and from there each share
|
|
10
|
+
// is a processor transfer to one person, under that person's own logged approval, through
|
|
11
|
+
// the same steps as a task payout (builder-payouts.js, M04): the debt first, then the
|
|
12
|
+
// authority, then the send under a lock with an idempotency key derived from the debt.
|
|
13
|
+
//
|
|
14
|
+
// THREE STEPS, EACH ITS OWN CALL:
|
|
15
|
+
// 1. setSplitRule — the project's authorised person records the seller and the shares, in
|
|
16
|
+
// basis points of the price (they add to 10000), and which credit row kinds weigh the
|
|
17
|
+
// contributors' pool (default: the session cost-plus rows, ADR 0387 D5). Refused until
|
|
18
|
+
// the module_share pay-type gate is cleared: the standard module licence, the takedown
|
|
19
|
+
// contact and payee onboarding (ADR 0388 D7). The seller must pass the payout gates
|
|
20
|
+
// (age, sanctions) when the listing is set up, not first at payday.
|
|
21
|
+
// 2. recordSale — the processor's reported sale (read through the processor port, never a
|
|
22
|
+
// figure from a screen) is recorded with the rule it sold under, and every share is
|
|
23
|
+
// written as owed in the same transaction. One sale per processor reference, so a
|
|
24
|
+
// repeated report finds the sale already there.
|
|
25
|
+
// 3. payShare — one share, one approval. The approval must name exactly this person and
|
|
26
|
+
// amount; it is bound to the share once, and the database refuses an approval already
|
|
27
|
+
// spent on a task or another share (core_292). Then the gates are read again and the
|
|
28
|
+
// processor is asked to transfer.
|
|
29
|
+
//
|
|
30
|
+
// WEIGHT (ADR 0388 D3). A contributor's weight is the credit they earned on the module's
|
|
31
|
+
// own SHIPPED tasks (tasks.module_key), counting only the rule's row kinds. A session
|
|
32
|
+
// reward row carries no task, so it counts in the share of its session's shipped tasks that
|
|
33
|
+
// are on the module. Weight is kept in milli-credits so that apportioning stays whole. Every
|
|
34
|
+
// input is on the share row, so a person can check their amount.
|
|
35
|
+
//
|
|
36
|
+
// WHAT THIS IS NOT. It holds no money, names no processor and suggests no percentage. With
|
|
37
|
+
// no processor connected nothing is read or sent: a sale cannot be recorded, and a share is
|
|
38
|
+
// held `no_processor` and stays owed. Refunds (ADR 0388 D6) are not handled here yet.
|
|
39
|
+
|
|
40
|
+
const seams = require('../module-seams');
|
|
41
|
+
const payouts = require('./builder-payouts');
|
|
42
|
+
|
|
43
|
+
const { PayoutRefusal, inTx, withTimeout, clip, MICRO_PER_CENT, CURRENCY, TRANSFER_TIMEOUT_MS } = payouts;
|
|
44
|
+
const LOCK_SPACE = 1004910;
|
|
45
|
+
const BPS = 10_000;
|
|
46
|
+
const MILLI = 1000;
|
|
47
|
+
const DEFAULT_ROW_KINDS = Object.freeze(['session.token_reward']);
|
|
48
|
+
const ROW_KIND_RE = /^[a-z_.]{1,40}$/;
|
|
49
|
+
const MODULE_KEY_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
50
|
+
|
|
51
|
+
const isId = (v) => (typeof v === 'number' && Number.isSafeInteger(v) && v > 0) || (typeof v === 'string' && /^[1-9][0-9]{0,18}$/.test(v));
|
|
52
|
+
const isBps = (v) => Number.isSafeInteger(v) && v >= 0 && v <= BPS;
|
|
53
|
+
|
|
54
|
+
// Plain words for the reasons a share waits, beside M04's own.
|
|
55
|
+
const HELD_WORDS = Object.freeze({
|
|
56
|
+
...payouts.HELD_WORDS,
|
|
57
|
+
module_share_gate: 'Module sales are not switched on yet: the standard module licence, the takedown contact or payee onboarding is not recorded. The share stays owed.',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const shareKey = (shareId) => `bongos-module-share-${shareId}`;
|
|
61
|
+
|
|
62
|
+
// ── pure half ────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
// A split rule's body, strictly. Returns { rule } or { errors }.
|
|
65
|
+
function validateSplitRule(raw) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { errors: ['the rule must be an object'] };
|
|
68
|
+
for (const k of Object.keys(raw)) {
|
|
69
|
+
if (!['seller_id', 'seller_bps', 'contributors_bps', 'credit_row_kinds'].includes(k)) errors.push(`unknown field: ${k}`);
|
|
70
|
+
}
|
|
71
|
+
if (!isId(raw.seller_id)) errors.push('seller_id must be a builder id');
|
|
72
|
+
if (!isBps(raw.seller_bps)) errors.push('seller_bps must be a whole number from 0 to 10000');
|
|
73
|
+
if (!isBps(raw.contributors_bps)) errors.push('contributors_bps must be a whole number from 0 to 10000');
|
|
74
|
+
if (isBps(raw.seller_bps) && isBps(raw.contributors_bps) && raw.seller_bps + raw.contributors_bps !== BPS) {
|
|
75
|
+
errors.push('seller_bps and contributors_bps must add up to 10000 (the whole price; the platform fee is paid on top)');
|
|
76
|
+
}
|
|
77
|
+
let kinds = DEFAULT_ROW_KINDS.slice();
|
|
78
|
+
if (raw.credit_row_kinds !== undefined) {
|
|
79
|
+
const k = raw.credit_row_kinds;
|
|
80
|
+
if (!Array.isArray(k) || k.length < 1 || k.length > 10 || k.some((x) => typeof x !== 'string' || !ROW_KIND_RE.test(x))) {
|
|
81
|
+
errors.push('credit_row_kinds must name 1 to 10 credit row kinds');
|
|
82
|
+
} else {
|
|
83
|
+
kinds = [...new Set(k)].sort();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (errors.length) return { errors };
|
|
87
|
+
return { rule: { seller_id: String(raw.seller_id), seller_bps: raw.seller_bps, contributors_bps: raw.contributors_bps, credit_row_kinds: kinds } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// splitSale({ priceCents, rule, weights }) → { shares, pool_unassigned_cents }. The seller
|
|
91
|
+
// gets their basis points of the price rounded down; the pool is the rest, so nothing is
|
|
92
|
+
// lost to rounding. The pool is divided by weight with largest remainders (ties to the
|
|
93
|
+
// lower builder id). A pool with no weighted contributor is left unassigned in the
|
|
94
|
+
// project's account, never handed to anyone by default. Zero-cent shares are dropped.
|
|
95
|
+
function splitSale({ priceCents, rule, weights }) {
|
|
96
|
+
const sellerCents = Math.floor((priceCents * rule.seller_bps) / BPS);
|
|
97
|
+
const poolCents = priceCents - sellerCents;
|
|
98
|
+
const shares = [];
|
|
99
|
+
if (sellerCents > 0) {
|
|
100
|
+
shares.push({ recipient_id: String(rule.seller_id), role: 'seller', amount_cents: sellerCents, weight: null, weight_total: null });
|
|
101
|
+
}
|
|
102
|
+
const ws = (weights || [])
|
|
103
|
+
.map((w) => ({ id: String(w.builder_id), weight: Number(w.weight) }))
|
|
104
|
+
.filter((w) => Number.isSafeInteger(w.weight) && w.weight > 0)
|
|
105
|
+
.sort((a, b) => (BigInt(a.id) < BigInt(b.id) ? -1 : 1));
|
|
106
|
+
const total = ws.reduce((s, w) => s + w.weight, 0);
|
|
107
|
+
if (poolCents === 0 || total === 0) return { shares, pool_unassigned_cents: poolCents };
|
|
108
|
+
|
|
109
|
+
const parts = ws.map((w) => {
|
|
110
|
+
const exact = (poolCents * w.weight) / total;
|
|
111
|
+
return { ...w, cents: Math.floor(exact), rem: exact - Math.floor(exact) };
|
|
112
|
+
});
|
|
113
|
+
let left = poolCents - parts.reduce((s, p) => s + p.cents, 0);
|
|
114
|
+
for (const p of [...parts].sort((a, b) => b.rem - a.rem)) {
|
|
115
|
+
if (left <= 0) break;
|
|
116
|
+
p.cents += 1;
|
|
117
|
+
left -= 1;
|
|
118
|
+
}
|
|
119
|
+
for (const p of parts) {
|
|
120
|
+
if (p.cents > 0) shares.push({ recipient_id: p.id, role: 'contributor', amount_cents: p.cents, weight: p.weight, weight_total: total });
|
|
121
|
+
}
|
|
122
|
+
return { shares, pool_unassigned_cents: 0 };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── db half (pool and ports injectable; tests hand in stand-ins) ─────────────
|
|
126
|
+
|
|
127
|
+
function defaultPool() { return require('./pool').pool; }
|
|
128
|
+
function defaultAuthority() { return require('./payout-approvals'); }
|
|
129
|
+
function defaultGates() { return require('./payout-gates'); }
|
|
130
|
+
function defaultAccount() { return seams.resolveOptional('economy.account'); }
|
|
131
|
+
function defaultPayTypeGates() { return seams.resolveOptional('lifecycle.payTypeGates'); }
|
|
132
|
+
|
|
133
|
+
// null when module_share is cleared, else the reason it is not. With the lifecycle module
|
|
134
|
+
// off there is no gate to read, and that is a no.
|
|
135
|
+
async function moduleShareGate(payTypeGates, db) {
|
|
136
|
+
if (!payTypeGates || typeof payTypeGates.refuseUnclearedGates !== 'function') {
|
|
137
|
+
return { code: 'pay_type_gate_not_cleared', missing: ['unknown'], message: 'The pay-type gates cannot be read, so module sales stay off.' };
|
|
138
|
+
}
|
|
139
|
+
return payTypeGates.refuseUnclearedGates([{ pay_type: 'module_share' }], { db });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Weight per builder for one module, in milli-credits, from the rule's row kinds. Every
|
|
143
|
+
// step is an index lookup (core_292 adds the two that were missing): the module's shipped
|
|
144
|
+
// tasks by idx_tasks_shipped_module, their task rows by idx_credit_log_task, the sessions
|
|
145
|
+
// that shipped any of them by the GIN index on session_records.task_ids, and those
|
|
146
|
+
// sessions' rows by idx_credit_log_session. Nothing scans the whole ledger.
|
|
147
|
+
const WEIGHTS_SQL = `
|
|
148
|
+
WITH mt AS (SELECT id FROM tasks WHERE module_key = $1 AND status = 'shipped'),
|
|
149
|
+
direct AS (
|
|
150
|
+
SELECT builder_id, SUM(delta)::numeric * $3 AS w
|
|
151
|
+
FROM credit_log WHERE task_id IN (SELECT id FROM mt) AND reason = ANY($2::text[])
|
|
152
|
+
GROUP BY builder_id),
|
|
153
|
+
ss AS (
|
|
154
|
+
SELECT sr.session_id,
|
|
155
|
+
(SELECT count(*) FROM unnest(sr.task_ids) t WHERE t IN (SELECT id FROM mt))::numeric
|
|
156
|
+
/ cardinality(sr.task_ids) AS part
|
|
157
|
+
FROM session_records sr
|
|
158
|
+
WHERE sr.task_ids && (SELECT array_agg(id) FROM mt)),
|
|
159
|
+
sess AS (
|
|
160
|
+
SELECT cl.builder_id, SUM(cl.delta::numeric * $3 * ss.part) AS w
|
|
161
|
+
FROM ss JOIN credit_log cl ON cl.session_id = ss.session_id
|
|
162
|
+
WHERE cl.task_id IS NULL AND cl.reason = ANY($2::text[])
|
|
163
|
+
GROUP BY cl.builder_id)
|
|
164
|
+
SELECT builder_id, floor(SUM(w))::bigint AS weight
|
|
165
|
+
FROM (SELECT * FROM direct UNION ALL SELECT * FROM sess) x
|
|
166
|
+
GROUP BY builder_id
|
|
167
|
+
HAVING floor(SUM(w)) > 0`;
|
|
168
|
+
|
|
169
|
+
async function readWeights(db, moduleKey, kinds) {
|
|
170
|
+
const { rows } = await db.query(WEIGHTS_SQL, [moduleKey, kinds, MILLI]);
|
|
171
|
+
return rows.map((r) => ({ builder_id: String(r.builder_id), weight: Number(r.weight) }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const RULE_COLS = 'id, module_key, seller_id, seller_bps, contributors_bps, credit_row_kinds, set_by, created_at';
|
|
175
|
+
|
|
176
|
+
async function readSplitRule(moduleKey, { pool = defaultPool() } = {}) {
|
|
177
|
+
const { rows } = await pool.query(
|
|
178
|
+
`SELECT ${RULE_COLS} FROM module_split_rules WHERE module_key = $1 ORDER BY created_at DESC, id DESC LIMIT 1`, [moduleKey]);
|
|
179
|
+
const r = rows[0];
|
|
180
|
+
return r ? { ...r, id: String(r.id), seller_id: String(r.seller_id), set_by: String(r.set_by) } : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The rule and the contributors' shares it would give now, for the people they affect to
|
|
184
|
+
// see before any money moves (ADR 0388 D3, Art. VIII).
|
|
185
|
+
async function previewSplit(moduleKey, { pool = defaultPool() } = {}) {
|
|
186
|
+
if (typeof moduleKey !== 'string' || !MODULE_KEY_RE.test(moduleKey)) throw new PayoutRefusal('bad_module_key', 'That is not a module key.');
|
|
187
|
+
const rule = await readSplitRule(moduleKey, { pool });
|
|
188
|
+
const kinds = rule ? rule.credit_row_kinds : DEFAULT_ROW_KINDS.slice();
|
|
189
|
+
const weights = await readWeights(pool, moduleKey, kinds);
|
|
190
|
+
const total = weights.reduce((s, w) => s + w.weight, 0);
|
|
191
|
+
return {
|
|
192
|
+
module_key: moduleKey,
|
|
193
|
+
rule,
|
|
194
|
+
credit_row_kinds: kinds,
|
|
195
|
+
weight_unit: 'milli-credits',
|
|
196
|
+
contributors: weights.map((w) => ({ ...w, pool_share: total ? w.weight / total : 0 })),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function setSplitRule({ moduleKey, body, actorId }, {
|
|
201
|
+
pool = defaultPool(), gates = defaultGates(), payTypeGates = defaultPayTypeGates(),
|
|
202
|
+
} = {}) {
|
|
203
|
+
if (typeof moduleKey !== 'string' || !MODULE_KEY_RE.test(moduleKey)) throw new PayoutRefusal('bad_module_key', 'That is not a module key.');
|
|
204
|
+
if (!isId(actorId)) throw new PayoutRefusal('no_actor', 'A split rule is recorded by a signed-in person.', 403);
|
|
205
|
+
const v = validateSplitRule(body);
|
|
206
|
+
if (v.errors) throw new PayoutRefusal('bad_split_rule', v.errors.join('; '));
|
|
207
|
+
const mod = (await pool.query('SELECT module_key, status FROM store_modules WHERE module_key = $1', [moduleKey])).rows[0];
|
|
208
|
+
if (!mod) throw new PayoutRefusal('no_such_module', 'The store has no module with that key.', 404);
|
|
209
|
+
if (mod.status !== 'listed') throw new PayoutRefusal('module_delisted', 'This module is delisted, so it takes no new split rule.', 409);
|
|
210
|
+
const gate = await moduleShareGate(payTypeGates, pool);
|
|
211
|
+
if (gate) throw new PayoutRefusal('module_share_gate', gate.message, 409);
|
|
212
|
+
const seller = await gates.gateRecipient(v.rule.seller_id, { pool });
|
|
213
|
+
if (!seller || !seller.ok) {
|
|
214
|
+
throw new PayoutRefusal('seller_gated', 'The seller cannot be paid right now (age or sanctions check), so the listing cannot take this rule.', 409);
|
|
215
|
+
}
|
|
216
|
+
const { rows } = await pool.query(
|
|
217
|
+
`INSERT INTO module_split_rules (module_key, seller_id, seller_bps, contributors_bps, credit_row_kinds, set_by)
|
|
218
|
+
VALUES ($1,$2,$3,$4,$5,$6) RETURNING ${RULE_COLS}`,
|
|
219
|
+
[moduleKey, v.rule.seller_id, v.rule.seller_bps, v.rule.contributors_bps, v.rule.credit_row_kinds, actorId]);
|
|
220
|
+
return { ...rows[0], id: String(rows[0].id), seller_id: String(rows[0].seller_id), set_by: String(rows[0].set_by) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The sale as the processor reports it. Only the fields used are taken, and each is checked.
|
|
224
|
+
function checkReportedSale(out, ref) {
|
|
225
|
+
const s = out || {};
|
|
226
|
+
const price = Number(s.priceCents);
|
|
227
|
+
const fee = s.platformFeeCents === undefined ? 0 : Number(s.platformFeeCents);
|
|
228
|
+
if (typeof s.moduleKey !== 'string' || !MODULE_KEY_RE.test(s.moduleKey)) throw new PayoutRefusal('bad_reported_sale', `The processor's sale ${ref} names no module.`, 502);
|
|
229
|
+
if (!Number.isSafeInteger(price) || price <= 0) throw new PayoutRefusal('bad_reported_sale', `The processor's sale ${ref} has no price.`, 502);
|
|
230
|
+
if (!Number.isSafeInteger(fee) || fee < 0) throw new PayoutRefusal('bad_reported_sale', `The processor's sale ${ref} has a bad fee.`, 502);
|
|
231
|
+
if (s.currency !== CURRENCY) throw new PayoutRefusal('currency_not_supported', 'The project account is kept in US dollars; this sale is in another currency.', 409);
|
|
232
|
+
return { moduleKey: s.moduleKey, priceCents: price, platformFeeCents: fee, currency: s.currency };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function readSale(db, ref) {
|
|
236
|
+
const { rows } = await db.query('SELECT * FROM module_sales WHERE processor_sale_ref = $1', [ref]);
|
|
237
|
+
if (!rows[0]) return null;
|
|
238
|
+
const shares = await db.query('SELECT * FROM module_sale_shares_owed WHERE sale_id = $1 ORDER BY id', [rows[0].id]);
|
|
239
|
+
return { sale: rows[0], shares: shares.rows };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// recordSale({ processorSaleRef, actorId }) → { sale, shares, already }.
|
|
243
|
+
async function recordSale({ processorSaleRef, actorId }, {
|
|
244
|
+
pool = defaultPool(), processor = null, account = defaultAccount(), payTypeGates = defaultPayTypeGates(),
|
|
245
|
+
timeoutMs = TRANSFER_TIMEOUT_MS,
|
|
246
|
+
} = {}) {
|
|
247
|
+
const ref = typeof processorSaleRef === 'string' ? processorSaleRef.trim() : '';
|
|
248
|
+
if (!ref || ref.length > 200) throw new PayoutRefusal('bad_sale_ref', 'processor_sale_ref is the processor\'s id for the sale.');
|
|
249
|
+
if (!isId(actorId)) throw new PayoutRefusal('no_actor', 'A sale is recorded by a signed-in person.', 403);
|
|
250
|
+
const had = await readSale(pool, ref);
|
|
251
|
+
if (had) return { ...had, already: true };
|
|
252
|
+
|
|
253
|
+
const gate = await moduleShareGate(payTypeGates, pool);
|
|
254
|
+
if (gate) throw new PayoutRefusal('module_share_gate', gate.message, 409);
|
|
255
|
+
if (!processor || typeof processor.readSale !== 'function') {
|
|
256
|
+
throw new PayoutRefusal('no_processor', 'No payment processor is connected, so there is no sale to read.', 503);
|
|
257
|
+
}
|
|
258
|
+
if (!account || typeof account.recordMovement !== 'function') {
|
|
259
|
+
throw new PayoutRefusal('no_account_record', 'The project account record is switched off, so the sale cannot be booked.', 503);
|
|
260
|
+
}
|
|
261
|
+
const sale = checkReportedSale(await withTimeout(Promise.resolve(processor.readSale({ ref })), timeoutMs), ref);
|
|
262
|
+
const rule = await readSplitRule(sale.moduleKey, { pool });
|
|
263
|
+
if (!rule) throw new PayoutRefusal('no_split_rule', 'This module has no split rule, so its sale cannot be divided. The project sets one first.', 409);
|
|
264
|
+
|
|
265
|
+
return inTx(pool, async (db) => {
|
|
266
|
+
await db.query('SELECT pg_advisory_xact_lock($1::int, hashtext($2::text))', [LOCK_SPACE, ref]);
|
|
267
|
+
const again = await readSale(db, ref);
|
|
268
|
+
if (again) return { ...again, already: true };
|
|
269
|
+
const weights = await readWeights(db, sale.moduleKey, rule.credit_row_kinds);
|
|
270
|
+
const split = splitSale({ priceCents: sale.priceCents, rule, weights });
|
|
271
|
+
const ins = await db.query(
|
|
272
|
+
`INSERT INTO module_sales (module_key, processor_sale_ref, price_cents, platform_fee_cents, currency, rule_id, pool_unassigned_cents, recorded_by)
|
|
273
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
|
274
|
+
[sale.moduleKey, ref, sale.priceCents, sale.platformFeeCents, sale.currency, rule.id, split.pool_unassigned_cents, actorId]);
|
|
275
|
+
const saleRow = ins.rows[0];
|
|
276
|
+
const shares = [];
|
|
277
|
+
for (const s of split.shares) {
|
|
278
|
+
const r = await db.query(
|
|
279
|
+
`INSERT INTO module_sale_shares_owed (sale_id, recipient_id, role, amount_cents, currency, weight, weight_total)
|
|
280
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
|
281
|
+
[saleRow.id, s.recipient_id, s.role, s.amount_cents, sale.currency, s.weight, s.weight_total]);
|
|
282
|
+
shares.push(r.rows[0]);
|
|
283
|
+
}
|
|
284
|
+
// The price came into the project's account; the platform fee did not (it went to the
|
|
285
|
+
// platform as the application fee), so only the price is booked.
|
|
286
|
+
await account.recordMovement(db, { kind: 'funding', amountMicro: sale.priceCents * MICRO_PER_CENT, processorRef: ref });
|
|
287
|
+
return { sale: saleRow, shares, already: false };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function readShare(db, shareId) {
|
|
292
|
+
const { rows } = await db.query('SELECT * FROM module_sale_shares_owed WHERE id = $1', [shareId]);
|
|
293
|
+
const r = rows[0];
|
|
294
|
+
return r ? { ...r, id: String(r.id), recipient_id: String(r.recipient_id), amount_cents: Number(r.amount_cents) } : null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function insertAttempt(db, shareId, { outcome, processorRef = null, reason = null, actorId = null }) {
|
|
298
|
+
const { rows } = await db.query(
|
|
299
|
+
`INSERT INTO module_share_attempts (share_id, outcome, processor_ref, reason, actor_id)
|
|
300
|
+
VALUES ($1,$2,$3,$4,$5) RETURNING id, share_id, outcome, processor_ref, reason, created_at`,
|
|
301
|
+
[shareId, outcome, processorRef, reason === null ? null : clip(reason), actorId]);
|
|
302
|
+
return rows[0];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function sentAttempt(db, shareId) {
|
|
306
|
+
const { rows } = await db.query(
|
|
307
|
+
"SELECT id, processor_ref FROM module_share_attempts WHERE share_id = $1 AND outcome = 'sent'", [shareId]);
|
|
308
|
+
return rows[0] || null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// The approval is checked against the share BEFORE it is bound, so a wrong approval can
|
|
312
|
+
// never lock a share; then it is bound once. A share already bound keeps its approval.
|
|
313
|
+
async function bindApproval(db, share, approvalId, actorId) {
|
|
314
|
+
const a = (await db.query('SELECT id, kind, recipient_id, amount_cents, currency FROM payout_approvals WHERE id = $1', [approvalId])).rows[0];
|
|
315
|
+
if (!a || a.kind !== 'payout_approved') throw new PayoutRefusal('no_such_approval', 'There is no payout approval with that id.', 404);
|
|
316
|
+
if (String(a.recipient_id) !== share.recipient_id) throw new PayoutRefusal('different_recipient', 'The approval is for a different person.', 409);
|
|
317
|
+
if (Number(a.amount_cents) !== share.amount_cents || a.currency !== share.currency) {
|
|
318
|
+
throw new PayoutRefusal('different_amount', 'The approval is for a different amount or currency than this share.', 409);
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
await db.query(
|
|
322
|
+
'INSERT INTO module_share_approvals (share_id, approval_id, bound_by) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING',
|
|
323
|
+
[share.id, a.id, actorId]);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (err && err.code === 'P0001') throw new PayoutRefusal('approval_already_used', 'This approval already pays a task. One approval pays one thing.', 409);
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
// ON CONFLICT DO NOTHING absorbs both unique rules (one approval per share, one share per
|
|
329
|
+
// approval), including the same approval pressed for two shares at once; what is bound
|
|
330
|
+
// now says which happened.
|
|
331
|
+
const bound = (await db.query('SELECT approval_id FROM module_share_approvals WHERE share_id = $1', [share.id])).rows[0];
|
|
332
|
+
if (!bound) throw new PayoutRefusal('approval_already_used', 'This approval already pays another share. One approval pays one thing.', 409);
|
|
333
|
+
if (String(bound.approval_id) !== String(a.id)) {
|
|
334
|
+
throw new PayoutRefusal('share_already_approved', `This share is paid under approval ${bound.approval_id}.`, 409);
|
|
335
|
+
}
|
|
336
|
+
return String(a.id);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// payShare({ shareId, approvalId, actorId }) → { share, status: 'paid'|'held'|'failed', ... }.
|
|
340
|
+
// The shape and the guarantees are payForTask's (builder-payouts.js).
|
|
341
|
+
async function payShare({ shareId, approvalId, actorId }, {
|
|
342
|
+
pool = defaultPool(), authority = defaultAuthority(), account = defaultAccount(),
|
|
343
|
+
payTypeGates = defaultPayTypeGates(), processor = null, timeoutMs = TRANSFER_TIMEOUT_MS,
|
|
344
|
+
} = {}) {
|
|
345
|
+
if (!isId(shareId)) throw new PayoutRefusal('bad_share_id', 'share id must be a number.');
|
|
346
|
+
if (!isId(approvalId)) throw new PayoutRefusal('bad_approval_id', 'approval_id must be a number.');
|
|
347
|
+
if (!isId(actorId)) throw new PayoutRefusal('no_actor', 'A payout is pressed by a signed-in person.', 403);
|
|
348
|
+
const share = await readShare(pool, shareId);
|
|
349
|
+
if (!share) throw new PayoutRefusal('no_such_share', 'There is no module-sale share with that id.', 404);
|
|
350
|
+
const paid = await sentAttempt(pool, share.id);
|
|
351
|
+
if (paid) return { share, status: 'paid', already: true, processor_ref: paid.processor_ref };
|
|
352
|
+
const boundId = await bindApproval(pool, share, approvalId, actorId);
|
|
353
|
+
|
|
354
|
+
const held = async (reason) => {
|
|
355
|
+
await insertAttempt(pool, share.id, { outcome: 'held', reason, actorId });
|
|
356
|
+
return { share, status: 'held', reason, words: HELD_WORDS[reason] || null };
|
|
357
|
+
};
|
|
358
|
+
if (share.currency !== CURRENCY) return held('currency_not_supported');
|
|
359
|
+
if (await moduleShareGate(payTypeGates, pool)) return held('module_share_gate');
|
|
360
|
+
const auth = await authority.checkApproval({
|
|
361
|
+
id: boundId, kind: 'payout_approved', recipientId: share.recipient_id,
|
|
362
|
+
amountCents: share.amount_cents, currency: share.currency,
|
|
363
|
+
}, { pool });
|
|
364
|
+
if (!auth || !auth.ok) return held((auth && auth.reason) || 'no_such_approval');
|
|
365
|
+
if (!account || typeof account.recordMovement !== 'function') return held('no_account_record');
|
|
366
|
+
if (!processor || typeof processor.transfer !== 'function') return held('no_processor');
|
|
367
|
+
|
|
368
|
+
return inTx(pool, async (db) => {
|
|
369
|
+
await db.query('SELECT pg_advisory_xact_lock($1::int, hashtext($2::text))', [LOCK_SPACE, share.id]);
|
|
370
|
+
const already = await sentAttempt(db, share.id);
|
|
371
|
+
if (already) return { share, status: 'paid', already: true, processor_ref: already.processor_ref };
|
|
372
|
+
let ref;
|
|
373
|
+
try {
|
|
374
|
+
const out = await withTimeout(Promise.resolve(processor.transfer({
|
|
375
|
+
idempotencyKey: shareKey(share.id),
|
|
376
|
+
amountCents: share.amount_cents,
|
|
377
|
+
currency: share.currency,
|
|
378
|
+
builderId: share.recipient_id,
|
|
379
|
+
moduleShareId: share.id,
|
|
380
|
+
payTo: auth.pay_to,
|
|
381
|
+
})), timeoutMs);
|
|
382
|
+
ref = out && typeof out.ref === 'string' && out.ref.trim() ? out.ref.trim() : null;
|
|
383
|
+
if (!ref) throw Object.assign(new Error('the processor gave no transfer reference'), { code: 'no_reference' });
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const reason = clip(`${err && err.code ? err.code + ': ' : ''}${err && err.message ? err.message : 'transfer failed'}`);
|
|
386
|
+
await insertAttempt(db, share.id, { outcome: 'failed', reason, actorId });
|
|
387
|
+
return { share, status: 'failed', reason };
|
|
388
|
+
}
|
|
389
|
+
await insertAttempt(db, share.id, { outcome: 'sent', processorRef: ref, actorId });
|
|
390
|
+
await account.recordMovement(db, {
|
|
391
|
+
kind: 'payout', amountMicro: share.amount_cents * MICRO_PER_CENT,
|
|
392
|
+
builderId: share.recipient_id, moduleShareId: share.id, processorRef: ref,
|
|
393
|
+
});
|
|
394
|
+
return { share, status: 'paid', already: false, processor_ref: ref };
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// listShares(db, { recipientId, limit, offset }) — shares with where each stands, newest
|
|
399
|
+
// first; recipientId narrows it to one person's own.
|
|
400
|
+
async function listShares(db = defaultPool(), { recipientId = null, limit = 50, offset = 0 } = {}) {
|
|
401
|
+
const lim = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
|
|
402
|
+
const off = Math.max(parseInt(offset, 10) || 0, 0);
|
|
403
|
+
const { rows } = await db.query(
|
|
404
|
+
`SELECT o.id, o.sale_id, s.module_key, s.processor_sale_ref, s.price_cents, o.recipient_id,
|
|
405
|
+
b.display_name AS recipient_name, o.role, o.amount_cents, o.currency, o.weight, o.weight_total,
|
|
406
|
+
o.created_at, ap.approval_id, st.processor_ref AS sent_ref, st.created_at AS paid_at,
|
|
407
|
+
l.outcome AS last_outcome, l.reason AS last_reason, l.created_at AS last_try_at
|
|
408
|
+
FROM module_sale_shares_owed o
|
|
409
|
+
JOIN module_sales s ON s.id = o.sale_id
|
|
410
|
+
LEFT JOIN builders b ON b.id = o.recipient_id
|
|
411
|
+
LEFT JOIN module_share_approvals ap ON ap.share_id = o.id
|
|
412
|
+
LEFT JOIN module_share_attempts st ON st.share_id = o.id AND st.outcome = 'sent'
|
|
413
|
+
LEFT JOIN LATERAL (
|
|
414
|
+
SELECT outcome, reason, created_at FROM module_share_attempts a
|
|
415
|
+
WHERE a.share_id = o.id ORDER BY a.id DESC LIMIT 1) l ON true
|
|
416
|
+
WHERE ($1::bigint IS NULL OR o.recipient_id = $1)
|
|
417
|
+
ORDER BY o.created_at DESC, o.id DESC
|
|
418
|
+
LIMIT $2 OFFSET $3`, [recipientId, lim, off]);
|
|
419
|
+
return rows.map((r) => ({
|
|
420
|
+
id: String(r.id), sale_id: String(r.sale_id), module_key: r.module_key, processor_sale_ref: r.processor_sale_ref,
|
|
421
|
+
price_cents: Number(r.price_cents), recipient_id: String(r.recipient_id), recipient_name: r.recipient_name,
|
|
422
|
+
role: r.role, amount_cents: Number(r.amount_cents), currency: r.currency,
|
|
423
|
+
weight: r.weight === null ? null : Number(r.weight), weight_total: r.weight_total === null ? null : Number(r.weight_total),
|
|
424
|
+
created_at: r.created_at, approval_id: r.approval_id ? String(r.approval_id) : null,
|
|
425
|
+
status: r.sent_ref ? 'paid' : 'owed', processor_ref: r.sent_ref || null, paid_at: r.paid_at || null,
|
|
426
|
+
last_try: r.last_outcome ? { outcome: r.last_outcome, reason: r.last_reason, at: r.last_try_at, words: HELD_WORDS[r.last_reason] || null } : null,
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// One object, as builder-payouts.js does: the router reaches it through an injectable default.
|
|
431
|
+
const api = { PayoutRefusal, validateSplitRule, splitSale, previewSplit, setSplitRule, recordSale, payShare, listShares };
|
|
432
|
+
module.exports = api;
|
|
@@ -302,13 +302,16 @@ async function checkApproval({ id, kind = 'payout_approved', recipientId, amount
|
|
|
302
302
|
const row = shapeRow(rows[0]);
|
|
303
303
|
if (!row || row.kind !== kind) return { ok: false, reason: 'no_such_approval' };
|
|
304
304
|
if (String(row.recipient_id) !== String(recipientId)) return { ok: false, reason: 'different_recipient' };
|
|
305
|
-
|
|
305
|
+
// Money kinds: a payout the project approved, or a person's own cash-out (N7,
|
|
306
|
+
// task 1004911: approver and recipient are the same person, by core_293's CHECK).
|
|
307
|
+
const money = kind === 'payout_approved' || kind === 'cashout_approved';
|
|
308
|
+
if (money && (row.amount_cents !== Number(amountCents) || row.currency !== currency)) {
|
|
306
309
|
return { ok: false, reason: 'different_amount' };
|
|
307
310
|
}
|
|
308
311
|
// N10: the recipient must still pass the gates when it pays. Money owed and
|
|
309
312
|
// refused files a hold that tells them; a grant or contract refuses without one.
|
|
310
313
|
const gates = gatesOf(opts);
|
|
311
|
-
const g =
|
|
314
|
+
const g = money
|
|
312
315
|
? await gates.gateOwedPay(row.recipient_id, { owedCents: row.amount_cents, now, pool })
|
|
313
316
|
: await gates.gateRecipient(row.recipient_id, { now, pool });
|
|
314
317
|
if (!g.ok) return { ok: false, reason: 'recipient_gated', gate: { reason: g.reason, freeze_id: g.freeze_id || null } };
|
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
// person with payout.approve recorded counts. PROCESSOR_SCREENING_CONFIRMED is the
|
|
25
25
|
// one switch N2 flips, with a reference to that written confirmation.
|
|
26
26
|
//
|
|
27
|
+
// CONTRACTOR PAPERS (task 1004908, N4). Our own contractors are paid only once an
|
|
28
|
+
// NDA is on file for them (the beta overlay, Art. XI) and the processor's
|
|
29
|
+
// onboarding has collected their identity and tax details. Bongos records THAT
|
|
30
|
+
// the papers exist, never their contents: no NDA text, no tax id. Nothing here
|
|
31
|
+
// sets a rate or a schedule (Art. IV); a missing paper only holds the money, with
|
|
32
|
+
// a notice. Internal only in this version, so every payout this instance makes
|
|
33
|
+
// is contractor pay and both papers apply to all of them.
|
|
34
|
+
//
|
|
27
35
|
// Pure first (decide), then the db half (pool injectable).
|
|
28
36
|
|
|
29
37
|
const PROCESSOR_SCREENING_CONFIRMED = null; // e.g. { processor: 'x', scope: 'OFAC+EU+UN', confirmed_in: 'ADR NNNN' }
|
|
@@ -31,7 +39,9 @@ const PROCESSOR_SCREENING_CONFIRMED = null; // e.g. { processor: 'x', scope: 'OF
|
|
|
31
39
|
const AGE_TIERS = Object.freeze(['under_13', '13_15', '16_17', 'adult']);
|
|
32
40
|
const AGE_BASES = Object.freeze(['self_declared', 'verified_by_processor', 'verified_by_person']);
|
|
33
41
|
const SCREEN_RESULTS = Object.freeze(['clear', 'match', 'error']);
|
|
34
|
-
const
|
|
42
|
+
const NDA_STATUSES = Object.freeze(['on_file', 'withdrawn']);
|
|
43
|
+
const TAX_STATUSES = Object.freeze(['collected', 'incomplete']);
|
|
44
|
+
const FREEZE_REASONS = Object.freeze(['sanctions_match', 'sanctions_unscreened', 'age_no_money', 'age_needs_guardian', 'age_unknown', 'nda_missing', 'tax_details_missing', 'manual']);
|
|
35
45
|
const APPEAL_ROUTE = 'POST /api/bongos/me/payout-freezes/:id/appeal';
|
|
36
46
|
const MAX_SCREEN_DAYS = 365;
|
|
37
47
|
|
|
@@ -42,8 +52,14 @@ const NOTICES = Object.freeze({
|
|
|
42
52
|
age_no_money: 'A payout to you cannot be paid in money because of the age recorded on your account. If the age is wrong, you can appeal and have it corrected.',
|
|
43
53
|
age_needs_guardian: 'A payout to you needs a parent or guardian to co-sign it and an account they control before it can be paid. You can appeal if this is wrong.',
|
|
44
54
|
age_unknown: 'A payout to you is waiting because your account has no age recorded yet. Record your age to continue, or appeal if you think this is wrong.',
|
|
55
|
+
nda_missing: 'A payout to you is waiting because no signed confidentiality agreement (NDA) is on file for you yet. Nothing is lost; it is paid once the signed agreement is recorded. You can appeal if you have already signed one.',
|
|
56
|
+
tax_details_missing: 'A payout to you is waiting because your identity and tax details have not been collected yet. You give these to the payment processor, never to us, and it is paid once they are in. You can appeal if you have already done this.',
|
|
45
57
|
});
|
|
46
58
|
|
|
59
|
+
// Nine digits in a row, dashes and spaces ignored: the shape of a US tax id. A
|
|
60
|
+
// reference that looks like one is refused here and by core_291's CHECK.
|
|
61
|
+
const looksLikeTaxId = (s) => /[0-9]{9}/.test(String(s).replace(/[\s-]/g, ''));
|
|
62
|
+
|
|
47
63
|
const latest = (rows) => (rows && rows.length ? rows[rows.length - 1] : null);
|
|
48
64
|
|
|
49
65
|
function screeningCounts(row) {
|
|
@@ -54,7 +70,7 @@ function screeningCounts(row) {
|
|
|
54
70
|
|
|
55
71
|
// Is this recipient payable in money right now? facts: their rows, oldest first.
|
|
56
72
|
// Returns { ok: true, pay_to } or { ok: false, reason, freeze_id? }.
|
|
57
|
-
function decide({ ageRecords = [], guardians = [], screenings = [], freezes = [], freezeEvents = [], now = new Date() }) {
|
|
73
|
+
function decide({ ageRecords = [], guardians = [], screenings = [], ndas = [], taxDetails = [], freezes = [], freezeEvents = [], now = new Date() }) {
|
|
58
74
|
const lifted = new Set(freezeEvents.filter((e) => e.kind === 'lifted').map((e) => String(e.freeze_id)));
|
|
59
75
|
const open = freezes.filter((f) => !lifted.has(String(f.id)));
|
|
60
76
|
if (open.length) return { ok: false, reason: 'frozen', freeze_id: String(open[open.length - 1].id), frozen_for: open[open.length - 1].reason };
|
|
@@ -74,6 +90,11 @@ function decide({ ageRecords = [], guardians = [], screenings = [], freezes = []
|
|
|
74
90
|
if (s && s.result === 'match') return { ok: false, reason: 'sanctions_match' };
|
|
75
91
|
if (!s || s.result !== 'clear' || !s.valid_until || new Date(s.valid_until) < now) return { ok: false, reason: 'sanctions_unscreened' };
|
|
76
92
|
|
|
93
|
+
const nda = latest(ndas);
|
|
94
|
+
if (!nda || nda.status !== 'on_file') return { ok: false, reason: 'nda_missing' };
|
|
95
|
+
const tax = latest(taxDetails);
|
|
96
|
+
if (!tax || tax.status !== 'collected') return { ok: false, reason: 'tax_details_missing' };
|
|
97
|
+
|
|
77
98
|
return { ok: true, pay_to: payTo, needs_guardian_cosign: payTo !== 'self' };
|
|
78
99
|
}
|
|
79
100
|
|
|
@@ -90,23 +111,25 @@ const ids = (rows) => rows.map((r) => {
|
|
|
90
111
|
});
|
|
91
112
|
|
|
92
113
|
// Everything the gate reads about one builder, oldest first per table.
|
|
93
|
-
// Everything the gate reads about a SET of builders:
|
|
114
|
+
// Everything the gate reads about a SET of builders: seven queries however many
|
|
94
115
|
// recipients a run has (one per table, then the holds' events), never one round
|
|
95
116
|
// of reads per person. Returns Map<builderId, facts>, each table oldest first.
|
|
96
117
|
async function readFactsMany(builderIds, { pool = defaultPool() } = {}) {
|
|
97
118
|
const want = [...new Set(builderIds.map(String))];
|
|
98
|
-
const out = new Map(want.map((id) => [id, { ageRecords: [], guardians: [], screenings: [], freezes: [], freezeEvents: [] }]));
|
|
119
|
+
const out = new Map(want.map((id) => [id, { ageRecords: [], guardians: [], screenings: [], ndas: [], taxDetails: [], freezes: [], freezeEvents: [] }]));
|
|
99
120
|
if (!want.length) return out;
|
|
100
121
|
// One recipient: every row is theirs. Many: rows carry builder_id.
|
|
101
122
|
const owner = (r) => (want.length === 1 ? want[0] : String(r.builder_id));
|
|
102
123
|
const q = (sql) => pool.query(sql, [want]).then((r) => ids(r.rows));
|
|
103
|
-
const [ageRecords, guardians, screenings, freezes] = await Promise.all([
|
|
124
|
+
const [ageRecords, guardians, screenings, ndas, taxDetails, freezes] = await Promise.all([
|
|
104
125
|
q('SELECT id, builder_id, tier, basis, recorded_by, recorded_at FROM payout_age_records WHERE builder_id = ANY($1::bigint[]) ORDER BY id'),
|
|
105
126
|
q('SELECT id, builder_id, guardian_id, guardian_account, active, recorded_by, recorded_at FROM payout_guardians WHERE builder_id = ANY($1::bigint[]) ORDER BY id'),
|
|
106
127
|
q('SELECT id, builder_id, result, screened_by, reference, valid_until, recorded_by, screened_at FROM payout_screenings WHERE builder_id = ANY($1::bigint[]) ORDER BY id'),
|
|
128
|
+
q('SELECT id, builder_id, status, nda_version, reference, recorded_by, recorded_at FROM payout_ndas WHERE builder_id = ANY($1::bigint[]) ORDER BY id'),
|
|
129
|
+
q('SELECT id, builder_id, status, collected_by, reference, recorded_by, recorded_at FROM payout_tax_details WHERE builder_id = ANY($1::bigint[]) ORDER BY id'),
|
|
107
130
|
q('SELECT id, builder_id, reason, notice_text, appeal_route, owed_cents, frozen_by, frozen_at FROM payout_freezes WHERE builder_id = ANY($1::bigint[]) ORDER BY id'),
|
|
108
131
|
]);
|
|
109
|
-
for (const [key, rows] of [['ageRecords', ageRecords], ['guardians', guardians], ['screenings', screenings], ['freezes', freezes]]) {
|
|
132
|
+
for (const [key, rows] of [['ageRecords', ageRecords], ['guardians', guardians], ['screenings', screenings], ['ndas', ndas], ['taxDetails', taxDetails], ['freezes', freezes]]) {
|
|
110
133
|
for (const r of rows) { const b = out.get(owner(r)); if (b) b[key].push(r); }
|
|
111
134
|
}
|
|
112
135
|
if (freezes.length) {
|
|
@@ -223,6 +246,11 @@ async function insertRow(table, cols, vals, { pool = defaultPool() } = {}) {
|
|
|
223
246
|
const recordAge = ({ builderId, tier, basis, recordedBy }, o) => insertRow('payout_age_records', ['builder_id', 'tier', 'basis', 'recorded_by'], [builderId, tier, basis, recordedBy], o);
|
|
224
247
|
const recordGuardian = ({ builderId, guardianId, guardianAccount, active, recordedBy }, o) => insertRow('payout_guardians', ['builder_id', 'guardian_id', 'guardian_account', 'active', 'recorded_by'], [builderId, guardianId, guardianAccount, active, recordedBy], o);
|
|
225
248
|
const recordScreening = ({ builderId, result, screenedBy, reference, validUntil, recordedBy }, o) => insertRow('payout_screenings', ['builder_id', 'result', 'screened_by', 'reference', 'valid_until', 'recorded_by'], [builderId, result, screenedBy, reference, validUntil, recordedBy], o);
|
|
249
|
+
const recordNda = ({ builderId, status, ndaVersion, reference, recordedBy }, o) => insertRow('payout_ndas', ['builder_id', 'status', 'nda_version', 'reference', 'recorded_by'], [builderId, status, ndaVersion, reference, recordedBy], o);
|
|
250
|
+
const recordTaxDetails = ({ builderId, status, collectedBy, reference, recordedBy }, o) => {
|
|
251
|
+
if (reference != null && looksLikeTaxId(reference)) throw new Error('recordTaxDetails: the reference looks like a tax id; record the processor onboarding reference, never the id itself');
|
|
252
|
+
return insertRow('payout_tax_details', ['builder_id', 'status', 'collected_by', 'reference', 'recorded_by'], [builderId, status, collectedBy, reference, recordedBy], o);
|
|
253
|
+
};
|
|
226
254
|
const freezeManually = ({ builderId, noticeText, owedCents, frozenBy }, o) => insertRow('payout_freezes', ['builder_id', 'reason', 'notice_text', 'appeal_route', 'owed_cents', 'frozen_by'], [builderId, 'manual', noticeText, APPEAL_ROUTE, owedCents, frozenBy], o);
|
|
227
255
|
const addFreezeEvent = ({ freezeId, kind, actorId, statement }, o) => insertRow('payout_freeze_events', ['freeze_id', 'kind', 'actor_id', 'statement'], [freezeId, kind, actorId, statement], o);
|
|
228
256
|
|
|
@@ -243,7 +271,7 @@ const recordCosign = ({ builderId, guardianId, approvalId, ruleId, runKey, sessi
|
|
|
243
271
|
async function cosignAsGuardian({ guardianId, sessionId, approvalId = null, ruleId = null, runKey = null }, { pool = defaultPool() } = {}) {
|
|
244
272
|
let recipients = [];
|
|
245
273
|
if (approvalId) {
|
|
246
|
-
const { rows } = await pool.query("SELECT recipient_id FROM payout_approvals WHERE id = $1 AND kind
|
|
274
|
+
const { rows } = await pool.query("SELECT recipient_id FROM payout_approvals WHERE id = $1 AND kind IN ('payout_approved', 'cashout_approved')", [approvalId]);
|
|
247
275
|
recipients = rows.map((r) => String(r.recipient_id));
|
|
248
276
|
} else {
|
|
249
277
|
const { rows } = await pool.query("SELECT terms FROM payout_approvals WHERE rule_id = $1 AND run_key = $2 AND kind = 'run_noticed'", [ruleId, runKey]);
|
|
@@ -290,6 +318,8 @@ function presentOwn(v) {
|
|
|
290
318
|
const age = latest(f.ageRecords || []);
|
|
291
319
|
const g = latest(f.guardians || []);
|
|
292
320
|
const s = latest((f.screenings || []).filter(screeningCounts));
|
|
321
|
+
const nda = latest(f.ndas || []);
|
|
322
|
+
const tax = latest(f.taxDetails || []);
|
|
293
323
|
const events = f.freezeEvents || [];
|
|
294
324
|
return {
|
|
295
325
|
payable: Boolean(v.ok),
|
|
@@ -297,6 +327,8 @@ function presentOwn(v) {
|
|
|
297
327
|
age_tier: age ? age.tier : null,
|
|
298
328
|
guardian: g ? { guardian_id: g.guardian_id, active: g.active } : null,
|
|
299
329
|
screening: s ? { result: s.result, valid_until: s.valid_until || null } : null,
|
|
330
|
+
nda_on_file: Boolean(nda && nda.status === 'on_file'),
|
|
331
|
+
tax_details_collected: Boolean(tax && tax.status === 'collected'),
|
|
300
332
|
holds: (f.freezes || []).map((fr) => {
|
|
301
333
|
const mine = events.filter((e) => String(e.freeze_id) === String(fr.id));
|
|
302
334
|
return {
|
|
@@ -334,8 +366,11 @@ const api = {
|
|
|
334
366
|
AGE_TIERS,
|
|
335
367
|
AGE_BASES,
|
|
336
368
|
SCREEN_RESULTS,
|
|
369
|
+
NDA_STATUSES,
|
|
370
|
+
TAX_STATUSES,
|
|
337
371
|
FREEZE_REASONS,
|
|
338
372
|
NOTICES,
|
|
373
|
+
looksLikeTaxId,
|
|
339
374
|
APPEAL_ROUTE,
|
|
340
375
|
MAX_SCREEN_DAYS,
|
|
341
376
|
screeningCounts,
|
|
@@ -352,6 +387,8 @@ const api = {
|
|
|
352
387
|
recordAge,
|
|
353
388
|
recordGuardian,
|
|
354
389
|
recordScreening,
|
|
390
|
+
recordNda,
|
|
391
|
+
recordTaxDetails,
|
|
355
392
|
recordCosign,
|
|
356
393
|
cosignAsGuardian,
|
|
357
394
|
hasCosign,
|
|
@@ -56,6 +56,8 @@ const SETTING_KEYS = {
|
|
|
56
56
|
// (rankCapKey). A rank cap is a PER-PERSON monthly allowance (task 1005019); an empty value means the rank has none of its own.
|
|
57
57
|
TWEAK_AUTOPICKUP_RANK_CAP_PREFIX: 'tweak.autopickup_rank_cap.',
|
|
58
58
|
SPEND_CAP: 'treasury.spend_cap_usd',
|
|
59
|
+
// The funding wall (task 1003185, ADR 0386): modules/lifecycle/funding-wall.js.
|
|
60
|
+
FUNDING_WALL: 'funding.wall',
|
|
59
61
|
// Testing new module versions first (task 1003802, ADR 0344 D1): module-testing.js.
|
|
60
62
|
MODULE_TESTER: 'modules.tester',
|
|
61
63
|
MODULE_GENERAL_ONLY: 'modules.general_only',
|