@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.
Files changed (78) hide show
  1. package/.bongos-core.json +208 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +42 -0
  4. package/clients/bongos-client/index.cjs +42 -0
  5. package/clients/bongos-client/index.d.ts +55 -1
  6. package/clients/bongos-client/index.mjs +42 -0
  7. package/docs/api/openapi.json +1181 -207
  8. package/docs/api-reference.md +30 -5
  9. package/docs/architecture.md +42 -0
  10. package/docs/copy-inventory.md +18 -8
  11. package/docs/copy-registry.json +99 -9
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-readings.json +64 -25
  14. package/migrations/core_291_payout_nda_tax.sql +75 -0
  15. package/migrations/core_292_module_sale_payouts.sql +170 -0
  16. package/migrations/core_293_cash_outs.sql +184 -0
  17. package/migrations/core_294_cash_out_attempts_sent_by_time.sql +21 -0
  18. package/modules/economy/ledger-check.js +360 -0
  19. package/modules/economy/migrations/economy_003_user_balances.sql +116 -0
  20. package/modules/economy/migrations/economy_004_module_share_payouts.sql +22 -0
  21. package/modules/economy/migrations/economy_005_project_account_holds_index.sql +20 -0
  22. package/modules/economy/migrations/economy_006_ledger_checks.sql +68 -0
  23. package/modules/economy/module.json +1 -1
  24. package/modules/economy/outflow.js +209 -0
  25. package/modules/economy/pollers/ledger-check.js +87 -0
  26. package/modules/economy/project-account.js +92 -7
  27. package/modules/economy/routes/balances.js +79 -0
  28. package/modules/economy/routes/project-account.js +40 -2
  29. package/modules/economy/user-balances.js +308 -0
  30. package/modules/government/decision-rules.js +1 -0
  31. package/modules/hall-ui/public/project-settings.css +18 -0
  32. package/modules/hall-ui/public/project-settings.html +15 -2
  33. package/modules/hall-ui/public/project-settings.js +142 -0
  34. package/modules/ideas/module.json +2 -1
  35. package/modules/ideas/routes/blockers.js +26 -0
  36. package/modules/lifecycle/db-claims.js +22 -0
  37. package/modules/lifecycle/db-ship.js +5 -5
  38. package/modules/lifecycle/funding-wall.js +253 -0
  39. package/modules/lifecycle/module.json +2 -1
  40. package/modules/lifecycle/routes/claims.js +9 -0
  41. package/modules/lifecycle/routes/pay-type-gates.js +7 -0
  42. package/modules/lifecycle/routes/project-settings.js +59 -2
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +38 -0
  46. package/scripts/bongos/government-walk.js +1 -0
  47. package/scripts/bongos/plain-cards.js +18 -0
  48. package/scripts/bongos/run-unit-tests.js +28 -0
  49. package/src/bongos/builder-payouts.js +6 -1
  50. package/src/bongos/cash-outs.js +399 -0
  51. package/src/bongos/module-payouts.js +432 -0
  52. package/src/bongos/payout-approvals.js +5 -2
  53. package/src/bongos/payout-gates.js +44 -7
  54. package/src/bongos/project-settings.js +2 -0
  55. package/src/bongos/routes/module-payouts.js +114 -0
  56. package/src/bongos/routes/my-cash-outs.js +103 -0
  57. package/src/bongos/routes/payout-gates.js +42 -1
  58. package/src/bongos/routes.js +6 -0
  59. package/src/module-api.js +1 -1
  60. package/tests/builder_payouts_db.mjs +1 -0
  61. package/tests/cash_outs.mjs +96 -0
  62. package/tests/cash_outs_db.mjs +205 -0
  63. package/tests/funded_project_pays_proof_db.mjs +181 -0
  64. package/tests/funding_wall.mjs +205 -0
  65. package/tests/funding_wall_db.mjs +174 -0
  66. package/tests/government_act_alone_buildings.mjs +1 -0
  67. package/tests/ledger_check.mjs +189 -0
  68. package/tests/ledger_check_db.mjs +119 -0
  69. package/tests/module_payouts.mjs +295 -0
  70. package/tests/module_payouts_db.mjs +140 -0
  71. package/tests/own_scope_guard.mjs +5 -0
  72. package/tests/payout_gates.mjs +67 -6
  73. package/tests/payout_gates_db.mjs +23 -1
  74. package/tests/project_account_db.mjs +5 -2
  75. package/tests/project_outflow.mjs +103 -0
  76. package/tests/project_outflow_db.mjs +100 -0
  77. package/tests/user_balances.mjs +241 -0
  78. 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
- if (kind === 'payout_approved' && (row.amount_cents !== Number(amountCents) || row.currency !== currency)) {
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 = kind === 'payout_approved'
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 FREEZE_REASONS = Object.freeze(['sanctions_match', 'sanctions_unscreened', 'age_no_money', 'age_needs_guardian', 'age_unknown', 'manual']);
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: five queries however many
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 = 'payout_approved'", [approvalId]);
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',