@bongos/core 1.24.23 → 1.24.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +208 -53
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +42 -0
- package/clients/bongos-client/index.cjs +42 -0
- package/clients/bongos-client/index.d.ts +55 -1
- package/clients/bongos-client/index.mjs +42 -0
- package/docs/api/openapi.json +1181 -207
- package/docs/api-reference.md +30 -5
- package/docs/architecture.md +42 -0
- package/docs/copy-inventory.md +18 -8
- package/docs/copy-registry.json +99 -9
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +64 -25
- package/migrations/core_291_payout_nda_tax.sql +75 -0
- package/migrations/core_292_module_sale_payouts.sql +170 -0
- package/migrations/core_293_cash_outs.sql +184 -0
- package/migrations/core_294_cash_out_attempts_sent_by_time.sql +21 -0
- package/modules/economy/ledger-check.js +360 -0
- package/modules/economy/migrations/economy_003_user_balances.sql +116 -0
- package/modules/economy/migrations/economy_004_module_share_payouts.sql +22 -0
- package/modules/economy/migrations/economy_005_project_account_holds_index.sql +20 -0
- package/modules/economy/migrations/economy_006_ledger_checks.sql +68 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/outflow.js +209 -0
- package/modules/economy/pollers/ledger-check.js +87 -0
- package/modules/economy/project-account.js +92 -7
- package/modules/economy/routes/balances.js +79 -0
- package/modules/economy/routes/project-account.js +40 -2
- package/modules/economy/user-balances.js +308 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/hall-ui/public/project-settings.css +18 -0
- package/modules/hall-ui/public/project-settings.html +15 -2
- package/modules/hall-ui/public/project-settings.js +142 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/blockers.js +26 -0
- package/modules/lifecycle/db-claims.js +22 -0
- package/modules/lifecycle/db-ship.js +5 -5
- package/modules/lifecycle/funding-wall.js +253 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +9 -0
- package/modules/lifecycle/routes/pay-type-gates.js +7 -0
- package/modules/lifecycle/routes/project-settings.js +59 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +38 -0
- package/scripts/bongos/government-walk.js +1 -0
- package/scripts/bongos/plain-cards.js +18 -0
- package/scripts/bongos/run-unit-tests.js +28 -0
- package/src/bongos/builder-payouts.js +6 -1
- package/src/bongos/cash-outs.js +399 -0
- package/src/bongos/module-payouts.js +432 -0
- package/src/bongos/payout-approvals.js +5 -2
- package/src/bongos/payout-gates.js +44 -7
- package/src/bongos/project-settings.js +2 -0
- package/src/bongos/routes/module-payouts.js +114 -0
- package/src/bongos/routes/my-cash-outs.js +103 -0
- package/src/bongos/routes/payout-gates.js +42 -1
- package/src/bongos/routes.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/builder_payouts_db.mjs +1 -0
- package/tests/cash_outs.mjs +96 -0
- package/tests/cash_outs_db.mjs +205 -0
- package/tests/funded_project_pays_proof_db.mjs +181 -0
- package/tests/funding_wall.mjs +205 -0
- package/tests/funding_wall_db.mjs +174 -0
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/ledger_check.mjs +189 -0
- package/tests/ledger_check_db.mjs +119 -0
- package/tests/module_payouts.mjs +295 -0
- package/tests/module_payouts_db.mjs +140 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/payout_gates.mjs +67 -6
- package/tests/payout_gates_db.mjs +23 -1
- package/tests/project_account_db.mjs +5 -2
- package/tests/project_outflow.mjs +103 -0
- package/tests/project_outflow_db.mjs +100 -0
- package/tests/user_balances.mjs +241 -0
- package/tests/user_balances_db.mjs +137 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/cash-outs.js — A PERSON WITHDRAWS THEIR OWN BALANCE TO THEIR BANK, WITH HOLDS,
|
|
4
|
+
// A MINIMUM AND A RECORD OF EVERY FAILURE (task 1004911, goal 1000092 N7; ADR 0385 D1/D8,
|
|
5
|
+
// ADR 0397 D2/D7).
|
|
6
|
+
//
|
|
7
|
+
// THE PRODUCT GATE (legal framework, by Article only; a product constraint, not advice).
|
|
8
|
+
// actor the person whose money it is, on their own signed-in web session, with their
|
|
9
|
+
// own logged approval of this one withdrawal (a `cashout_approved` row in the
|
|
10
|
+
// N9 log: approver and recipient are the same person, by CHECK). Never a CLI,
|
|
11
|
+
// an agent, a standing rule or the project's owner.
|
|
12
|
+
// custody the processor, throughout. The money is in the person's own connected
|
|
13
|
+
// account and goes from there to the bank they gave the processor. Bongos never
|
|
14
|
+
// holds it, never sees bank details, and never sums these rows into a balance.
|
|
15
|
+
// words balance, cash-out, withdrawal, payout, bank. Never deposit, wallet, interest,
|
|
16
|
+
// return-on-anything or yield.
|
|
17
|
+
// control one approval, one withdrawal; one press, one withdrawal. Nothing repeats.
|
|
18
|
+
// advice none is given.
|
|
19
|
+
// primitive the Stripe Connect payout from a connected account's balance to its external
|
|
20
|
+
// bank account (ADR 0397 D3 table). Only the processor adapter names Stripe.
|
|
21
|
+
//
|
|
22
|
+
// THE ORDER.
|
|
23
|
+
// 1. Refuse what is malformed or below the minimum, a person with no account here, a
|
|
24
|
+
// second withdrawal while one is still open, and more than the processor says they
|
|
25
|
+
// have. Under a per-person lock: record the person's approval and the withdrawal in
|
|
26
|
+
// ONE transaction, once per instruction id.
|
|
27
|
+
// 2. Check the authority again at send time (core checkApproval): the approval covers
|
|
28
|
+
// exactly this, and the payout gates (age tier, sanctions, open holds; N10) still pass.
|
|
29
|
+
// A refusal of money that is the person's files a hold that TELLS them, with the appeal
|
|
30
|
+
// route; here it is a `held` attempt with the reason.
|
|
31
|
+
// 3. Ask the processor, under a per-withdrawal lock, with the key bongos-cashout-<id>-<try>.
|
|
32
|
+
// The database (core_293) works out <try> itself and moves it on only after the
|
|
33
|
+
// processor said the last try did not pay (`refused`) or the bank sent it back
|
|
34
|
+
// (`returned`). A timeout or an answer we cannot trust is `failed` and keeps the key,
|
|
35
|
+
// so pressing retry gets back the payout the processor already made, never a second.
|
|
36
|
+
//
|
|
37
|
+
// THE PROCESSOR PORT (optional; with none connected every withdrawal is held `no_processor`):
|
|
38
|
+
// memberBalanceMicro(accountRef) the person's balance, the processor's figure
|
|
39
|
+
// memberPayout({ idempotencyKey, amountCents, currency, account, payTo })
|
|
40
|
+
// → { ref, paid? }; an error with `refused: true`
|
|
41
|
+
// means the processor created no payout
|
|
42
|
+
// and the adapter's webhook reports later news through recordProcessorOutcome (paid, or
|
|
43
|
+
// returned with the bank's reason). The person's account is read through the economy
|
|
44
|
+
// module's `economy.account` port (memberAccount), never from the request.
|
|
45
|
+
|
|
46
|
+
const seams = require('../module-seams');
|
|
47
|
+
|
|
48
|
+
const LOCK_SPACE = 1004911;
|
|
49
|
+
const PROCESSOR_TIMEOUT_MS = 15_000;
|
|
50
|
+
const READ_TIMEOUT_MS = 5000;
|
|
51
|
+
const MICRO_PER_CENT = 10_000;
|
|
52
|
+
const CURRENCY = 'USD';
|
|
53
|
+
// The smallest withdrawal: below this the bank's own handling outweighs the amount. A
|
|
54
|
+
// product choice, recorded in the session log; changing it is a code change, not a setting.
|
|
55
|
+
const MIN_CASH_OUT_CENTS = 1000;
|
|
56
|
+
const MAX_CASH_OUT_CENTS = 100_000_000_00; // a sanity ceiling, the approval log's own
|
|
57
|
+
const REASON_MAX = 300;
|
|
58
|
+
const INSTRUCTION_RE = /^[A-Za-z0-9_-]{8,80}$/;
|
|
59
|
+
|
|
60
|
+
const isId = (v) => (typeof v === 'number' && Number.isSafeInteger(v) && v > 0) || (typeof v === 'string' && /^[1-9][0-9]{0,18}$/.test(v));
|
|
61
|
+
const clip = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, REASON_MAX) || 'unknown';
|
|
62
|
+
const idempotencyKey = (cashOutId, tryNo) => `bongos-cashout-${cashOutId}-${tryNo}`;
|
|
63
|
+
const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;
|
|
64
|
+
|
|
65
|
+
class CashOutRefusal extends Error {
|
|
66
|
+
constructor(code, message, status = 400, details = undefined) { super(message); this.code = code; this.status = status; this.details = details; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Plain words for why a withdrawal waits.
|
|
70
|
+
const HELD_WORDS = Object.freeze({
|
|
71
|
+
no_processor: 'No payment processor is connected to this project yet, so nothing was sent. Your money is still in your balance.',
|
|
72
|
+
no_such_approval: 'The approval for this withdrawal could not be found, so nothing was sent.',
|
|
73
|
+
different_recipient: 'The approval for this withdrawal is not yours, so nothing was sent.',
|
|
74
|
+
different_amount: 'The approval for this withdrawal is for a different amount, so nothing was sent.',
|
|
75
|
+
recipient_gated: 'You cannot be paid out right now. We have told you why under your payout holds, and you can appeal there. Your money is still in your balance.',
|
|
76
|
+
guardian_cosign_missing: 'Because you are 16 or 17, your parent or guardian needs to co-sign this withdrawal before it can be sent.',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Plain words for where each withdrawal stands, as the person reads it.
|
|
80
|
+
const STATUS_WORDS = Object.freeze({
|
|
81
|
+
held: 'Waiting. Nothing has left your balance.',
|
|
82
|
+
failed: 'We could not confirm with the processor whether this went. Pressing retry is safe: it can never be paid twice.',
|
|
83
|
+
refused: 'The processor turned this down and nothing was sent. Your money is still in your balance. You can try again.',
|
|
84
|
+
sent: 'On its way to your bank.',
|
|
85
|
+
paid: 'Arrived at your bank.',
|
|
86
|
+
returned: 'Your bank sent this back, so the money is in your balance again. You can try again.',
|
|
87
|
+
cancelled: 'You cancelled this. Nothing was sent.',
|
|
88
|
+
});
|
|
89
|
+
// Where a withdrawal still needs something: the person can retry or cancel it.
|
|
90
|
+
const OPEN = Object.freeze(['held', 'failed', 'refused', 'returned']);
|
|
91
|
+
|
|
92
|
+
function defaultPool() { return require('./pool').pool; }
|
|
93
|
+
function defaultAuthority() { return require('./payout-approvals'); }
|
|
94
|
+
function defaultAccount() { return seams.resolveOptional('economy.account'); }
|
|
95
|
+
|
|
96
|
+
function withTimeout(promise, ms) {
|
|
97
|
+
let timer;
|
|
98
|
+
return Promise.race([
|
|
99
|
+
promise,
|
|
100
|
+
new Promise((_, rej) => { timer = setTimeout(() => rej(Object.assign(new Error('the processor did not answer in time'), { code: 'timeout' })), ms); }),
|
|
101
|
+
]).finally(() => clearTimeout(timer));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A transaction on a checked-out client when the pool can give one; a single connection
|
|
105
|
+
// (a test stand-in, or a caller's client) runs the statements as they are.
|
|
106
|
+
async function inTx(pool, fn) {
|
|
107
|
+
if (typeof pool.connect !== 'function' || typeof pool.release === 'function') return fn(pool);
|
|
108
|
+
const client = await pool.connect();
|
|
109
|
+
try {
|
|
110
|
+
await client.query('BEGIN');
|
|
111
|
+
const out = await fn(client);
|
|
112
|
+
await client.query('COMMIT');
|
|
113
|
+
return out;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
116
|
+
throw err;
|
|
117
|
+
} finally {
|
|
118
|
+
client.release();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const lock = (db, key) => db.query('SELECT pg_advisory_xact_lock($1::int, hashtext($2::text))', [LOCK_SPACE, key]);
|
|
123
|
+
|
|
124
|
+
// ── reading where a withdrawal stands ──────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
const CASH_OUT_COLS = 'id, builder_id, approval_id, from_account, amount_cents, currency, instruction_id, created_at';
|
|
127
|
+
|
|
128
|
+
async function readAttempts(db, cashOutId) {
|
|
129
|
+
const { rows } = await db.query(
|
|
130
|
+
'SELECT id, try_no, outcome, processor_ref, reason, created_at FROM cash_out_attempts WHERE cash_out_id = $1 ORDER BY id', [cashOutId]);
|
|
131
|
+
return rows;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Fold a withdrawal's attempts, oldest first, into where it stands. The try number is the
|
|
135
|
+
// database's rule, repeated: one more than the tries the processor said did not pay.
|
|
136
|
+
function standing(attempts) {
|
|
137
|
+
const tryNo = 1 + attempts.filter((a) => a.outcome === 'refused' || a.outcome === 'returned').length;
|
|
138
|
+
const last = attempts[attempts.length - 1] || null;
|
|
139
|
+
const sent = attempts.find((a) => a.outcome === 'sent' && Number(a.try_no) === tryNo) || null;
|
|
140
|
+
const cancelled = attempts.some((a) => a.outcome === 'cancelled');
|
|
141
|
+
let status;
|
|
142
|
+
if (cancelled) status = 'cancelled';
|
|
143
|
+
else if (sent) status = attempts.some((a) => a.outcome === 'paid' && Number(a.try_no) === tryNo) ? 'paid' : 'sent';
|
|
144
|
+
else status = last ? last.outcome : 'held';
|
|
145
|
+
return { status, try_no: tryNo, processor_ref: sent ? sent.processor_ref : null, last };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function present(row, attempts) {
|
|
149
|
+
const s = standing(attempts);
|
|
150
|
+
const reason = s.status === 'paid' || s.status === 'sent' || !s.last ? null : (s.last.reason || null);
|
|
151
|
+
const heldFor = s.status === 'held' && reason ? reason.split(' ')[0] : null;
|
|
152
|
+
return {
|
|
153
|
+
id: String(row.id), amount_cents: Number(row.amount_cents), currency: row.currency,
|
|
154
|
+
approval_id: String(row.approval_id), created_at: row.created_at,
|
|
155
|
+
status: s.status, words: (heldFor && HELD_WORDS[heldFor]) || STATUS_WORDS[s.status],
|
|
156
|
+
reason, processor_ref: s.processor_ref, tries: s.try_no,
|
|
157
|
+
// the record that this money has not reached the bank yet: it stays until paid or cancelled
|
|
158
|
+
still_yours: s.status !== 'paid' && s.status !== 'sent' && s.status !== 'cancelled',
|
|
159
|
+
can_retry: OPEN.includes(s.status), can_cancel: OPEN.includes(s.status),
|
|
160
|
+
history: attempts.map((a) => ({ outcome: a.outcome, try: Number(a.try_no), reason: a.reason || null, at: a.created_at })),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readCashOut(db, cashOutId) {
|
|
165
|
+
const { rows } = await db.query(`SELECT ${CASH_OUT_COLS} FROM cash_outs WHERE id = $1`, [cashOutId]);
|
|
166
|
+
return rows[0] || null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The person's own withdrawal that still needs something, if any. Only their newest can
|
|
170
|
+
// be: a new one is recorded only while none is open (requestCashOut, under the person's lock).
|
|
171
|
+
async function openCashOut(db, builderId) {
|
|
172
|
+
const { rows } = await db.query(`SELECT ${CASH_OUT_COLS} FROM cash_outs WHERE builder_id = $1 ORDER BY id DESC LIMIT 1`, [builderId]);
|
|
173
|
+
if (!rows[0]) return null;
|
|
174
|
+
const s = standing(await readAttempts(db, rows[0].id));
|
|
175
|
+
return OPEN.includes(s.status) || s.status === 'sent' ? { row: rows[0], status: s.status } : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function insertAttempt(db, cashOutId, tryNo, { outcome, processorRef = null, reason = null, actorId = null }) {
|
|
179
|
+
await db.query(
|
|
180
|
+
'INSERT INTO cash_out_attempts (cash_out_id, try_no, outcome, processor_ref, reason, actor_id) VALUES ($1,$2,$3,$4,$5,$6)',
|
|
181
|
+
[cashOutId, tryNo, outcome, processorRef, reason === null ? null : clip(reason), isId(actorId) ? actorId : null]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── step 3: the send ───────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
async function send(pool, row, { processor, payTo, actorId, timeoutMs }) {
|
|
187
|
+
return inTx(pool, async (db) => {
|
|
188
|
+
await lock(db, `c:${row.id}`);
|
|
189
|
+
const s = standing(await readAttempts(db, row.id));
|
|
190
|
+
if (!OPEN.includes(s.status)) return { status: s.status, already: true };
|
|
191
|
+
let out;
|
|
192
|
+
try {
|
|
193
|
+
out = await withTimeout(Promise.resolve(processor.memberPayout({
|
|
194
|
+
idempotencyKey: idempotencyKey(row.id, s.try_no),
|
|
195
|
+
amountCents: Number(row.amount_cents), currency: row.currency,
|
|
196
|
+
account: row.from_account, payTo,
|
|
197
|
+
})), timeoutMs);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const reason = clip(`${err && err.code ? err.code + ': ' : ''}${err && err.message ? err.message : 'payout failed'}`);
|
|
200
|
+
// `refused` only when the processor says it made nothing; anything else may have paid
|
|
201
|
+
const outcome = err && err.refused === true ? 'refused' : 'failed';
|
|
202
|
+
await insertAttempt(db, row.id, s.try_no, { outcome, reason, actorId });
|
|
203
|
+
return { status: outcome, reason };
|
|
204
|
+
}
|
|
205
|
+
const ref = out && typeof out.ref === 'string' && out.ref.trim() ? out.ref.trim() : null;
|
|
206
|
+
if (!ref) {
|
|
207
|
+
await insertAttempt(db, row.id, s.try_no, { outcome: 'failed', reason: 'no_reference: the processor gave no payout reference', actorId });
|
|
208
|
+
return { status: 'failed', reason: 'no_reference' };
|
|
209
|
+
}
|
|
210
|
+
// If the database fails from here the payout exists and nothing says so; the
|
|
211
|
+
// transaction rolls back, the withdrawal still reads open, and the next retry sends
|
|
212
|
+
// the same key, so the processor hands back this payout and it is booked then.
|
|
213
|
+
await insertAttempt(db, row.id, s.try_no, { outcome: 'sent', processorRef: ref, actorId });
|
|
214
|
+
if (out.paid === true) await insertAttempt(db, row.id, s.try_no, { outcome: 'paid', processorRef: ref, actorId });
|
|
215
|
+
return { status: out.paid === true ? 'paid' : 'sent', processor_ref: ref };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// sendCashOut(row, …) — steps 2 and 3 for one recorded withdrawal: re-check the authority,
|
|
220
|
+
// then ask the processor. Every outcome is a returned status, written as an attempt.
|
|
221
|
+
async function sendCashOut(row, { pool, authority, processor, actorId, timeoutMs }) {
|
|
222
|
+
const s = standing(await readAttempts(pool, row.id));
|
|
223
|
+
if (!OPEN.includes(s.status)) return { status: s.status, already: true };
|
|
224
|
+
|
|
225
|
+
const held = async (reason, extra = {}) => {
|
|
226
|
+
await insertAttempt(pool, row.id, s.try_no, { outcome: 'held', reason, actorId });
|
|
227
|
+
return { status: 'held', reason, words: HELD_WORDS[reason.split(' ')[0]] || null, ...extra };
|
|
228
|
+
};
|
|
229
|
+
const auth = await authority.checkApproval({
|
|
230
|
+
id: row.approval_id, kind: 'cashout_approved', recipientId: row.builder_id,
|
|
231
|
+
amountCents: Number(row.amount_cents), currency: row.currency,
|
|
232
|
+
}, { pool });
|
|
233
|
+
if (!auth || !auth.ok) {
|
|
234
|
+
const why = (auth && auth.reason) || 'no_such_approval';
|
|
235
|
+
const gate = auth && auth.gate ? auth.gate : null;
|
|
236
|
+
return held(gate ? `${why} (${gate.reason})` : why, gate ? { freeze_id: gate.freeze_id } : {});
|
|
237
|
+
}
|
|
238
|
+
if (!processor || typeof processor.memberPayout !== 'function') return held('no_processor');
|
|
239
|
+
return send(pool, row, { processor, payTo: auth.pay_to, actorId, timeoutMs });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── the person's three acts ────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
async function readBalance(processor, account, log) {
|
|
245
|
+
if (!processor || typeof processor.memberBalanceMicro !== 'function' || !account) return null;
|
|
246
|
+
try {
|
|
247
|
+
const v = Number(await withTimeout(Promise.resolve(processor.memberBalanceMicro(account)), READ_TIMEOUT_MS));
|
|
248
|
+
return Number.isSafeInteger(v) ? v : null;
|
|
249
|
+
} catch (e) {
|
|
250
|
+
if (log) log.error({ err: e.message }, 'cash-out: member balance unreadable');
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// requestCashOut({ builderId, actor, amountCents, instructionId }, deps) — the person asks
|
|
256
|
+
// to take amountCents of their own balance to their bank. actor is the personal web session
|
|
257
|
+
// ({ id, rank, session_id, session_source:'web' }, payout-approvals personalSession).
|
|
258
|
+
// Throws CashOutRefusal only BEFORE anything is recorded; afterwards every outcome is a
|
|
259
|
+
// returned status. Pressing again with the same instruction id is the same withdrawal.
|
|
260
|
+
async function requestCashOut({ builderId, actor, amountCents, instructionId }, {
|
|
261
|
+
pool = defaultPool(), authority = defaultAuthority(), account = defaultAccount(),
|
|
262
|
+
processor = null, timeoutMs = PROCESSOR_TIMEOUT_MS, log = null,
|
|
263
|
+
} = {}) {
|
|
264
|
+
if (!isId(builderId) || !actor || String(actor.id) !== String(builderId) || actor.session_source !== 'web') {
|
|
265
|
+
throw new CashOutRefusal('personal_session_required', 'A cash-out is made by the person themself, signed in on the website.', 403);
|
|
266
|
+
}
|
|
267
|
+
const amount = Number(amountCents);
|
|
268
|
+
if (!Number.isSafeInteger(amount) || amount <= 0 || amount > MAX_CASH_OUT_CENTS) {
|
|
269
|
+
throw new CashOutRefusal('bad_amount', 'amount_cents must be a positive whole number of cents.');
|
|
270
|
+
}
|
|
271
|
+
if (amount < MIN_CASH_OUT_CENTS) {
|
|
272
|
+
throw new CashOutRefusal('below_minimum', `The smallest cash-out is ${dollars(MIN_CASH_OUT_CENTS)}.`, 400, { minimum_cents: MIN_CASH_OUT_CENTS });
|
|
273
|
+
}
|
|
274
|
+
if (typeof instructionId !== 'string' || !INSTRUCTION_RE.test(instructionId)) {
|
|
275
|
+
throw new CashOutRefusal('bad_instruction_id', 'instruction_id must be 8 to 80 letters, digits, dashes or underscores, one per cash-out.');
|
|
276
|
+
}
|
|
277
|
+
if (!account || typeof account.memberAccount !== 'function') {
|
|
278
|
+
throw new CashOutRefusal('no_account_record', 'Balances are switched off in this project, so there is nothing to cash out.', 409);
|
|
279
|
+
}
|
|
280
|
+
const from = await account.memberAccount(pool, builderId);
|
|
281
|
+
if (!from) throw new CashOutRefusal('no_account', 'You have no processor account in this project yet, so there is nothing to cash out.', 409);
|
|
282
|
+
|
|
283
|
+
// the processor's figure, when it can be read. When it cannot, nothing here stops the
|
|
284
|
+
// request: the processor's own payout refuses an overdraw (recorded as `refused`), and
|
|
285
|
+
// the adapter must keep it so.
|
|
286
|
+
const balance = await readBalance(processor, from, log);
|
|
287
|
+
if (balance !== null && amount * MICRO_PER_CENT > balance) {
|
|
288
|
+
throw new CashOutRefusal('over_balance', `You have ${dollars(Math.floor(balance / MICRO_PER_CENT))} in your balance; you cannot cash out more than that.`, 409);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const row = await inTx(pool, async (db) => {
|
|
292
|
+
await lock(db, `b:${builderId}`);
|
|
293
|
+
const { rows: had } = await db.query(`SELECT ${CASH_OUT_COLS} FROM cash_outs WHERE builder_id = $1 AND instruction_id = $2`, [builderId, instructionId]);
|
|
294
|
+
if (had[0]) {
|
|
295
|
+
if (Number(had[0].amount_cents) !== amount) throw new CashOutRefusal('instruction_reused', 'That instruction id was already used for a different cash-out.', 409);
|
|
296
|
+
return had[0];
|
|
297
|
+
}
|
|
298
|
+
const open = await openCashOut(db, builderId);
|
|
299
|
+
if (open) {
|
|
300
|
+
throw new CashOutRefusal('cash_out_open', 'You already have a cash-out that is not finished. Retry or cancel it first.', 409,
|
|
301
|
+
{ cash_out_id: String(open.row.id), status: open.status });
|
|
302
|
+
}
|
|
303
|
+
const approval = await authority.insertEvent({
|
|
304
|
+
kind: 'cashout_approved', actor, recipient_id: String(builderId),
|
|
305
|
+
amount_cents: amount, currency: CURRENCY, note: 'My own cash-out to my bank.',
|
|
306
|
+
}, { pool: db });
|
|
307
|
+
const { rows } = await db.query(
|
|
308
|
+
`INSERT INTO cash_outs (builder_id, approval_id, from_account, amount_cents, currency, instruction_id)
|
|
309
|
+
VALUES ($1,$2,$3,$4,$5,$6) RETURNING ${CASH_OUT_COLS}`,
|
|
310
|
+
[builderId, approval.id, from, amount, CURRENCY, instructionId]);
|
|
311
|
+
return rows[0];
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const out = await sendCashOut(row, { pool, authority, processor, actorId: builderId, timeoutMs });
|
|
315
|
+
return { ...out, cash_out: present(row, await readAttempts(pool, row.id)) };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function ownCashOut(pool, builderId, cashOutId) {
|
|
319
|
+
if (!isId(cashOutId)) throw new CashOutRefusal('bad_cash_out_id', 'cash-out id must be a number.');
|
|
320
|
+
const row = await readCashOut(pool, cashOutId);
|
|
321
|
+
if (!row || String(row.builder_id) !== String(builderId)) throw new CashOutRefusal('no_such_cash_out', 'You have no cash-out with that id.', 404);
|
|
322
|
+
return row;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// retryCashOut({ builderId, cashOutId }, deps) — try an open withdrawal again. Safe to press
|
|
326
|
+
// any number of times: the key changes only when the processor said the last try did not pay.
|
|
327
|
+
async function retryCashOut({ builderId, cashOutId }, {
|
|
328
|
+
pool = defaultPool(), authority = defaultAuthority(), processor = null, timeoutMs = PROCESSOR_TIMEOUT_MS,
|
|
329
|
+
} = {}) {
|
|
330
|
+
const row = await ownCashOut(pool, builderId, cashOutId);
|
|
331
|
+
const out = await sendCashOut(row, { pool, authority, processor, actorId: builderId, timeoutMs });
|
|
332
|
+
return { ...out, cash_out: present(row, await readAttempts(pool, row.id)) };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// cancelCashOut({ builderId, cashOutId }, deps) — the person withdraws a request that has not
|
|
336
|
+
// been sent. A sent one cannot be cancelled here: it is with the processor and the bank.
|
|
337
|
+
async function cancelCashOut({ builderId, cashOutId }, { pool = defaultPool() } = {}) {
|
|
338
|
+
const row = await ownCashOut(pool, builderId, cashOutId);
|
|
339
|
+
return inTx(pool, async (db) => {
|
|
340
|
+
await lock(db, `c:${row.id}`);
|
|
341
|
+
const s = standing(await readAttempts(db, row.id));
|
|
342
|
+
if (s.status === 'cancelled') return { status: 'cancelled', already: true, cash_out: present(row, await readAttempts(db, row.id)) };
|
|
343
|
+
if (!OPEN.includes(s.status)) {
|
|
344
|
+
throw new CashOutRefusal('cannot_cancel', s.status === 'paid' ? 'This cash-out has already reached your bank.' : 'This cash-out is already on its way to your bank and cannot be cancelled here.', 409);
|
|
345
|
+
}
|
|
346
|
+
await insertAttempt(db, row.id, s.try_no, { outcome: 'cancelled', reason: 'cancelled by the person', actorId: builderId });
|
|
347
|
+
return { status: 'cancelled', already: false, cash_out: present(row, await readAttempts(db, row.id)) };
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// recordProcessorOutcome({ processorRef, outcome, reason }, { pool }) — the processor
|
|
352
|
+
// adapter's later news about a sent payout: `paid` (it reached the bank) or `returned` (the
|
|
353
|
+
// bank sent it back; the money is in the person's balance again and the next try gets a new
|
|
354
|
+
// key). Repeating the same news is a no-op. Returns null for a reference we never sent.
|
|
355
|
+
async function recordProcessorOutcome({ processorRef, outcome, reason = null }, { pool = defaultPool() } = {}) {
|
|
356
|
+
if (outcome !== 'paid' && outcome !== 'returned') throw new CashOutRefusal('bad_outcome', 'outcome is paid or returned.');
|
|
357
|
+
const ref = typeof processorRef === 'string' ? processorRef.trim() : '';
|
|
358
|
+
if (!ref) throw new CashOutRefusal('bad_reference', 'processorRef is the payout reference the processor gave.');
|
|
359
|
+
if (outcome === 'returned' && !String(reason || '').trim()) throw new CashOutRefusal('no_reason', 'A returned payout says why.');
|
|
360
|
+
const { rows } = await pool.query("SELECT cash_out_id, try_no FROM cash_out_attempts WHERE processor_ref = $1 AND outcome = 'sent'", [ref]);
|
|
361
|
+
if (!rows[0]) return null;
|
|
362
|
+
const { cash_out_id: id, try_no: tryNo } = rows[0];
|
|
363
|
+
return inTx(pool, async (db) => {
|
|
364
|
+
await lock(db, `c:${id}`);
|
|
365
|
+
const attempts = await readAttempts(db, id);
|
|
366
|
+
const mine = attempts.filter((a) => Number(a.try_no) === Number(tryNo) && a.processor_ref === ref);
|
|
367
|
+
if (mine.some((a) => a.outcome === 'returned')) return { cash_out_id: String(id), status: 'returned', already: true };
|
|
368
|
+
if (outcome === 'paid' && mine.some((a) => a.outcome === 'paid')) return { cash_out_id: String(id), status: 'paid', already: true };
|
|
369
|
+
await insertAttempt(db, id, tryNo, { outcome, processorRef: ref, reason: outcome === 'returned' ? reason : null });
|
|
370
|
+
return { cash_out_id: String(id), status: outcome, already: false };
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// listMyCashOuts(builderId, { pool, account, processor, limit }) — the person's own
|
|
375
|
+
// withdrawals, newest first, each in plain words, with the minimum and their balance.
|
|
376
|
+
async function listMyCashOuts(builderId, { pool = defaultPool(), account = defaultAccount(), processor = null, limit = 20, log = null } = {}) {
|
|
377
|
+
const lim = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
|
|
378
|
+
const { rows } = await pool.query(`SELECT ${CASH_OUT_COLS} FROM cash_outs WHERE builder_id = $1 ORDER BY id DESC LIMIT $2`, [builderId, lim]);
|
|
379
|
+
const ids = rows.map((r) => String(r.id));
|
|
380
|
+
const byId = new Map(ids.map((id) => [id, []]));
|
|
381
|
+
if (ids.length) {
|
|
382
|
+
const { rows: tries } = await pool.query(
|
|
383
|
+
'SELECT id, cash_out_id, try_no, outcome, processor_ref, reason, created_at FROM cash_out_attempts WHERE cash_out_id = ANY($1::bigint[]) ORDER BY id', [ids]);
|
|
384
|
+
for (const t of tries) byId.get(String(t.cash_out_id)).push(t);
|
|
385
|
+
}
|
|
386
|
+
const from = account && typeof account.memberAccount === 'function' ? await account.memberAccount(pool, builderId) : null;
|
|
387
|
+
return {
|
|
388
|
+
unit: 'cents', holds_money: false, source: 'processor',
|
|
389
|
+
minimum_cents: MIN_CASH_OUT_CENTS, connected: Boolean(from),
|
|
390
|
+
balance_micro: await readBalance(processor, from, log),
|
|
391
|
+
cash_outs: rows.map((r) => present(r, byId.get(String(r.id)))),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const api = {
|
|
396
|
+
CashOutRefusal, HELD_WORDS, STATUS_WORDS, MIN_CASH_OUT_CENTS, idempotencyKey, standing,
|
|
397
|
+
requestCashOut, retryCashOut, cancelCashOut, recordProcessorOutcome, listMyCashOuts,
|
|
398
|
+
};
|
|
399
|
+
module.exports = api;
|