@firedrill-tools/trolley 0.1.1
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/LICENSE +201 -0
- package/README.md +219 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +2109 -0
- package/firedrill/conformance.suite.json +19 -0
- package/firedrill/partner-outage.scenario.json +11 -0
- package/firedrill/processing-response-lost.scenario.json +11 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tight-limits.scenario.json +16 -0
- package/firedrill/tools/trolley/app/assets/ATTRIBUTION.md +35 -0
- package/firedrill/tools/trolley/app/assets/cropped-Site-Icon-512x512-px-1-192x192.png +0 -0
- package/firedrill/tools/trolley/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/trolley/app/assets/full-logo.svg +1 -0
- package/firedrill/tools/trolley/app/assets/logo-black.svg +18 -0
- package/firedrill/tools/trolley/app/site/app.js +139 -0
- package/firedrill/tools/trolley/app/site/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/trolley/app/site/assets/trolley-icon.png +0 -0
- package/firedrill/tools/trolley/app/site/assets/trolley-logo.svg +1 -0
- package/firedrill/tools/trolley/app/site/base.css +99 -0
- package/firedrill/tools/trolley/app/site/batch-actions.js +139 -0
- package/firedrill/tools/trolley/app/site/components.css +149 -0
- package/firedrill/tools/trolley/app/site/icons.js +50 -0
- package/firedrill/tools/trolley/app/site/index.html +53 -0
- package/firedrill/tools/trolley/app/site/list.js +87 -0
- package/firedrill/tools/trolley/app/site/ui.js +181 -0
- package/firedrill/tools/trolley/app/site/view-balances.js +30 -0
- package/firedrill/tools/trolley/app/site/view-batch.js +86 -0
- package/firedrill/tools/trolley/app/site/view-batches.js +78 -0
- package/firedrill/tools/trolley/app/site/view-dashboard.js +79 -0
- package/firedrill/tools/trolley/app/site/view-methods.js +80 -0
- package/firedrill/tools/trolley/app/site/view-recipient.js +124 -0
- package/firedrill/tools/trolley/app/site/view-recipients.js +115 -0
- package/firedrill/tools/trolley/behavior.mjs +78 -0
- package/firedrill/tools/trolley/lib/access.mjs +21 -0
- package/firedrill/tools/trolley/lib/ids.mjs +28 -0
- package/firedrill/tools/trolley/lib/money.mjs +55 -0
- package/firedrill/tools/trolley/lib/paging.mjs +65 -0
- package/firedrill/tools/trolley/lib/pricing.mjs +88 -0
- package/firedrill/tools/trolley/lib/records.mjs +87 -0
- package/firedrill/tools/trolley/lib/serialize.mjs +152 -0
- package/firedrill/tools/trolley/lib/store.mjs +61 -0
- package/firedrill/tools/trolley/lib/time.mjs +60 -0
- package/firedrill/tools/trolley/lib/validate.mjs +111 -0
- package/firedrill/tools/trolley/lib/wire.mjs +145 -0
- package/firedrill/tools/trolley/ops/accounts-write.mjs +117 -0
- package/firedrill/tools/trolley/ops/accounts.mjs +129 -0
- package/firedrill/tools/trolley/ops/balances.mjs +23 -0
- package/firedrill/tools/trolley/ops/batches.mjs +139 -0
- package/firedrill/tools/trolley/ops/payment-build.mjs +79 -0
- package/firedrill/tools/trolley/ops/payments.mjs +112 -0
- package/firedrill/tools/trolley/ops/processing.mjs +104 -0
- package/firedrill/tools/trolley/ops/recipients-list.mjs +58 -0
- package/firedrill/tools/trolley/ops/recipients.mjs +127 -0
- package/firedrill/tools/trolley/trolley.tool.json +10095 -0
- package/firedrill/trolley-accounts.drill.json +205 -0
- package/firedrill/trolley-batch-lifecycle.drill.json +522 -0
- package/firedrill/trolley-denied.drill.json +74 -0
- package/firedrill/trolley-fresh-install.drill.json +68 -0
- package/firedrill/trolley-invalid-key.drill.json +743 -0
- package/firedrill/trolley-partner-outage.drill.json +97 -0
- package/firedrill/trolley-processing-rules.drill.json +138 -0
- package/firedrill/trolley-rate-limited.drill.json +118 -0
- package/firedrill/trolley-read-only.drill.json +468 -0
- package/firedrill/trolley-recipients.drill.json +183 -0
- package/firedrill/trolley-response-lost.drill.json +127 -0
- package/firedrill/trolley-tight-limits.drill.json +348 -0
- package/firedrill/world.json +2539 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +2108 -0
- package/test/conformance.mjs +12 -0
- package/test/flow-access.mjs +54 -0
- package/test/flow-accounts.mjs +63 -0
- package/test/flow-batches.mjs +107 -0
- package/test/flow-faults.mjs +58 -0
- package/test/flow-processing.mjs +41 -0
- package/test/flow-recipients.mjs +93 -0
- package/test/harness.mjs +87 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// POST /v1/batches/{id}/generate-quote and /start-processing. Every check runs before any write; processing then
|
|
2
|
+
// settles in the same invocation (each payment processed or failed, balance debited, batch complete or failed).
|
|
3
|
+
import { requireKey } from "../lib/access.mjs";
|
|
4
|
+
import { fromCents, toCents } from "../lib/money.mjs";
|
|
5
|
+
import { minimumCents, pricePayment } from "../lib/pricing.mjs";
|
|
6
|
+
import { accountsOf, batchOrFail, merchantId, paymentsOf } from "../lib/records.mjs";
|
|
7
|
+
import { batchOut, paymentOut, primaryAccount } from "../lib/serialize.mjs";
|
|
8
|
+
import { paymentRowId } from "../lib/store.mjs";
|
|
9
|
+
import { isoFromMs, msFromIso, nowIso, nowMs } from "../lib/time.mjs";
|
|
10
|
+
import { fieldError } from "../lib/validate.mjs";
|
|
11
|
+
|
|
12
|
+
const QUOTE_MS = 90 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
function currentAccount(context, payment) {
|
|
15
|
+
return primaryAccount(accountsOf(context, payment.recipientId));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function batchesGenerateQuote(input, context) {
|
|
19
|
+
requireKey(context, true);
|
|
20
|
+
const batch = batchOrFail(context, input.batchId);
|
|
21
|
+
if (batch.status !== "open") context.fail({ code: "INVALID_STATUS", message: "Quotes can only be generated for open batches" });
|
|
22
|
+
const now = nowIso(context);
|
|
23
|
+
const payments = [];
|
|
24
|
+
for (const payment of paymentsOf(context, batch.id)) {
|
|
25
|
+
const account = payment.status === "pending" ? currentAccount(context, payment) : null;
|
|
26
|
+
const next = account === null ? payment : { ...pricePayment(context, payment, account), updatedAt: now };
|
|
27
|
+
if (next !== payment) context.state.put("payments", paymentRowId(batch.id, next.id), next);
|
|
28
|
+
payments.push(next);
|
|
29
|
+
}
|
|
30
|
+
const quoted = { ...batch, quoteExpiredAt: isoFromMs(nowMs(context) + QUOTE_MS), updatedAt: now };
|
|
31
|
+
context.state.put("batches", batch.id, quoted);
|
|
32
|
+
return { ok: true, batch: batchOut(quoted, payments) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function event(context, model, action, key, value) {
|
|
36
|
+
context.events.emit(`${model}.${action}`, { model, action, body: { [key]: value } });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function batchesStartProcessing(input, context) {
|
|
40
|
+
requireKey(context, true);
|
|
41
|
+
const batch = batchOrFail(context, input.batchId);
|
|
42
|
+
if (batch.status !== "open") context.fail({ code: "INVALID_STATUS", message: "Batch is not open" });
|
|
43
|
+
const payments = paymentsOf(context, batch.id);
|
|
44
|
+
if (payments.length === 0) context.fail({ code: "INVALID_STATUS", message: "Batch has no payments" });
|
|
45
|
+
|
|
46
|
+
// Plan: which payments are attempted, with their payout account; which fail and why.
|
|
47
|
+
const plan = payments.map((payment) => {
|
|
48
|
+
const recipient = context.state.get("recipients", payment.recipientId);
|
|
49
|
+
if (payment.status !== "pending") return { payment, recipient, skip: true };
|
|
50
|
+
if (recipient === null || recipient.status !== "active") return { payment, recipient, failure: "Recipient is not active" };
|
|
51
|
+
const account = currentAccount(context, payment);
|
|
52
|
+
if (account === null) return { payment, recipient, failure: "Recipient has no primary payout method" };
|
|
53
|
+
const priced = account.id === payment.accountId && account.type === payment.payoutMethod ? payment : pricePayment(context, payment, account);
|
|
54
|
+
return { payment: priced, recipient };
|
|
55
|
+
});
|
|
56
|
+
const attempted = plan.filter((entry) => !entry.skip && entry.failure === undefined);
|
|
57
|
+
const nowAt = nowMs(context);
|
|
58
|
+
if (attempted.some((entry) => entry.payment.sourceCurrency !== entry.payment.targetCurrency)) {
|
|
59
|
+
const expires = msFromIso(batch.quoteExpiredAt);
|
|
60
|
+
if (expires === null || expires <= nowAt) context.fail({ code: "EXPIRED_QUOTE", message: "Quote is expired" });
|
|
61
|
+
}
|
|
62
|
+
let debit = 0n;
|
|
63
|
+
for (const { payment } of attempted) {
|
|
64
|
+
if (toCents(payment.sourceAmount) < minimumCents(context, payment.payoutMethod, payment.sourceCurrency)) {
|
|
65
|
+
fieldError(context, "INVALID_FIELD", "amount", "Payment amount is below the minimum for this payout method");
|
|
66
|
+
}
|
|
67
|
+
debit += toCents(payment.sourceAmount) + toCents(payment.merchantFees);
|
|
68
|
+
}
|
|
69
|
+
const balanceId = `paymentrails:${batch.currency}`;
|
|
70
|
+
const balance = context.state.get("balances", balanceId);
|
|
71
|
+
const available = balance === null ? 0n : toCents(balance.amount);
|
|
72
|
+
if (debit > available) context.fail({ code: "NON_SUFFICIENT_FUNDS", message: "Insufficient funds" });
|
|
73
|
+
|
|
74
|
+
// Settle.
|
|
75
|
+
const now = nowIso(context);
|
|
76
|
+
const merchant = merchantId(context);
|
|
77
|
+
const sending = { ...batch, status: "processing", sentAt: now, updatedAt: now };
|
|
78
|
+
event(context, "batch", "processing", "batch", batchOut(sending, plan.map((entry) => entry.payment)));
|
|
79
|
+
const settled = [];
|
|
80
|
+
let processed = 0;
|
|
81
|
+
for (const entry of plan) {
|
|
82
|
+
if (entry.skip) {
|
|
83
|
+
settled.push(entry.payment);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const ok = entry.failure === undefined;
|
|
87
|
+
const row = {
|
|
88
|
+
...entry.payment,
|
|
89
|
+
status: ok ? "processed" : "failed",
|
|
90
|
+
failureMessage: ok ? null : entry.failure,
|
|
91
|
+
initiatedAt: now,
|
|
92
|
+
processedAt: ok ? now : null,
|
|
93
|
+
updatedAt: now,
|
|
94
|
+
};
|
|
95
|
+
context.state.put("payments", paymentRowId(batch.id, row.id), row);
|
|
96
|
+
settled.push(row);
|
|
97
|
+
if (ok) processed += 1;
|
|
98
|
+
event(context, "payment", ok ? "processed" : "failed", "payment", paymentOut(row, entry.recipient, sending, merchant));
|
|
99
|
+
}
|
|
100
|
+
if (balance !== null && debit > 0n) context.state.put("balances", balanceId, { ...balance, amount: fromCents(available - debit) });
|
|
101
|
+
const final = { ...sending, status: processed > 0 ? "complete" : "failed", completedAt: now };
|
|
102
|
+
context.state.put("batches", batch.id, final);
|
|
103
|
+
return { ok: true, batch: batchOut(final, settled) };
|
|
104
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// GET /v1/recipients: search, filters, ordering and page-number paging over one bounded scan per namespace.
|
|
2
|
+
import { requireKey } from "../lib/access.mjs";
|
|
3
|
+
import { filterList, filterText, pageArgs, pageOf, sortArgs, sortItems } from "../lib/paging.mjs";
|
|
4
|
+
import { primaryAccount, recipientOut } from "../lib/serialize.mjs";
|
|
5
|
+
import { scanAll } from "../lib/store.mjs";
|
|
6
|
+
import { CURRENCIES, PAYOUT_METHODS, checkEnum, hasOwn, invalid } from "../lib/validate.mjs";
|
|
7
|
+
|
|
8
|
+
const STATUSES = ["active", "incomplete", "disabled", "archived", "suspended", "blocked"];
|
|
9
|
+
const COMPLIANCE = ["pending", "review", "verified", "blocked"];
|
|
10
|
+
const ORDER = ["name", "email", "referenceId", "payoutMethod", "createdAt", "updatedAt"];
|
|
11
|
+
|
|
12
|
+
const lower = (value) => (typeof value === "string" ? value.toLowerCase() : "");
|
|
13
|
+
|
|
14
|
+
export function recipientsList(input, context) {
|
|
15
|
+
requireKey(context, false);
|
|
16
|
+
const paging = pageArgs(context, input);
|
|
17
|
+
const search = filterText(context, input, "search");
|
|
18
|
+
const name = filterText(context, input, "name");
|
|
19
|
+
const email = filterText(context, input, "email", 254);
|
|
20
|
+
const referenceId = filterText(context, input, "referenceId", 100);
|
|
21
|
+
const status = hasOwn(input, "status") ? checkEnum(context, input.status, STATUSES, "status") : undefined;
|
|
22
|
+
const compliance = hasOwn(input, "complianceStatus") ? checkEnum(context, input.complianceStatus, COMPLIANCE, "complianceStatus") : undefined;
|
|
23
|
+
const countries = filterList(context, input, "country")?.map((code) => code.toUpperCase());
|
|
24
|
+
const method = hasOwn(input, "payoutMethod") ? checkEnum(context, input.payoutMethod, PAYOUT_METHODS, "payoutMethod") : undefined;
|
|
25
|
+
const currency = hasOwn(input, "currency") ? checkEnum(context, lower(input.currency).toUpperCase(), CURRENCIES, "currency") : undefined;
|
|
26
|
+
const tags = filterList(context, input, "tags");
|
|
27
|
+
const { orderBy, sortBy } = sortArgs(context, input, ORDER, "createdAt");
|
|
28
|
+
if (countries !== undefined && countries.some((code) => !/^[A-Z]{2}$/.test(code))) invalid(context, "country");
|
|
29
|
+
|
|
30
|
+
const byRecipient = new Map();
|
|
31
|
+
for (const account of scanAll(context, "recipient_accounts")) {
|
|
32
|
+
if (!byRecipient.has(account.recipientId)) byRecipient.set(account.recipientId, []);
|
|
33
|
+
byRecipient.get(account.recipientId).push(account);
|
|
34
|
+
}
|
|
35
|
+
const accountsFor = (id) => byRecipient.get(id) ?? [];
|
|
36
|
+
const needle = search === undefined ? undefined : search.toLowerCase();
|
|
37
|
+
const nameNeedle = name === undefined ? undefined : name.toLowerCase();
|
|
38
|
+
|
|
39
|
+
const matched = scanAll(context, "recipients").filter((row) => {
|
|
40
|
+
if (status === undefined ? row.status === "archived" : row.status !== status) return false;
|
|
41
|
+
if (compliance !== undefined && row.complianceStatus !== compliance) return false;
|
|
42
|
+
if (needle !== undefined && ![row.name, row.email, row.referenceId].some((field) => lower(field).includes(needle))) return false;
|
|
43
|
+
if (nameNeedle !== undefined && ![row.name, row.firstName, row.lastName].some((field) => lower(field).includes(nameNeedle))) return false;
|
|
44
|
+
if (email !== undefined && lower(row.email) !== email.toLowerCase()) return false;
|
|
45
|
+
if (referenceId !== undefined && row.referenceId !== referenceId) return false;
|
|
46
|
+
if (countries !== undefined && !countries.includes(row.address?.country)) return false;
|
|
47
|
+
if (tags !== undefined && !tags.every((tag) => (row.tags ?? []).includes(tag))) return false;
|
|
48
|
+
if (method !== undefined || currency !== undefined) {
|
|
49
|
+
const primary = primaryAccount(accountsFor(row.id));
|
|
50
|
+
if (method !== undefined && primary?.type !== method) return false;
|
|
51
|
+
if (currency !== undefined && primary?.currency !== currency) return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
55
|
+
const key = orderBy === "payoutMethod" ? (row) => primaryAccount(accountsFor(row.id))?.type ?? null : (row) => row[orderBy] ?? null;
|
|
56
|
+
sortItems(matched, key, sortBy);
|
|
57
|
+
return pageOf(context, matched, paging, "recipients", (row) => recipientOut(context, row, accountsFor(row.id)));
|
|
58
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Recipients: create, get, update, delete (archive). Status is derived on every write (lib/serialize.mjs).
|
|
2
|
+
import { requireKey } from "../lib/access.mjs";
|
|
3
|
+
import { nextId } from "../lib/ids.mjs";
|
|
4
|
+
import { accountsOf, assertUnique, hasPendingOpenPayment, recipientOrFail, refreshRecipient } from "../lib/records.mjs";
|
|
5
|
+
import { deriveStatus, primaryAccount, recipientOut } from "../lib/serialize.mjs";
|
|
6
|
+
import { isDate, nowIso } from "../lib/time.mjs";
|
|
7
|
+
import { checkCountry, checkEmail, checkEnum, checkTags, checkText, empty, hasOwn, invalid, optionalText, rejectReservedKeys, requireText } from "../lib/validate.mjs";
|
|
8
|
+
import { attachAccount, buildAccount } from "./accounts.mjs";
|
|
9
|
+
|
|
10
|
+
const ADDRESS_KEYS = ["street1", "street2", "city", "region", "postalCode", "country", "phone"];
|
|
11
|
+
|
|
12
|
+
function checkAddress(context, value, base) {
|
|
13
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) invalid(context, "address");
|
|
14
|
+
const out = { ...base };
|
|
15
|
+
for (const key of Object.keys(value)) {
|
|
16
|
+
if (!ADDRESS_KEYS.includes(key)) invalid(context, `address.${key}`, "Field is not allowed");
|
|
17
|
+
const entry = value[key];
|
|
18
|
+
if (entry === null || entry === "") out[key] = null;
|
|
19
|
+
else out[key] = key === "country" ? checkCountry(context, entry, "address.country") : checkText(context, entry, `address.${key}`, 200);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const EMPTY_ADDRESS = { street1: null, street2: null, city: null, region: null, postalCode: null, country: null, phone: null };
|
|
25
|
+
|
|
26
|
+
/** Optional profile fields shared by create and update, validated into `row`. */
|
|
27
|
+
function applyProfile(context, input, row) {
|
|
28
|
+
const referenceId = optionalText(context, input, "referenceId", 100);
|
|
29
|
+
if (referenceId !== undefined) row.referenceId = referenceId === "" ? null : referenceId;
|
|
30
|
+
if (hasOwn(input, "dob")) {
|
|
31
|
+
if (input.dob !== null && input.dob !== "" && !isDate(input.dob)) invalid(context, "dob", "Date of birth must be YYYY-MM-DD");
|
|
32
|
+
row.dob = input.dob === "" ? null : input.dob;
|
|
33
|
+
}
|
|
34
|
+
const phone = optionalText(context, input, "phone", 32);
|
|
35
|
+
if (phone !== undefined) row.phone = phone === "" ? null : phone;
|
|
36
|
+
const language = optionalText(context, input, "language", 10);
|
|
37
|
+
if (language !== undefined) row.language = language === null || language === "" ? "en" : language;
|
|
38
|
+
if (hasOwn(input, "tags")) row.tags = checkTags(context, input.tags);
|
|
39
|
+
if (hasOwn(input, "address")) row.address = checkAddress(context, input.address, row.address);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function applyNames(context, input, row) {
|
|
43
|
+
if (row.type === "individual") {
|
|
44
|
+
for (const key of ["firstName", "lastName"]) {
|
|
45
|
+
if (hasOwn(input, key) || row[key] === null) row[key] = requireText(context, { [key]: hasOwn(input, key) ? input[key] : row[key] }, key, 100);
|
|
46
|
+
}
|
|
47
|
+
row.name = hasOwn(input, "name") ? requireText(context, input, "name", 200) : `${row.firstName} ${row.lastName}`;
|
|
48
|
+
} else {
|
|
49
|
+
if (hasOwn(input, "name") || row.name === null) row.name = requireText(context, { name: hasOwn(input, "name") ? input.name : row.name }, "name", 200);
|
|
50
|
+
for (const key of ["firstName", "lastName"]) {
|
|
51
|
+
const value = optionalText(context, input, key, 100);
|
|
52
|
+
if (value !== undefined) row[key] = value === "" ? null : value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function recipientsCreate(input, context) {
|
|
58
|
+
requireKey(context, true);
|
|
59
|
+
rejectReservedKeys(context, input);
|
|
60
|
+
const type = requireText(context, input, "type", 20);
|
|
61
|
+
checkEnum(context, type, ["individual", "business"], "type");
|
|
62
|
+
const email = checkEmail(context, hasOwn(input, "email") ? input.email : undefined);
|
|
63
|
+
const now = nowIso(context);
|
|
64
|
+
const row = {
|
|
65
|
+
id: "", referenceId: null, email, type, firstName: null, lastName: null, name: null, status: "incomplete",
|
|
66
|
+
complianceStatus: "pending", holdStatus: "none", dob: null, phone: null, language: "en", tags: [],
|
|
67
|
+
address: { ...EMPTY_ADDRESS }, createdAt: now, updatedAt: now,
|
|
68
|
+
};
|
|
69
|
+
applyNames(context, input, row);
|
|
70
|
+
applyProfile(context, input, row);
|
|
71
|
+
if (hasOwn(input, "accounts") && (!Array.isArray(input.accounts) || input.accounts.length > 5)) invalid(context, "accounts");
|
|
72
|
+
assertUnique(context, { email, referenceId: row.referenceId }, null);
|
|
73
|
+
row.id = nextId(context, "R");
|
|
74
|
+
const accounts = [];
|
|
75
|
+
for (const [index, account] of (input.accounts ?? []).entries()) {
|
|
76
|
+
accounts.push(attachAccount(context, buildAccount(context, row, account, `accounts[${index}].`, now), accounts));
|
|
77
|
+
}
|
|
78
|
+
row.status = deriveStatus(row, primaryAccount(accounts) !== null);
|
|
79
|
+
context.state.put("recipients", row.id, row);
|
|
80
|
+
return { ok: true, recipient: recipientOut(context, row, accountsOf(context, row.id)) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function recipientsGet(input, context) {
|
|
84
|
+
requireKey(context, false);
|
|
85
|
+
const row = recipientOrFail(context, input.recipientId);
|
|
86
|
+
return { ok: true, recipient: recipientOut(context, row, accountsOf(context, row.id)) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function recipientsUpdate(input, context) {
|
|
90
|
+
requireKey(context, true);
|
|
91
|
+
rejectReservedKeys(context, input);
|
|
92
|
+
const current = recipientOrFail(context, input.recipientId);
|
|
93
|
+
const { recipientId, ...changes } = input;
|
|
94
|
+
if (Object.keys(changes).length === 0) empty(context, "body");
|
|
95
|
+
const now = nowIso(context);
|
|
96
|
+
const row = { ...current, address: { ...EMPTY_ADDRESS, ...current.address }, tags: [...(current.tags ?? [])] };
|
|
97
|
+
if (hasOwn(changes, "type") && changes.type !== current.type) invalid(context, "type", "Recipient type cannot be changed");
|
|
98
|
+
if (hasOwn(changes, "email")) row.email = checkEmail(context, changes.email);
|
|
99
|
+
applyNames(context, changes, row);
|
|
100
|
+
applyProfile(context, changes, row);
|
|
101
|
+
if (hasOwn(changes, "status")) {
|
|
102
|
+
if (current.status !== "archived") context.fail({ code: "INVALID_STATUS", message: "Only an archived recipient can be restored" });
|
|
103
|
+
if (current.holdStatus !== "none") context.fail({ code: "INVALID_STATUS", message: "Recipient is on hold and cannot be restored" });
|
|
104
|
+
row.status = "incomplete";
|
|
105
|
+
}
|
|
106
|
+
if (hasOwn(changes, "email") || hasOwn(changes, "referenceId") || hasOwn(changes, "status")) {
|
|
107
|
+
assertUnique(context, { email: row.email, referenceId: row.referenceId }, row.id);
|
|
108
|
+
}
|
|
109
|
+
const accounts = accountsOf(context, row.id);
|
|
110
|
+
row.status = deriveStatus(row, primaryAccount(accounts) !== null);
|
|
111
|
+
row.updatedAt = now;
|
|
112
|
+
context.state.put("recipients", row.id, row);
|
|
113
|
+
return { ok: true, recipient: recipientOut(context, row, accounts) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function recipientsDelete(input, context) {
|
|
117
|
+
requireKey(context, true);
|
|
118
|
+
const row = recipientOrFail(context, input.recipientId);
|
|
119
|
+
if (row.status === "archived") return { ok: true };
|
|
120
|
+
if (hasPendingOpenPayment(context, (payment) => payment.recipientId === row.id)) {
|
|
121
|
+
context.fail({ code: "INVALID_STATUS", message: "Recipient has pending payments" });
|
|
122
|
+
}
|
|
123
|
+
context.state.put("recipients", row.id, { ...row, status: "archived", updatedAt: nowIso(context) });
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { refreshRecipient };
|