@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,117 @@
|
|
|
1
|
+
// PATCH and DELETE /v1/recipients/{recipientId}/accounts/{accountId}.
|
|
2
|
+
import { requireKey } from "../lib/access.mjs";
|
|
3
|
+
import { accountOrFail, accountsOf, hasPendingOpenPayment, recipientOrFail, refreshRecipient } from "../lib/records.mjs";
|
|
4
|
+
import { accountOut } from "../lib/serialize.mjs";
|
|
5
|
+
import { accountRowId } from "../lib/store.mjs";
|
|
6
|
+
import { nowIso } from "../lib/time.mjs";
|
|
7
|
+
import { checkCountry, checkCurrency, checkDigits, checkEmail, checkText, empty, hasOwn, invalid, rejectReservedKeys, requireText } from "../lib/validate.mjs";
|
|
8
|
+
|
|
9
|
+
const IMMUTABLE = {
|
|
10
|
+
type: "Create a new account to change the payout method",
|
|
11
|
+
country: "Create a new account to change the bank country",
|
|
12
|
+
accountNum: "Create a new account to change the account number",
|
|
13
|
+
iban: "Create a new account to change the account number",
|
|
14
|
+
};
|
|
15
|
+
const BRANCH_DIGITS = { US: 9, CA: 5, GB: 6, AU: 6 };
|
|
16
|
+
|
|
17
|
+
function notApplicable(context, field) {
|
|
18
|
+
invalid(context, field, "Field does not apply to this payout method");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function applyChanges(context, account, changes) {
|
|
22
|
+
const next = { ...account };
|
|
23
|
+
const bank = account.type === "bank-transfer";
|
|
24
|
+
for (const key of Object.keys(changes)) {
|
|
25
|
+
if (Object.hasOwn(IMMUTABLE, key)) invalid(context, key, IMMUTABLE[key]);
|
|
26
|
+
}
|
|
27
|
+
if (hasOwn(changes, "primary") && changes.primary !== true) invalid(context, "primary", "Set another account as primary instead");
|
|
28
|
+
if (hasOwn(changes, "accountHolderName")) {
|
|
29
|
+
if (!bank) notApplicable(context, "accountHolderName");
|
|
30
|
+
next.accountHolderName = requireText(context, changes, "accountHolderName", 200);
|
|
31
|
+
}
|
|
32
|
+
if (hasOwn(changes, "currency")) {
|
|
33
|
+
const currency = checkCurrency(context, changes.currency, "currency");
|
|
34
|
+
if (!bank && account.type !== "paypal" && currency !== "USD") invalid(context, "currency", "This payout method pays in USD");
|
|
35
|
+
next.currency = currency;
|
|
36
|
+
}
|
|
37
|
+
if (hasOwn(changes, "emailAddress")) {
|
|
38
|
+
if (account.type !== "paypal") notApplicable(context, "emailAddress");
|
|
39
|
+
next.emailAddress = checkEmail(context, changes.emailAddress, "emailAddress");
|
|
40
|
+
}
|
|
41
|
+
if (hasOwn(changes, "phoneNumber")) {
|
|
42
|
+
if (account.type !== "venmo") notApplicable(context, "phoneNumber");
|
|
43
|
+
const raw = typeof changes.phoneNumber === "string" ? changes.phoneNumber.replace(/[-() +]/g, "") : changes.phoneNumber;
|
|
44
|
+
next.phoneNumber = checkDigits(context, raw, "phoneNumber", 10, 11);
|
|
45
|
+
}
|
|
46
|
+
if (hasOwn(changes, "mailing")) {
|
|
47
|
+
if (account.type !== "check") notApplicable(context, "mailing");
|
|
48
|
+
const value = changes.mailing;
|
|
49
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) empty(context, "mailing");
|
|
50
|
+
const mailing = { ...account.mailing };
|
|
51
|
+
for (const key of Object.keys(value)) {
|
|
52
|
+
if (!["name", "street1", "street2", "city", "region", "postal", "country"].includes(key)) invalid(context, `mailing.${key}`, "Field is not allowed");
|
|
53
|
+
if (key === "street2") mailing.street2 = value.street2 === null || value.street2 === "" ? null : checkText(context, value.street2, "mailing.street2", 200);
|
|
54
|
+
else mailing[key] = requireText(context, value, key, 200, `mailing.${key}`);
|
|
55
|
+
}
|
|
56
|
+
if (checkCountry(context, mailing.country, "mailing.country") !== "US") invalid(context, "mailing.country", "Checks can only be mailed within the US");
|
|
57
|
+
mailing.country = "US";
|
|
58
|
+
next.mailing = mailing;
|
|
59
|
+
}
|
|
60
|
+
if (hasOwn(changes, "branchId")) {
|
|
61
|
+
if (!bank || !Object.hasOwn(BRANCH_DIGITS, account.country)) notApplicable(context, "branchId");
|
|
62
|
+
const n = BRANCH_DIGITS[account.country];
|
|
63
|
+
next.branchId = checkDigits(context, changes.branchId, "branchId", n, n);
|
|
64
|
+
}
|
|
65
|
+
if (hasOwn(changes, "bankId")) {
|
|
66
|
+
if (!bank || account.country !== "CA") notApplicable(context, "bankId");
|
|
67
|
+
next.bankId = checkDigits(context, changes.bankId, "bankId", 3, 3);
|
|
68
|
+
}
|
|
69
|
+
if (hasOwn(changes, "swiftBic")) {
|
|
70
|
+
if (!bank) notApplicable(context, "swiftBic");
|
|
71
|
+
if (changes.swiftBic === null || changes.swiftBic === "") next.swiftBic = null;
|
|
72
|
+
else {
|
|
73
|
+
const swift = checkText(context, changes.swiftBic, "swiftBic", 11).toUpperCase();
|
|
74
|
+
if (!/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(swift)) invalid(context, "swiftBic", "SWIFT/BIC is invalid");
|
|
75
|
+
next.swiftBic = swift;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return next;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function accountsUpdate(input, context) {
|
|
82
|
+
requireKey(context, true);
|
|
83
|
+
rejectReservedKeys(context, input);
|
|
84
|
+
const recipient = recipientOrFail(context, input.recipientId);
|
|
85
|
+
const account = accountOrFail(context, recipient.id, input.accountId);
|
|
86
|
+
const { recipientId, accountId, ...changes } = input;
|
|
87
|
+
if (Object.keys(changes).length === 0) empty(context, "body");
|
|
88
|
+
if (account.status === "disabled") context.fail({ code: "INVALID_STATUS", message: "Account is disabled" });
|
|
89
|
+
const now = nowIso(context);
|
|
90
|
+
const next = { ...applyChanges(context, account, changes), updatedAt: now };
|
|
91
|
+
const siblings = accountsOf(context, recipient.id);
|
|
92
|
+
if (changes.primary === true && !account.primary) {
|
|
93
|
+
for (const sibling of siblings) {
|
|
94
|
+
if (sibling.id === account.id || !sibling.primary) continue;
|
|
95
|
+
context.state.put("recipient_accounts", accountRowId(recipient.id, sibling.id), { ...sibling, primary: false, status: "active", updatedAt: now });
|
|
96
|
+
}
|
|
97
|
+
next.primary = true;
|
|
98
|
+
next.status = "primary";
|
|
99
|
+
}
|
|
100
|
+
context.state.put("recipient_accounts", accountRowId(recipient.id, account.id), next);
|
|
101
|
+
refreshRecipient(context, recipient, accountsOf(context, recipient.id), now);
|
|
102
|
+
return { ok: true, account: accountOut(next) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function accountsDelete(input, context) {
|
|
106
|
+
requireKey(context, true);
|
|
107
|
+
const recipient = recipientOrFail(context, input.recipientId);
|
|
108
|
+
const account = accountOrFail(context, recipient.id, input.accountId);
|
|
109
|
+
if (account.status === "disabled") context.fail({ code: "INVALID_STATUS", message: "Account is already disabled" });
|
|
110
|
+
if (hasPendingOpenPayment(context, (payment) => payment.accountId === account.id)) {
|
|
111
|
+
context.fail({ code: "INVALID_STATUS", message: "Account has pending payments" });
|
|
112
|
+
}
|
|
113
|
+
const now = nowIso(context);
|
|
114
|
+
context.state.put("recipient_accounts", accountRowId(recipient.id, account.id), { ...account, primary: false, status: "disabled", disabledAt: now, updatedAt: now });
|
|
115
|
+
refreshRecipient(context, recipient, accountsOf(context, recipient.id), now);
|
|
116
|
+
return { ok: true };
|
|
117
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Recipient accounts (payout methods): bank-transfer, paypal, check, venmo. Full account numbers are never stored.
|
|
2
|
+
import { requireKey } from "../lib/access.mjs";
|
|
3
|
+
import { nextId } from "../lib/ids.mjs";
|
|
4
|
+
import { accountOrFail, accountsOf, hasPendingOpenPayment, recipientOrFail, refreshRecipient } from "../lib/records.mjs";
|
|
5
|
+
import { accountOut, activeAccounts } from "../lib/serialize.mjs";
|
|
6
|
+
import { accountRowId } from "../lib/store.mjs";
|
|
7
|
+
import { nowIso } from "../lib/time.mjs";
|
|
8
|
+
import { IBAN_COUNTRIES, checkCountry, checkCurrency, checkDigits, checkEmail, checkText, empty, fieldError, hasOwn, invalid, requireText } from "../lib/validate.mjs";
|
|
9
|
+
|
|
10
|
+
const MAX_ACCOUNTS = 20;
|
|
11
|
+
const BRANCH_DIGITS = { US: 9, CA: 5, GB: 6, AU: 6 };
|
|
12
|
+
|
|
13
|
+
function bankName(country) {
|
|
14
|
+
return `Firedrill Test Bank ${country}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function checkSwift(context, value, field) {
|
|
18
|
+
const text = checkText(context, value, field, 11).toUpperCase();
|
|
19
|
+
if (!/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(text)) invalid(context, field, "SWIFT/BIC is invalid");
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function checkMailing(context, value, field) {
|
|
24
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) empty(context, field);
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const key of ["name", "street1", "city", "region", "postal", "country"]) out[key] = requireText(context, value, key, 200, `${field}.${key}`);
|
|
27
|
+
out.street2 = hasOwn(value, "street2") && value.street2 !== null ? checkText(context, value.street2, `${field}.street2`, 200) : null;
|
|
28
|
+
out.country = checkCountry(context, out.country, `${field}.country`);
|
|
29
|
+
if (out.country !== "US") invalid(context, `${field}.country`, "Checks can only be mailed within the US");
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function blank(recipientId, id, type, now) {
|
|
34
|
+
return {
|
|
35
|
+
id, recipientId, type, primary: false, status: "active", currency: "USD", country: "US", accountHolderName: null,
|
|
36
|
+
accountNumLast4: null, ibanLast4: null, bankId: null, branchId: null, swiftBic: null, bankName: null,
|
|
37
|
+
emailAddress: null, phoneNumber: null, mailing: null, disabledAt: null, createdAt: now, updatedAt: now,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Validates a create payload and returns an unsaved account row (ids allocated). `p` prefixes field names. */
|
|
42
|
+
export function buildAccount(context, recipient, input, p, now) {
|
|
43
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) invalid(context, p.replace(/\.$/, "") || "accounts");
|
|
44
|
+
const type = requireText(context, input, "type", 40, `${p}type`);
|
|
45
|
+
if (type === "debit-card" || type === "mobile-wallet") invalid(context, `${p}type`, "This payout method cannot be created via the API");
|
|
46
|
+
if (!["bank-transfer", "paypal", "check", "venmo"].includes(type)) invalid(context, `${p}type`, "Payout method is not supported");
|
|
47
|
+
const fallbackCountry = typeof recipient.address?.country === "string" ? recipient.address.country : "US";
|
|
48
|
+
const row = blank(recipient.id, nextId(context, "A"), type, now);
|
|
49
|
+
if (type === "bank-transfer") {
|
|
50
|
+
row.currency = checkCurrency(context, input.currency, `${p}currency`);
|
|
51
|
+
row.country = checkCountry(context, input.country, `${p}country`);
|
|
52
|
+
row.accountHolderName = requireText(context, input, "accountHolderName", 200, `${p}accountHolderName`);
|
|
53
|
+
const usesIban = hasOwn(input, "iban") && input.iban !== null;
|
|
54
|
+
if (usesIban) {
|
|
55
|
+
const iban = checkText(context, input.iban, `${p}iban`, 42).replace(/ /g, "").toUpperCase();
|
|
56
|
+
if (!IBAN_COUNTRIES.includes(row.country) || !/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/.test(iban) || iban.slice(0, 2) !== row.country) invalid(context, `${p}iban`, "IBAN is invalid");
|
|
57
|
+
row.ibanLast4 = iban.slice(-4);
|
|
58
|
+
} else {
|
|
59
|
+
if (!hasOwn(input, "accountNum")) empty(context, IBAN_COUNTRIES.includes(row.country) && row.country !== "GB" ? `${p}iban` : `${p}accountNum`);
|
|
60
|
+
row.accountNumLast4 = checkDigits(context, input.accountNum, `${p}accountNum`, 4, 34).slice(-4);
|
|
61
|
+
if (Object.hasOwn(BRANCH_DIGITS, row.country)) {
|
|
62
|
+
if (!hasOwn(input, "branchId")) empty(context, `${p}branchId`);
|
|
63
|
+
const n = BRANCH_DIGITS[row.country];
|
|
64
|
+
row.branchId = checkDigits(context, input.branchId, `${p}branchId`, n, n);
|
|
65
|
+
}
|
|
66
|
+
if (row.country === "CA") {
|
|
67
|
+
if (!hasOwn(input, "bankId")) empty(context, `${p}bankId`);
|
|
68
|
+
row.bankId = checkDigits(context, input.bankId, `${p}bankId`, 3, 3);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (hasOwn(input, "swiftBic") && input.swiftBic !== null) row.swiftBic = checkSwift(context, input.swiftBic, `${p}swiftBic`);
|
|
72
|
+
row.bankName = bankName(row.country);
|
|
73
|
+
} else if (type === "paypal") {
|
|
74
|
+
row.emailAddress = checkEmail(context, input.emailAddress, `${p}emailAddress`);
|
|
75
|
+
row.currency = hasOwn(input, "currency") ? checkCurrency(context, input.currency, `${p}currency`) : "USD";
|
|
76
|
+
row.country = hasOwn(input, "country") ? checkCountry(context, input.country, `${p}country`) : fallbackCountry;
|
|
77
|
+
} else if (type === "venmo") {
|
|
78
|
+
if (hasOwn(input, "country") && checkCountry(context, input.country, `${p}country`) !== "US") invalid(context, `${p}country`, "Venmo is only available in the US");
|
|
79
|
+
if (hasOwn(input, "currency") && checkCurrency(context, input.currency, `${p}currency`) !== "USD") invalid(context, `${p}currency`, "Venmo pays in USD");
|
|
80
|
+
row.phoneNumber = checkDigits(context, typeof input.phoneNumber === "string" ? input.phoneNumber.replace(/[-() +]/g, "") : input.phoneNumber, `${p}phoneNumber`, 10, 11);
|
|
81
|
+
} else {
|
|
82
|
+
if (hasOwn(input, "currency") && checkCurrency(context, input.currency, `${p}currency`) !== "USD") invalid(context, `${p}currency`, "Checks are paid in USD");
|
|
83
|
+
row.mailing = checkMailing(context, input.mailing, `${p}mailing`);
|
|
84
|
+
}
|
|
85
|
+
if (hasOwn(input, "primary") && typeof input.primary !== "boolean") invalid(context, `${p}primary`);
|
|
86
|
+
row.primary = input.primary === true;
|
|
87
|
+
return row;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Stores `row`, making it primary when requested or when no active primary exists, demoting siblings. */
|
|
91
|
+
export function attachAccount(context, row, existing) {
|
|
92
|
+
const active = existing.filter((account) => account.status !== "disabled");
|
|
93
|
+
if (active.length >= MAX_ACCOUNTS) invalid(context, "type", "Recipient has the maximum number of payout methods");
|
|
94
|
+
const makePrimary = row.primary || !active.some((account) => account.primary);
|
|
95
|
+
if (makePrimary) {
|
|
96
|
+
for (const sibling of active.filter((account) => account.primary)) {
|
|
97
|
+
context.state.put("recipient_accounts", accountRowId(sibling.recipientId, sibling.id), { ...sibling, primary: false, status: "active", updatedAt: row.updatedAt });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const stored = { ...row, primary: makePrimary, status: makePrimary ? "primary" : "active" };
|
|
101
|
+
context.state.put("recipient_accounts", accountRowId(row.recipientId, row.id), stored);
|
|
102
|
+
return stored;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function accountsCreate(input, context) {
|
|
106
|
+
requireKey(context, true);
|
|
107
|
+
const recipient = recipientOrFail(context, input.recipientId);
|
|
108
|
+
if (recipient.status === "archived") context.fail({ code: "INVALID_STATUS", message: "Recipient is archived" });
|
|
109
|
+
const now = nowIso(context);
|
|
110
|
+
const existing = accountsOf(context, recipient.id);
|
|
111
|
+
const { recipientId, ...payload } = input;
|
|
112
|
+
const stored = attachAccount(context, buildAccount(context, recipient, payload, "", now), existing);
|
|
113
|
+
refreshRecipient(context, recipient, accountsOf(context, recipient.id), now);
|
|
114
|
+
return { ok: true, account: accountOut(stored) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function accountsList(input, context) {
|
|
118
|
+
requireKey(context, false);
|
|
119
|
+
const recipient = recipientOrFail(context, input.recipientId);
|
|
120
|
+
return { ok: true, accounts: activeAccounts(accountsOf(context, recipient.id)).map(accountOut) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function accountsGet(input, context) {
|
|
124
|
+
requireKey(context, false);
|
|
125
|
+
recipientOrFail(context, input.recipientId);
|
|
126
|
+
return { ok: true, account: accountOut(accountOrFail(context, input.recipientId, input.accountId)) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { fieldError };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// GET /v1/balances and /v1/balances/{kind}. `serverTime` (virtual time) is a Firedrill addition for the app.
|
|
2
|
+
import { requireKey } from "../lib/access.mjs";
|
|
3
|
+
import { scanAll } from "../lib/store.mjs";
|
|
4
|
+
import { nowIso } from "../lib/time.mjs";
|
|
5
|
+
import { checkEnum, hasOwn } from "../lib/validate.mjs";
|
|
6
|
+
|
|
7
|
+
export function balancesList(input, context) {
|
|
8
|
+
requireKey(context, false);
|
|
9
|
+
const kind = hasOwn(input, "kind") ? checkEnum(context, input.kind, ["paymentrails", "paypal"], "kind") : undefined;
|
|
10
|
+
const balances = scanAll(context, "balances")
|
|
11
|
+
.filter((row) => kind === undefined || row.type === kind)
|
|
12
|
+
.sort((a, b) => (a.primary !== b.primary ? (a.primary ? -1 : 1) : a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : a.type < b.type ? -1 : 1))
|
|
13
|
+
.map((row) => ({
|
|
14
|
+
primary: row.primary,
|
|
15
|
+
amount: row.amount,
|
|
16
|
+
currency: row.currency,
|
|
17
|
+
type: row.type,
|
|
18
|
+
accountNumber: row.accountNumber,
|
|
19
|
+
display: row.display,
|
|
20
|
+
pendingAmount: row.pendingAmount,
|
|
21
|
+
}));
|
|
22
|
+
return { ok: true, balances, serverTime: nowIso(context) };
|
|
23
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Batches: create (optionally with payments), get, update, delete, list, summary.
|
|
2
|
+
import { merchant, requireKey } from "../lib/access.mjs";
|
|
3
|
+
import { nextId } from "../lib/ids.mjs";
|
|
4
|
+
import { fromCents, toCents } from "../lib/money.mjs";
|
|
5
|
+
import { filterList, filterText, pageArgs, pageOf, sortArgs, sortItems } from "../lib/paging.mjs";
|
|
6
|
+
import { batchOrFail, paymentsOf } from "../lib/records.mjs";
|
|
7
|
+
import { batchOut } from "../lib/serialize.mjs";
|
|
8
|
+
import { paymentRowId, scanAll } from "../lib/store.mjs";
|
|
9
|
+
import { nowIso } from "../lib/time.mjs";
|
|
10
|
+
import { CURRENCIES, checkCurrency, checkEnum, checkTags, checkText, hasOwn, invalid, rejectReservedKeys } from "../lib/validate.mjs";
|
|
11
|
+
import { MAX_PAYMENTS, createPayment } from "./payment-build.mjs";
|
|
12
|
+
|
|
13
|
+
const STATUSES = ["open", "processing", "complete", "failed"];
|
|
14
|
+
|
|
15
|
+
export function batchesCreate(input, context) {
|
|
16
|
+
requireKey(context, true);
|
|
17
|
+
rejectReservedKeys(context, input);
|
|
18
|
+
const payments = hasOwn(input, "payments") ? input.payments : [];
|
|
19
|
+
if (!Array.isArray(payments)) invalid(context, "payments");
|
|
20
|
+
if (payments.length > MAX_PAYMENTS) invalid(context, "payments", `A batch can hold at most ${MAX_PAYMENTS} payments`);
|
|
21
|
+
const first = payments[0] !== null && typeof payments[0] === "object" ? payments[0] : {};
|
|
22
|
+
const currencySource = hasOwn(input, "currency") ? ["currency", input.currency]
|
|
23
|
+
: hasOwn(first, "sourceCurrency") ? ["payments[0].sourceCurrency", first.sourceCurrency]
|
|
24
|
+
: hasOwn(first, "currency") && hasOwn(first, "amount") ? ["payments[0].currency", first.currency] : undefined;
|
|
25
|
+
const currency = currencySource === undefined ? merchant(context).defaultCurrency : checkCurrency(context, currencySource[1], currencySource[0]);
|
|
26
|
+
const now = nowIso(context);
|
|
27
|
+
const batch = {
|
|
28
|
+
id: nextId(context, "B"), status: "open", currency,
|
|
29
|
+
description: hasOwn(input, "description") && input.description !== null ? checkText(context, input.description, "description", 1024) : "",
|
|
30
|
+
tags: hasOwn(input, "tags") ? checkTags(context, input.tags) : [],
|
|
31
|
+
quoteExpiredAt: null, sentAt: null, completedAt: null, createdAt: now, updatedAt: now,
|
|
32
|
+
};
|
|
33
|
+
context.state.put("batches", batch.id, batch);
|
|
34
|
+
const rows = [];
|
|
35
|
+
for (const [index, payment] of payments.entries()) {
|
|
36
|
+
rows.push(createPayment(context, batch, payment, `payments[${index}].`, now, rows).row);
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, batch: batchOut(batch, rows) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function batchesGet(input, context) {
|
|
42
|
+
requireKey(context, false);
|
|
43
|
+
const batch = batchOrFail(context, input.batchId);
|
|
44
|
+
return { ok: true, batch: batchOut(batch, paymentsOf(context, batch.id)) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function batchesUpdate(input, context) {
|
|
48
|
+
requireKey(context, true);
|
|
49
|
+
rejectReservedKeys(context, input);
|
|
50
|
+
const batch = batchOrFail(context, input.batchId);
|
|
51
|
+
for (const key of ["status", "amount", "totalPayments", "quoteExpiredAt", "sentAt", "completedAt"]) {
|
|
52
|
+
if (hasOwn(input, key)) invalid(context, key, "Field is read-only");
|
|
53
|
+
}
|
|
54
|
+
if (batch.status !== "open") context.fail({ code: "INVALID_STATUS", message: "Batch is not open" });
|
|
55
|
+
const payments = paymentsOf(context, batch.id);
|
|
56
|
+
const next = { ...batch, updatedAt: nowIso(context) };
|
|
57
|
+
if (hasOwn(input, "description")) next.description = input.description === null ? "" : checkText(context, input.description, "description", 1024);
|
|
58
|
+
if (hasOwn(input, "tags")) next.tags = checkTags(context, input.tags);
|
|
59
|
+
if (hasOwn(input, "currency")) {
|
|
60
|
+
const currency = checkCurrency(context, input.currency, "currency");
|
|
61
|
+
if (currency !== batch.currency && payments.length > 0) invalid(context, "currency", "Currency cannot change once the batch has payments");
|
|
62
|
+
if (currency !== batch.currency) next.quoteExpiredAt = null;
|
|
63
|
+
next.currency = currency;
|
|
64
|
+
}
|
|
65
|
+
context.state.put("batches", batch.id, next);
|
|
66
|
+
return { ok: true, batch: batchOut(next, payments) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function batchesDelete(input, context) {
|
|
70
|
+
requireKey(context, true);
|
|
71
|
+
const batch = batchOrFail(context, input.batchId);
|
|
72
|
+
if (batch.status !== "open") context.fail({ code: "INVALID_STATUS", message: "Only open batches can be deleted" });
|
|
73
|
+
for (const payment of paymentsOf(context, batch.id)) {
|
|
74
|
+
context.state.delete("payments", paymentRowId(batch.id, payment.id));
|
|
75
|
+
context.state.delete("payment_index", payment.id);
|
|
76
|
+
}
|
|
77
|
+
context.state.delete("batches", batch.id);
|
|
78
|
+
return { ok: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function batchesList(input, context) {
|
|
82
|
+
requireKey(context, false);
|
|
83
|
+
const paging = pageArgs(context, input);
|
|
84
|
+
const search = filterText(context, input, "search")?.toLowerCase();
|
|
85
|
+
const status = hasOwn(input, "status") ? checkEnum(context, input.status, STATUSES, "status") : undefined;
|
|
86
|
+
const currency = hasOwn(input, "currency") ? checkEnum(context, String(input.currency).toUpperCase(), CURRENCIES, "currency") : undefined;
|
|
87
|
+
const tags = filterList(context, input, "tags");
|
|
88
|
+
const { orderBy, sortBy } = sortArgs(context, input, ["createdAt", "amount", "status"], "createdAt");
|
|
89
|
+
const byBatch = new Map();
|
|
90
|
+
for (const payment of scanAll(context, "payments")) {
|
|
91
|
+
if (!byBatch.has(payment.batchId)) byBatch.set(payment.batchId, []);
|
|
92
|
+
byBatch.get(payment.batchId).push(payment);
|
|
93
|
+
}
|
|
94
|
+
const items = scanAll(context, "batches")
|
|
95
|
+
.filter((batch) => {
|
|
96
|
+
if (status !== undefined && batch.status !== status) return false;
|
|
97
|
+
if (currency !== undefined && batch.currency !== currency) return false;
|
|
98
|
+
if (tags !== undefined && !tags.every((tag) => (batch.tags ?? []).includes(tag))) return false;
|
|
99
|
+
if (search !== undefined && ![batch.id, batch.description, ...(batch.tags ?? [])].some((field) => String(field).toLowerCase().includes(search))) return false;
|
|
100
|
+
return true;
|
|
101
|
+
})
|
|
102
|
+
.map((batch) => batchOut(batch, byBatch.get(batch.id) ?? []));
|
|
103
|
+
sortItems(items, orderBy === "amount" ? (item) => toCents(item.amount) : (item) => item[orderBy], sortBy);
|
|
104
|
+
return pageOf(context, items, paging, "batches");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function emptySummary() {
|
|
108
|
+
return { count: 0, totalFees: 0n, merchantFees: 0n, sendingAmount: 0n };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderSummary(entry) {
|
|
112
|
+
return {
|
|
113
|
+
count: entry.count,
|
|
114
|
+
totalFees: fromCents(entry.totalFees),
|
|
115
|
+
merchantFees: fromCents(entry.merchantFees),
|
|
116
|
+
debitAmount: fromCents(entry.sendingAmount + entry.merchantFees),
|
|
117
|
+
sendingAmount: fromCents(entry.sendingAmount),
|
|
118
|
+
totalWithheld: "0.00",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function batchesSummary(input, context) {
|
|
123
|
+
requireKey(context, false);
|
|
124
|
+
const batch = batchOrFail(context, input.batchId);
|
|
125
|
+
const detail = new Map();
|
|
126
|
+
const total = emptySummary();
|
|
127
|
+
for (const payment of paymentsOf(context, batch.id)) {
|
|
128
|
+
if (!detail.has(payment.payoutMethod)) detail.set(payment.payoutMethod, emptySummary());
|
|
129
|
+
for (const entry of [detail.get(payment.payoutMethod), total]) {
|
|
130
|
+
entry.count += 1;
|
|
131
|
+
entry.totalFees += toCents(payment.fees);
|
|
132
|
+
entry.merchantFees += toCents(payment.merchantFees);
|
|
133
|
+
entry.sendingAmount += toCents(payment.sourceAmount);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const rendered = {};
|
|
137
|
+
for (const method of [...detail.keys()].sort()) rendered[method] = renderSummary(detail.get(method));
|
|
138
|
+
return { ok: true, batchSummary: { detail: rendered, total: renderSummary(total) } };
|
|
139
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Payment validation and construction shared by POST /v1/batches (with payments) and POST /v1/batches/{id}/payments.
|
|
2
|
+
import { nextId } from "../lib/ids.mjs";
|
|
3
|
+
import { fromCents, isAmount, toCents } from "../lib/money.mjs";
|
|
4
|
+
import { pricePayment } from "../lib/pricing.mjs";
|
|
5
|
+
import { accountsOf, resolveRecipient } from "../lib/records.mjs";
|
|
6
|
+
import { primaryAccount } from "../lib/serialize.mjs";
|
|
7
|
+
import { paymentRowId } from "../lib/store.mjs";
|
|
8
|
+
import { CATEGORIES, checkCurrency, checkTags, checkText, empty, fieldError, hasOwn, invalid } from "../lib/validate.mjs";
|
|
9
|
+
|
|
10
|
+
export const MAX_PAYMENTS = 1000;
|
|
11
|
+
const BLOCKED = ["archived", "suspended", "blocked", "disabled"];
|
|
12
|
+
const PAIRS = [["amount", "currency"], ["sourceAmount", "sourceCurrency"], ["targetAmount", "targetCurrency"]];
|
|
13
|
+
|
|
14
|
+
function checkAmount(context, value, field) {
|
|
15
|
+
if (value === undefined || value === null || value === "") empty(context, field);
|
|
16
|
+
if (!isAmount(value) || toCents(value) === 0n) invalid(context, field, "Amount must be a positive decimal with at most two decimals");
|
|
17
|
+
return fromCents(toCents(value));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The amount pair the caller fixed: `{ side, amount }` for the batch currency or the payout account currency. */
|
|
21
|
+
export function amountChoice(context, input, p, batchCurrency, account) {
|
|
22
|
+
const present = PAIRS.filter(([amountKey, currencyKey]) => hasOwn(input, amountKey) || hasOwn(input, currencyKey));
|
|
23
|
+
if (present.length === 0) empty(context, `${p}amount`);
|
|
24
|
+
if (present.length > 1) invalid(context, `${p}${present[1][0]}`, "Send exactly one amount and currency pair");
|
|
25
|
+
const [amountKey, currencyKey] = present[0];
|
|
26
|
+
const amount = checkAmount(context, input[amountKey], `${p}${amountKey}`);
|
|
27
|
+
if (!hasOwn(input, currencyKey)) empty(context, `${p}${currencyKey}`);
|
|
28
|
+
const currency = checkCurrency(context, input[currencyKey], `${p}${currencyKey}`);
|
|
29
|
+
const payoutCurrency = account.type === "paypal" ? batchCurrency : account.currency;
|
|
30
|
+
const isSource = currency === batchCurrency && amountKey !== "targetAmount";
|
|
31
|
+
const isTarget = currency === payoutCurrency && amountKey !== "sourceAmount";
|
|
32
|
+
if (!isSource && !isTarget) invalid(context, `${p}${currencyKey}`, "Currency must be the batch currency or the recipient's payout currency");
|
|
33
|
+
return isSource ? { side: "source", amount } : { side: "target", amount };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Optional descriptive payment fields into `row`. */
|
|
37
|
+
export function applyPaymentDetails(context, input, p, row) {
|
|
38
|
+
if (hasOwn(input, "memo")) row.memo = input.memo === null ? "" : checkText(context, input.memo, `${p}memo`, 1024);
|
|
39
|
+
if (hasOwn(input, "externalId")) row.externalId = input.externalId === null || input.externalId === "" ? null : checkText(context, input.externalId, `${p}externalId`, 100);
|
|
40
|
+
if (hasOwn(input, "coverFees")) {
|
|
41
|
+
if (typeof input.coverFees !== "boolean") invalid(context, `${p}coverFees`);
|
|
42
|
+
row.coverFees = input.coverFees;
|
|
43
|
+
}
|
|
44
|
+
if (hasOwn(input, "category")) {
|
|
45
|
+
if (input.category !== null && !CATEGORIES.includes(input.category)) invalid(context, `${p}category`);
|
|
46
|
+
row.category = input.category;
|
|
47
|
+
}
|
|
48
|
+
if (hasOwn(input, "tags")) row.tags = checkTags(context, input.tags, `${p}tags`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function assertExternalIdFree(context, payments, externalId, selfId, field) {
|
|
52
|
+
if (typeof externalId !== "string") return;
|
|
53
|
+
if (payments.some((payment) => payment.id !== selfId && payment.externalId === externalId)) invalid(context, field, "External ID already exists in this batch");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Validates and stores one pending payment in `batch`; returns `{ row, recipient }`. */
|
|
57
|
+
export function createPayment(context, batch, input, p, now, existing) {
|
|
58
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) invalid(context, p.replace(/\.$/, ""));
|
|
59
|
+
if (existing.length >= MAX_PAYMENTS) invalid(context, `${p}amount`, "Batch has the maximum number of payments");
|
|
60
|
+
if (!hasOwn(input, "recipient")) empty(context, `${p}recipient`);
|
|
61
|
+
const recipient = resolveRecipient(context, input.recipient, `${p}recipient`);
|
|
62
|
+
if (BLOCKED.includes(recipient.status)) fieldError(context, "INVALID_STATUS", `${p}recipient`, `Recipient is ${recipient.status}`);
|
|
63
|
+
const account = primaryAccount(accountsOf(context, recipient.id));
|
|
64
|
+
if (account === null) fieldError(context, "INVALID_STATUS", `${p}recipient`, "Recipient has no primary payout method");
|
|
65
|
+
const choice = amountChoice(context, input, p, batch.currency, account);
|
|
66
|
+
const row = {
|
|
67
|
+
id: nextId(context, "P"), batchId: batch.id, recipientId: recipient.id, accountId: account.id, status: "pending",
|
|
68
|
+
amountSide: choice.side, sourceAmount: choice.side === "source" ? choice.amount : "0.00", sourceCurrency: batch.currency,
|
|
69
|
+
targetAmount: choice.side === "target" ? choice.amount : "0.00", targetCurrency: batch.currency, exchangeRate: "1.000000",
|
|
70
|
+
fees: "0.00", recipientFees: "0.00", merchantFees: "0.00", coverFees: false, payoutMethod: account.type, memo: "",
|
|
71
|
+
externalId: null, category: null, tags: [], failureMessage: null, initiatedAt: null, processedAt: null, createdAt: now, updatedAt: now,
|
|
72
|
+
};
|
|
73
|
+
applyPaymentDetails(context, input, p, row);
|
|
74
|
+
assertExternalIdFree(context, existing, row.externalId, row.id, `${p}externalId`);
|
|
75
|
+
const priced = pricePayment(context, row, account);
|
|
76
|
+
context.state.put("payments", paymentRowId(batch.id, priced.id), priced);
|
|
77
|
+
context.state.put("payment_index", priced.id, { batchId: batch.id });
|
|
78
|
+
return { row: priced, recipient };
|
|
79
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Payments inside batches: create, list, get (two paths), update, delete.
|
|
2
|
+
import { requireKey } from "../lib/access.mjs";
|
|
3
|
+
import { isId } from "../lib/ids.mjs";
|
|
4
|
+
import { filterText, pageArgs, pageOf, sortItems } from "../lib/paging.mjs";
|
|
5
|
+
import { pricePayment } from "../lib/pricing.mjs";
|
|
6
|
+
import { accountsOf, batchOrFail, merchantId, notFound, paymentOrFail, paymentsOf } from "../lib/records.mjs";
|
|
7
|
+
import { paymentOut, primaryAccount } from "../lib/serialize.mjs";
|
|
8
|
+
import { accountRowId, paymentRowId } from "../lib/store.mjs";
|
|
9
|
+
import { nowIso } from "../lib/time.mjs";
|
|
10
|
+
import { checkEnum, empty, hasOwn, invalid, rejectReservedKeys } from "../lib/validate.mjs";
|
|
11
|
+
import { amountChoice, applyPaymentDetails, assertExternalIdFree, createPayment } from "./payment-build.mjs";
|
|
12
|
+
|
|
13
|
+
const STATUSES = ["pending", "processing", "processed", "failed"];
|
|
14
|
+
|
|
15
|
+
function openBatch(context, id) {
|
|
16
|
+
const batch = batchOrFail(context, id);
|
|
17
|
+
if (batch.status !== "open") context.fail({ code: "INVALID_STATUS", message: "Batch is not open" });
|
|
18
|
+
return batch;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A changed batch needs a new quote. */
|
|
22
|
+
function clearQuote(context, batch, now) {
|
|
23
|
+
const next = { ...batch, quoteExpiredAt: null, updatedAt: now };
|
|
24
|
+
context.state.put("batches", batch.id, next);
|
|
25
|
+
return next;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const out = (context, row, batch) => paymentOut(row, context.state.get("recipients", row.recipientId), batch, merchantId(context));
|
|
29
|
+
|
|
30
|
+
export function paymentsCreate(input, context) {
|
|
31
|
+
requireKey(context, true);
|
|
32
|
+
rejectReservedKeys(context, input);
|
|
33
|
+
const batch = openBatch(context, input.batchId);
|
|
34
|
+
const now = nowIso(context);
|
|
35
|
+
const { batchId, ...payload } = input;
|
|
36
|
+
const { row } = createPayment(context, batch, payload, "", now, paymentsOf(context, batch.id));
|
|
37
|
+
return { ok: true, payment: out(context, row, clearQuote(context, batch, now)) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function paymentsList(input, context) {
|
|
41
|
+
requireKey(context, false);
|
|
42
|
+
const batch = batchOrFail(context, input.batchId);
|
|
43
|
+
const paging = pageArgs(context, input);
|
|
44
|
+
const status = hasOwn(input, "status") ? checkEnum(context, input.status, STATUSES, "status") : undefined;
|
|
45
|
+
const search = filterText(context, input, "search");
|
|
46
|
+
const needle = search?.toLowerCase();
|
|
47
|
+
const recipients = new Map();
|
|
48
|
+
const recipientOf = (id) => {
|
|
49
|
+
if (!recipients.has(id)) recipients.set(id, context.state.get("recipients", id));
|
|
50
|
+
return recipients.get(id);
|
|
51
|
+
};
|
|
52
|
+
const rows = paymentsOf(context, batch.id).filter((row) => {
|
|
53
|
+
if (status !== undefined && row.status !== status) return false;
|
|
54
|
+
if (needle === undefined) return true;
|
|
55
|
+
const recipient = recipientOf(row.recipientId);
|
|
56
|
+
return [recipient?.name, recipient?.email, recipient?.referenceId, row.memo, row.externalId]
|
|
57
|
+
.some((field) => typeof field === "string" && field.toLowerCase().includes(needle));
|
|
58
|
+
});
|
|
59
|
+
sortItems(rows, (row) => row.createdAt, "asc");
|
|
60
|
+
const merchant = merchantId(context);
|
|
61
|
+
return pageOf(context, rows, paging, "payments", (row) => paymentOut(row, recipientOf(row.recipientId), batch, merchant));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function paymentsGet(input, context) {
|
|
65
|
+
requireKey(context, false);
|
|
66
|
+
let batchId = input.batchId;
|
|
67
|
+
if (batchId === undefined) {
|
|
68
|
+
const index = isId("P", input.paymentId) ? context.state.get("payment_index", input.paymentId) : null;
|
|
69
|
+
if (index === null) notFound(context, "Payment");
|
|
70
|
+
batchId = index.batchId;
|
|
71
|
+
}
|
|
72
|
+
const row = paymentOrFail(context, batchId, input.paymentId);
|
|
73
|
+
return { ok: true, payment: out(context, row, context.state.get("batches", row.batchId)) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function paymentsUpdate(input, context) {
|
|
77
|
+
requireKey(context, true);
|
|
78
|
+
rejectReservedKeys(context, input);
|
|
79
|
+
const batch = batchOrFail(context, input.batchId);
|
|
80
|
+
const current = paymentOrFail(context, batch.id, input.paymentId);
|
|
81
|
+
const { batchId, paymentId, ...changes } = input;
|
|
82
|
+
if (Object.keys(changes).length === 0) empty(context, "body");
|
|
83
|
+
if (hasOwn(changes, "recipient")) invalid(context, "recipient", "Create a new payment to change the recipient");
|
|
84
|
+
if (hasOwn(changes, "status")) invalid(context, "status", "Field is read-only");
|
|
85
|
+
if (batch.status !== "open" || current.status !== "pending") context.fail({ code: "INVALID_STATUS", message: "Only pending payments in an open batch can be changed" });
|
|
86
|
+
const now = nowIso(context);
|
|
87
|
+
let row = { ...current, updatedAt: now };
|
|
88
|
+
const stored = context.state.get("recipient_accounts", accountRowId(current.recipientId, current.accountId));
|
|
89
|
+
const account = stored !== null && stored.status !== "disabled" ? stored : primaryAccount(accountsOf(context, current.recipientId));
|
|
90
|
+
if (account === null) context.fail({ code: "INVALID_STATUS", message: "Recipient has no primary payout method" });
|
|
91
|
+
const amountKeys = ["amount", "currency", "sourceAmount", "sourceCurrency", "targetAmount", "targetCurrency"];
|
|
92
|
+
if (amountKeys.some((key) => hasOwn(changes, key))) {
|
|
93
|
+
const choice = amountChoice(context, changes, "", batch.currency, account);
|
|
94
|
+
row = { ...row, amountSide: choice.side, sourceAmount: choice.side === "source" ? choice.amount : row.sourceAmount, targetAmount: choice.side === "target" ? choice.amount : row.targetAmount };
|
|
95
|
+
}
|
|
96
|
+
applyPaymentDetails(context, changes, "", row);
|
|
97
|
+
if (hasOwn(changes, "externalId")) assertExternalIdFree(context, paymentsOf(context, batch.id), row.externalId, row.id, "externalId");
|
|
98
|
+
row = pricePayment(context, row, account);
|
|
99
|
+
context.state.put("payments", paymentRowId(batch.id, row.id), row);
|
|
100
|
+
return { ok: true, payment: out(context, row, clearQuote(context, batch, now)) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function paymentsDelete(input, context) {
|
|
104
|
+
requireKey(context, true);
|
|
105
|
+
const batch = batchOrFail(context, input.batchId);
|
|
106
|
+
const row = paymentOrFail(context, batch.id, input.paymentId);
|
|
107
|
+
if (batch.status !== "open" || row.status !== "pending") context.fail({ code: "INVALID_STATUS", message: "Only pending payments in an open batch can be deleted" });
|
|
108
|
+
context.state.delete("payments", paymentRowId(batch.id, row.id));
|
|
109
|
+
context.state.delete("payment_index", row.id);
|
|
110
|
+
clearQuote(context, batch, nowIso(context));
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|