@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,124 @@
|
|
|
1
|
+
// Recipient profile: header, profile details (edit), payout methods, archive/restore.
|
|
2
|
+
import { app, go, setTitle } from "./app.js";
|
|
3
|
+
import { errorBlock, stateBlock } from "./list.js";
|
|
4
|
+
import { btn, call, confirmDialog, date, describe, el, field, human, initials, input, newKey, openModal, pill, showFieldError, toast } from "./ui.js";
|
|
5
|
+
import { openAddMethod, methodIcon, methodLine } from "./view-methods.js";
|
|
6
|
+
|
|
7
|
+
export async function renderRecipient(host, route, view) {
|
|
8
|
+
setTitle("Recipients");
|
|
9
|
+
host.replaceChildren(el("div", { class: "card card-body" }, el("div", { class: "skeleton" })));
|
|
10
|
+
let recipient;
|
|
11
|
+
let accounts;
|
|
12
|
+
try {
|
|
13
|
+
[recipient, accounts] = await Promise.all([
|
|
14
|
+
call("recipients.get", { recipientId: route.id }).then((v) => v.recipient),
|
|
15
|
+
call("recipient_accounts.list", { recipientId: route.id }).then((v) => v.accounts),
|
|
16
|
+
]);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (!view.isCurrent()) return;
|
|
19
|
+
host.replaceChildren(crumbs(route.id), el("section", { class: "card" }, error.is?.("NOT_FOUND") ? stateBlock("user", "Recipient not found", "It may have been removed or the ID is wrong.") : errorBlock(error, () => go("recipients", route.id))));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (!view.isCurrent()) return;
|
|
23
|
+
const archived = recipient.status === "archived";
|
|
24
|
+
|
|
25
|
+
const actions = archived
|
|
26
|
+
? btn("Restore recipient", "secondary", { onClick: () => mutate("recipients.update", { recipientId: recipient.id, status: "active" }, "Recipient restored.") })
|
|
27
|
+
: btn("Archive", "secondary", { onClick: archive });
|
|
28
|
+
const head = el("div", { class: "profile-head" }, [
|
|
29
|
+
el("span", { class: `face${recipient.type === "business" ? " biz" : ""}`, text: initials(recipient.name), attrs: { "aria-hidden": "true" } }),
|
|
30
|
+
el("div", {}, [el("h2", { text: recipient.name || "(no name)" }), el("div", { class: "method-sub", text: `${human(recipient.type)} · ${recipient.email ?? ""}` })]),
|
|
31
|
+
pill(recipient.status), pill(recipient.complianceStatus),
|
|
32
|
+
el("span", { class: "spacer" }), actions,
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const address = recipient.address ?? {};
|
|
36
|
+
const addressText = [address.street1, address.street2, address.city, address.region, address.postalCode, address.country].filter(Boolean).join(", ");
|
|
37
|
+
const details = el("section", { class: "card" }, [
|
|
38
|
+
el("div", { class: "card-head" }, [el("h2", { text: "Profile" }), el("span", { class: "spacer" }), btn("Edit", "secondary", { small: true, disabled: archived, onClick: openEdit })]),
|
|
39
|
+
el("div", { class: "card-body" }, el("dl", { class: "dl" }, [
|
|
40
|
+
["Recipient ID", recipient.id], ["Reference ID", recipient.referenceId ?? "—"], ["Email", recipient.email ?? "—"],
|
|
41
|
+
["Phone", recipient.phone ?? "—"], ["Date of birth", recipient.dob ?? "—"], ["Address", addressText || "Missing (profile incomplete)"],
|
|
42
|
+
["Tags", (recipient.tags ?? []).join(", ") || "—"], ["Created", date(recipient.createdAt, true)], ["Last updated", date(recipient.updatedAt, true)],
|
|
43
|
+
].flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: v })]))),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const methodsBody = el("div", { class: "card-body" });
|
|
47
|
+
if (accounts.length === 0) methodsBody.append(stateBlock("bank", "No payout method", "Add a payout method so this recipient can be paid."));
|
|
48
|
+
for (const account of accounts) {
|
|
49
|
+
const row = el("div", { class: "method" }, [
|
|
50
|
+
el("div", { class: "method-icon" }, methodIcon(account.type)),
|
|
51
|
+
el("div", { class: "method-main" }, [el("div", { class: "method-title" }, [el("span", { text: `${human(account.type)} · ${account.currency ?? ""} ` }), account.primary ? pill("primary") : null]), el("div", { class: "method-sub", text: methodLine(account) })]),
|
|
52
|
+
]);
|
|
53
|
+
if (!account.primary && !archived) row.append(btn("Make primary", "link", { small: true, onClick: () => mutate("recipient_accounts.update", { recipientId: recipient.id, accountId: account.id, primary: true }, "Primary payout method updated.") }));
|
|
54
|
+
if (!archived) row.append(btn("Remove", "link", { small: true, onClick: () => removeAccount(account) }));
|
|
55
|
+
methodsBody.append(row);
|
|
56
|
+
}
|
|
57
|
+
const route_ = recipient.payoutMethod ? el("div", { class: "method-sub", text: `Route: ${human(recipient.routeType ?? "")} · minimum ${recipient.routeMinimum ?? "—"} · estimated fees ${recipient.estimatedFees ?? "—"}` }) : null;
|
|
58
|
+
const methods = el("section", { class: "card" }, [
|
|
59
|
+
el("div", { class: "card-head" }, [el("h2", { text: "Payout methods" }), el("span", { class: "spacer" }), btn("Add payout method", "dark", { disabled: archived, onClick: () => openAddMethod(recipient, () => go("recipients", recipient.id)) })]),
|
|
60
|
+
methodsBody, route_ ? el("div", { class: "card-body" }, route_) : null,
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
host.replaceChildren(crumbs(recipient.name || recipient.id),
|
|
64
|
+
archived ? el("div", { class: "banner banner-warn", attrs: { role: "status" } }, el("span", { text: "This recipient is archived. Restore it to edit details or add payments." })) : "",
|
|
65
|
+
head, el("div", { class: "grid-2" }, [details, methods]));
|
|
66
|
+
|
|
67
|
+
async function mutate(operation, args, message) {
|
|
68
|
+
try {
|
|
69
|
+
await call(operation, args, newKey());
|
|
70
|
+
toast(message);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
toast(describe(error), { error: true });
|
|
73
|
+
}
|
|
74
|
+
go("recipients", recipient.id);
|
|
75
|
+
}
|
|
76
|
+
async function archive() {
|
|
77
|
+
if (await confirmDialog("Archive recipient?", `${recipient.name} will be hidden from the recipient list and cannot be added to new payments.`, "Archive", { danger: true })) {
|
|
78
|
+
await mutate("recipients.delete", { recipientId: recipient.id }, "Recipient archived.");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function removeAccount(account) {
|
|
82
|
+
if (await confirmDialog("Remove payout method?", `${human(account.type)} ${methodLine(account)} will be disabled.`, "Remove", { danger: true })) {
|
|
83
|
+
await mutate("recipient_accounts.delete", { recipientId: recipient.id, accountId: account.id }, "Payout method removed.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function openEdit() {
|
|
87
|
+
const form = el("form", { class: "form-grid", attrs: { novalidate: true } });
|
|
88
|
+
const individual = recipient.type === "individual";
|
|
89
|
+
form.append(
|
|
90
|
+
...(individual ? [field("First name", input("firstName", recipient.firstName)), field("Last name", input("lastName", recipient.lastName))] : [field("Business name", input("name", recipient.name), { full: true })]),
|
|
91
|
+
field("Email", input("email", recipient.email)), field("Phone", input("phone", recipient.phone)),
|
|
92
|
+
field("Reference ID", input("referenceId", recipient.referenceId)), field("Tags", input("tags", (recipient.tags ?? []).join(", ")), { hint: "Comma separated" }),
|
|
93
|
+
field("Street address", input("street1", address.street1), { full: true }), field("City", input("city", address.city)),
|
|
94
|
+
field("Region / state", input("region", address.region)), field("Postal code", input("postalCode", address.postalCode)), field("Country code", input("country", address.country, { maxlength: 2 })),
|
|
95
|
+
);
|
|
96
|
+
const formError = el("div", { class: "form-error", attrs: { role: "alert" } });
|
|
97
|
+
const save = btn("Save changes", "primary");
|
|
98
|
+
const key = newKey();
|
|
99
|
+
app.editing += 1;
|
|
100
|
+
const modal = openModal({ title: "Edit profile", body: [form, formError], wide: true, actions: [btn("Cancel", "secondary", { onClick: () => modal.close() }), save], onClose: () => { app.editing -= 1; } });
|
|
101
|
+
save.addEventListener("click", async () => {
|
|
102
|
+
const d = Object.fromEntries(new FormData(form).entries());
|
|
103
|
+
const args = { recipientId: recipient.id, email: d.email.trim(), tags: d.tags.split(",").map((t) => t.trim()).filter(Boolean) };
|
|
104
|
+
if (individual) { args.firstName = d.firstName.trim(); args.lastName = d.lastName.trim(); } else args.name = d.name.trim();
|
|
105
|
+
if (d.phone.trim()) args.phone = d.phone.trim();
|
|
106
|
+
if (d.referenceId.trim()) args.referenceId = d.referenceId.trim();
|
|
107
|
+
args.address = { street1: d.street1.trim() || null, city: d.city.trim() || null, region: d.region.trim() || null, postalCode: d.postalCode.trim() || null, country: d.country.trim().toUpperCase() || null };
|
|
108
|
+
save.disabled = true;
|
|
109
|
+
try {
|
|
110
|
+
await call("recipients.update", args, key);
|
|
111
|
+
modal.close();
|
|
112
|
+
toast("Profile saved.");
|
|
113
|
+
go("recipients", recipient.id);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (!showFieldError(form, error)) formError.textContent = describe(error);
|
|
116
|
+
save.disabled = false;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function crumbs(label) {
|
|
123
|
+
return el("nav", { class: "crumbs", attrs: { "aria-label": "Breadcrumb" } }, [el("a", { text: "Recipients", attrs: { href: "#/recipients" } }), el("span", { text: "›" }), el("span", { text: label })]);
|
|
124
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Recipients list: search, status/payout-method/country filters, sortable columns, paging, Add recipient.
|
|
2
|
+
import { app, go, setParams, setTitle } from "./app.js";
|
|
3
|
+
import { dataTable, pager, searchBox, stateBlock, tabs } from "./list.js";
|
|
4
|
+
import { btn, call, date, describe, el, field, human, initials, input, newKey, openModal, pill, select, showFieldError, toast } from "./ui.js";
|
|
5
|
+
|
|
6
|
+
const STATUS_TABS = [["", "All"], ["active", "Active"], ["incomplete", "Incomplete"], ["disabled", "Disabled"], ["blocked", "Blocked"], ["archived", "Archived"]];
|
|
7
|
+
const METHODS = [["", "Any payout method"], ["bank-transfer", "Bank transfer"], ["paypal", "PayPal"], ["check", "Check"], ["venmo", "Venmo"]];
|
|
8
|
+
|
|
9
|
+
export function recipientCell(r) {
|
|
10
|
+
return el("div", { class: "who" }, [
|
|
11
|
+
el("span", { class: `face${r.type === "business" ? " biz" : ""}`, text: initials(r.name), attrs: { "aria-hidden": "true" } }),
|
|
12
|
+
el("div", {}, [el("div", { text: r.name || "(no name)" }), el("div", { class: "sub", text: r.email ?? "" })]),
|
|
13
|
+
]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function renderRecipients(host, route, view) {
|
|
17
|
+
setTitle("Recipients");
|
|
18
|
+
const state = {
|
|
19
|
+
page: Number(route.params.page) || 1, pageSize: Number(route.params.pageSize) || 10, search: route.params.search ?? "",
|
|
20
|
+
status: route.params.status ?? "", payoutMethod: route.params.payoutMethod ?? "", country: route.params.country ?? "",
|
|
21
|
+
orderBy: route.params.orderBy ?? "createdAt", sortBy: route.params.sortBy ?? "desc",
|
|
22
|
+
};
|
|
23
|
+
const table = dataTable([
|
|
24
|
+
{ label: "Name", key: "name", cell: recipientCell },
|
|
25
|
+
{ label: "Reference ID", key: "referenceId", cell: (r) => el("span", { class: "mono", text: r.referenceId ?? "—" }) },
|
|
26
|
+
{ label: "Payout method", key: "payoutMethod", cell: (r) => (r.payoutMethod ? human(r.payoutMethod) : "—") },
|
|
27
|
+
{ label: "Country", cell: (r) => (r.address?.country ? el("span", { class: "flag", text: r.address.country }) : "—") },
|
|
28
|
+
{ label: "Status", cell: (r) => pill(r.status) },
|
|
29
|
+
{ label: "Created", key: "createdAt", cell: (r) => date(r.createdAt) },
|
|
30
|
+
], {
|
|
31
|
+
onRowClick: (r) => go("recipients", r.id),
|
|
32
|
+
sort: state,
|
|
33
|
+
onSort: (key) => { state.sortBy = state.orderBy === key && state.sortBy === "asc" ? "desc" : "asc"; state.orderBy = key; state.page = 1; void load(); },
|
|
34
|
+
});
|
|
35
|
+
const pagerHost = el("div");
|
|
36
|
+
const tabHost = el("div");
|
|
37
|
+
const countryInput = el("input", { class: "input", attrs: { placeholder: "Country (US, CA)", "aria-label": "Country filter", value: state.country, size: 20 } });
|
|
38
|
+
countryInput.addEventListener("change", () => { state.country = countryInput.value.trim().toUpperCase(); state.page = 1; void load(); });
|
|
39
|
+
const methodSelect = select("payoutMethod", METHODS, state.payoutMethod);
|
|
40
|
+
methodSelect.setAttribute("aria-label", "Payout method filter");
|
|
41
|
+
methodSelect.addEventListener("change", () => { state.payoutMethod = methodSelect.value; state.page = 1; void load(); });
|
|
42
|
+
const toolbar = el("div", { class: "toolbar" }, [
|
|
43
|
+
searchBox("Search name, email or reference ID", state.search, (value) => { state.search = value; state.page = 1; void load(); }),
|
|
44
|
+
methodSelect, countryInput, el("span", { class: "spacer" }),
|
|
45
|
+
btn("Add recipient", "primary", { icon: "plus", onClick: () => openCreate() }),
|
|
46
|
+
]);
|
|
47
|
+
host.replaceChildren(el("div", {}, [tabHost, toolbar, el("section", { class: "card" }, [table.node, pagerHost])]));
|
|
48
|
+
|
|
49
|
+
function drawTabs() {
|
|
50
|
+
tabHost.replaceChildren(tabs(STATUS_TABS, state.status, (value) => { state.status = value; state.page = 1; drawTabs(); void load(); }));
|
|
51
|
+
}
|
|
52
|
+
drawTabs();
|
|
53
|
+
|
|
54
|
+
async function load() {
|
|
55
|
+
setParams("recipients", { ...state, page: state.page === 1 ? "" : state.page, pageSize: state.pageSize === 10 ? "" : state.pageSize, orderBy: state.orderBy === "createdAt" ? "" : state.orderBy, sortBy: state.sortBy === "desc" ? "" : state.sortBy });
|
|
56
|
+
table.setLoading();
|
|
57
|
+
const args = { page: state.page, pageSize: state.pageSize, orderBy: state.orderBy, sortBy: state.sortBy };
|
|
58
|
+
for (const key of ["search", "status", "payoutMethod", "country"]) if (state[key]) args[key] = state[key];
|
|
59
|
+
try {
|
|
60
|
+
const value = await call("recipients.list", args);
|
|
61
|
+
if (!view.isCurrent()) return;
|
|
62
|
+
const filtered = state.search || state.status || state.payoutMethod || state.country;
|
|
63
|
+
table.setRows(value.recipients, filtered ? stateBlock("search", "No recipients match these filters", "Try a different search or clear the filters.") : stateBlock("user", "No recipients yet", "Add a recipient to start paying them."));
|
|
64
|
+
pagerHost.replaceChildren(pager(value.meta, state.pageSize, (next) => { Object.assign(state, next); void load(); }));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!view.isCurrent()) return;
|
|
67
|
+
pagerHost.replaceChildren();
|
|
68
|
+
table.setError(error, () => void load());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await load();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function openCreate() {
|
|
75
|
+
let type = "individual";
|
|
76
|
+
const form = el("form", { class: "form-grid", attrs: { novalidate: true } });
|
|
77
|
+
const typeSelect = select("type", [["individual", "Individual"], ["business", "Business"]], type);
|
|
78
|
+
const names = el("div", { class: "field-full form-grid" });
|
|
79
|
+
const drawNames = () => names.replaceChildren(...(type === "individual"
|
|
80
|
+
? [field("First name", input("firstName", "", { autocomplete: "off" })), field("Last name", input("lastName", "", { autocomplete: "off" }))]
|
|
81
|
+
: [field("Business name", input("name", "", { autocomplete: "off" }), { full: true })]));
|
|
82
|
+
typeSelect.addEventListener("change", () => { type = typeSelect.value; drawNames(); });
|
|
83
|
+
drawNames();
|
|
84
|
+
form.append(
|
|
85
|
+
field("Recipient type", typeSelect, { full: true }), names,
|
|
86
|
+
field("Email", input("email", "", { type: "email", autocomplete: "off" })), field("Reference ID", input("referenceId"), { hint: "Your own identifier (optional)" }),
|
|
87
|
+
field("Street address", input("street1"), { full: true }), field("City", input("city")), field("Region / state", input("region")),
|
|
88
|
+
field("Postal code", input("postalCode")), field("Country code", input("country", "US", { maxlength: 2 })),
|
|
89
|
+
);
|
|
90
|
+
const formError = el("div", { class: "form-error", attrs: { role: "alert" } });
|
|
91
|
+
const submit = btn("Create recipient", "primary");
|
|
92
|
+
const key = newKey();
|
|
93
|
+
app.editing += 1;
|
|
94
|
+
const modal = openModal({ title: "Add recipient", body: [el("p", { text: "Recipients receive payouts. Address and a payout method complete their profile." }), form, formError], actions: [btn("Cancel", "secondary", { onClick: () => modal.close() }), submit], onClose: () => { app.editing -= 1; }, wide: true });
|
|
95
|
+
submit.addEventListener("click", async () => {
|
|
96
|
+
const data = Object.fromEntries(new FormData(form).entries());
|
|
97
|
+
const args = { type, email: data.email.trim() };
|
|
98
|
+
if (type === "individual") { args.firstName = data.firstName.trim(); args.lastName = data.lastName.trim(); } else args.name = data.name.trim();
|
|
99
|
+
if (data.referenceId.trim()) args.referenceId = data.referenceId.trim();
|
|
100
|
+
const address = {};
|
|
101
|
+
for (const k of ["street1", "city", "region", "postalCode", "country"]) if (data[k].trim()) address[k] = k === "country" ? data[k].trim().toUpperCase() : data[k].trim();
|
|
102
|
+
if (Object.keys(address).length) args.address = address;
|
|
103
|
+
submit.disabled = true;
|
|
104
|
+
formError.textContent = "";
|
|
105
|
+
try {
|
|
106
|
+
const value = await call("recipients.create", args, key);
|
|
107
|
+
modal.close();
|
|
108
|
+
toast(`Recipient ${value.recipient.name} was created.`);
|
|
109
|
+
go("recipients", value.recipient.id);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (!showFieldError(form, error)) formError.textContent = describe(error);
|
|
112
|
+
submit.disabled = false;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Synthetic Trolley merchant account (sandbox) for Firedrill. Every operation computes from `context.state`: ids come
|
|
2
|
+
// from the `meta/counters` row, timestamps from virtual time, FX and fees from the synthetic `meta/fx` and `meta/fees`
|
|
3
|
+
// rows. Nothing here contacts Trolley, a bank, PayPal, Venmo or a check printer; no money moves.
|
|
4
|
+
import { route } from "./lib/wire.mjs";
|
|
5
|
+
import { accountsCreate, accountsGet, accountsList } from "./ops/accounts.mjs";
|
|
6
|
+
import { accountsDelete, accountsUpdate } from "./ops/accounts-write.mjs";
|
|
7
|
+
import { balancesList } from "./ops/balances.mjs";
|
|
8
|
+
import { batchesCreate, batchesDelete, batchesGet, batchesList, batchesSummary, batchesUpdate } from "./ops/batches.mjs";
|
|
9
|
+
import { paymentsCreate, paymentsDelete, paymentsGet, paymentsList, paymentsUpdate } from "./ops/payments.mjs";
|
|
10
|
+
import { batchesGenerateQuote, batchesStartProcessing } from "./ops/processing.mjs";
|
|
11
|
+
import { recipientsCreate, recipientsDelete, recipientsGet, recipientsUpdate } from "./ops/recipients.mjs";
|
|
12
|
+
import { recipientsList } from "./ops/recipients-list.mjs";
|
|
13
|
+
|
|
14
|
+
const operations = {
|
|
15
|
+
"recipients.create": recipientsCreate,
|
|
16
|
+
"recipients.get": recipientsGet,
|
|
17
|
+
"recipients.update": recipientsUpdate,
|
|
18
|
+
"recipients.delete": recipientsDelete,
|
|
19
|
+
"recipients.list": recipientsList,
|
|
20
|
+
"recipient_accounts.create": accountsCreate,
|
|
21
|
+
"recipient_accounts.list": accountsList,
|
|
22
|
+
"recipient_accounts.get": accountsGet,
|
|
23
|
+
"recipient_accounts.update": accountsUpdate,
|
|
24
|
+
"recipient_accounts.delete": accountsDelete,
|
|
25
|
+
"batches.create": batchesCreate,
|
|
26
|
+
"batches.get": batchesGet,
|
|
27
|
+
"batches.update": batchesUpdate,
|
|
28
|
+
"batches.delete": batchesDelete,
|
|
29
|
+
"batches.list": batchesList,
|
|
30
|
+
"batches.generate_quote": batchesGenerateQuote,
|
|
31
|
+
"batches.start_processing": batchesStartProcessing,
|
|
32
|
+
"batches.summary": batchesSummary,
|
|
33
|
+
"payments.create": paymentsCreate,
|
|
34
|
+
"payments.list": paymentsList,
|
|
35
|
+
"payments.get": paymentsGet,
|
|
36
|
+
"payments.update": paymentsUpdate,
|
|
37
|
+
"payments.delete": paymentsDelete,
|
|
38
|
+
"balances.list": balancesList,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const PAGE = { page: "integer", pageSize: "integer" };
|
|
42
|
+
const RECIPIENT_QUERY = {
|
|
43
|
+
...PAGE, search: "string", name: "string", email: "string", referenceId: "string", status: "string", complianceStatus: "string",
|
|
44
|
+
country: "string", payoutMethod: "string", currency: "string", tags: "string", orderBy: "string", sortBy: "string",
|
|
45
|
+
};
|
|
46
|
+
const BATCH_QUERY = { ...PAGE, search: "string", status: "string", currency: "string", tags: "string", orderBy: "string", sortBy: "string" };
|
|
47
|
+
const PAYMENT_QUERY = { ...PAGE, status: "string", search: "string" };
|
|
48
|
+
|
|
49
|
+
const http = {
|
|
50
|
+
"create-recipient": route({ body: "json" }),
|
|
51
|
+
"get-recipient": route(),
|
|
52
|
+
"update-recipient": route({ body: "json" }),
|
|
53
|
+
"delete-recipient": route(),
|
|
54
|
+
"list-recipients": route({ query: RECIPIENT_QUERY }),
|
|
55
|
+
"create-recipient-account": route({ body: "json" }),
|
|
56
|
+
"list-recipient-accounts": route(),
|
|
57
|
+
"get-recipient-account": route(),
|
|
58
|
+
"update-recipient-account": route({ body: "json" }),
|
|
59
|
+
"delete-recipient-account": route(),
|
|
60
|
+
"create-batch": route({ body: "json" }),
|
|
61
|
+
"get-batch": route(),
|
|
62
|
+
"update-batch": route({ body: "json" }),
|
|
63
|
+
"delete-batch": route(),
|
|
64
|
+
"list-batches": route({ query: BATCH_QUERY }),
|
|
65
|
+
"generate-batch-quote": route({ body: "empty" }),
|
|
66
|
+
"start-batch-processing": route({ body: "empty" }),
|
|
67
|
+
"get-batch-summary": route(),
|
|
68
|
+
"create-payment": route({ body: "json" }),
|
|
69
|
+
"list-batch-payments": route({ query: PAYMENT_QUERY }),
|
|
70
|
+
"get-batch-payment": route(),
|
|
71
|
+
"get-payment": route(),
|
|
72
|
+
"update-payment": route({ body: "json" }),
|
|
73
|
+
"delete-payment": route(),
|
|
74
|
+
"list-balances": route(),
|
|
75
|
+
"list-balances-by-kind": route(),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export default { operations, http };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// API-key checks from actor attributes. A Trolley key belongs to one merchant and is account-wide; what varies is
|
|
2
|
+
// the merchant it belongs to and whether it may write. Fresh actors (no attributes) are the seeded merchant, full access.
|
|
3
|
+
export const DEFAULT_MERCHANT_ID = "M-4Lk9Qx2Rb7Tn3Vw8Yz1Ca6";
|
|
4
|
+
|
|
5
|
+
export function merchant(context) {
|
|
6
|
+
const row = context.state.get("meta", "merchant");
|
|
7
|
+
return row === null ? { id: DEFAULT_MERCHANT_ID, name: "Merchant", country: "US", defaultCurrency: "USD", sandbox: true } : row;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Key check for every operation: merchant mismatch or an unknown access level → INVALID_API_KEY; writes need full access. */
|
|
11
|
+
export function requireKey(context, write) {
|
|
12
|
+
const attributes = context.actor.attributes ?? {};
|
|
13
|
+
const claimed = Object.hasOwn(attributes, "merchantId") ? attributes.merchantId : undefined;
|
|
14
|
+
const level = Object.hasOwn(attributes, "accessLevel") ? attributes.accessLevel : "full";
|
|
15
|
+
if ((claimed !== undefined && claimed !== merchant(context).id) || (level !== "full" && level !== "read_only")) {
|
|
16
|
+
context.fail({ code: "INVALID_API_KEY", message: "API key is invalid" });
|
|
17
|
+
}
|
|
18
|
+
if (write && level === "read_only") {
|
|
19
|
+
context.fail({ code: "NOT_AUTHORIZED", message: "Authentication not permitted to access resource" });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Trolley-format ids: `<prefix>-<22 base-62 characters>`. The characters are the zero-padded base-62 rendering of
|
|
2
|
+
// one shared counter (`meta/counters.next`), alphabet `0-9A-Za-z`, so lexical order equals creation order.
|
|
3
|
+
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
4
|
+
const WIDTH = 22;
|
|
5
|
+
const ID_PATTERN = /^[RABPM]-[0-9A-Za-z]{22}$/;
|
|
6
|
+
|
|
7
|
+
export function renderId(prefix, sequence) {
|
|
8
|
+
let n = sequence;
|
|
9
|
+
let out = "";
|
|
10
|
+
while (n > 0) {
|
|
11
|
+
out = ALPHABET[n % 62] + out;
|
|
12
|
+
n = Math.floor(n / 62);
|
|
13
|
+
}
|
|
14
|
+
return `${prefix}-${out.padStart(WIDTH, "0")}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** True when `value` has the exact id shape for `prefix`. Never throws. */
|
|
18
|
+
export function isId(prefix, value) {
|
|
19
|
+
return typeof value === "string" && value.length === WIDTH + 2 && value[0] === prefix && ID_PATTERN.test(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Next id for `prefix`, advancing the shared counter row. */
|
|
23
|
+
export function nextId(context, prefix) {
|
|
24
|
+
const row = context.state.get("meta", "counters");
|
|
25
|
+
const next = row !== null && Number.isSafeInteger(row.next) && row.next >= 1 ? row.next : 1000;
|
|
26
|
+
context.state.put("meta", "counters", { next: next + 1 });
|
|
27
|
+
return renderId(prefix, next);
|
|
28
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Decimal-string money in integer minor units (BigInt), two decimals for every supported currency.
|
|
2
|
+
// FX rates are 6-decimal strings held as integer micro-units. No floating point anywhere.
|
|
3
|
+
const AMOUNT = /^(0|[1-9][0-9]{0,9})(\.[0-9]{2})?$/;
|
|
4
|
+
const RATE = /^(0|[1-9][0-9]{0,5})\.[0-9]{6}$/;
|
|
5
|
+
|
|
6
|
+
export function isAmount(value) {
|
|
7
|
+
return typeof value === "string" && AMOUNT.test(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** "150" or "150.00" → 15000n. Caller checks `isAmount` first. */
|
|
11
|
+
export function toCents(value) {
|
|
12
|
+
const [whole, fraction = "00"] = value.split(".");
|
|
13
|
+
return BigInt(whole) * 100n + BigInt(fraction);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function fromCents(cents) {
|
|
17
|
+
const negative = cents < 0n;
|
|
18
|
+
const abs = negative ? -cents : cents;
|
|
19
|
+
const whole = abs / 100n;
|
|
20
|
+
const fraction = (abs % 100n).toString().padStart(2, "0");
|
|
21
|
+
return `${negative ? "-" : ""}${whole}.${fraction}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Canonical two-decimal rendering of a valid amount string. */
|
|
25
|
+
export function normalizeAmount(value) {
|
|
26
|
+
return fromCents(toCents(value));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isRate(value) {
|
|
30
|
+
return typeof value === "string" && RATE.test(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function toMicros(rate) {
|
|
34
|
+
const [whole, fraction] = rate.split(".");
|
|
35
|
+
return BigInt(whole) * 1000000n + BigInt(fraction);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function fromMicros(micros) {
|
|
39
|
+
return `${micros / 1000000n}.${(micros % 1000000n).toString().padStart(6, "0")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Integer division rounding half up (non-negative operands). */
|
|
43
|
+
export function divRound(numerator, denominator) {
|
|
44
|
+
return (numerator * 2n + denominator) / (denominator * 2n);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** amount (cents) × rate (micros) → cents, half up. */
|
|
48
|
+
export function convert(cents, rateMicros) {
|
|
49
|
+
return divRound(cents * rateMicros, 1000000n);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Inverse conversion: target cents ÷ rate → source cents, half up. */
|
|
53
|
+
export function convertBack(cents, rateMicros) {
|
|
54
|
+
return rateMicros === 0n ? 0n : divRound(cents * 1000000n, rateMicros);
|
|
55
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Trolley page-number paging: `page` (≥ 1), `pageSize` (1–1000, default 10), `meta { page, pages, records }`.
|
|
2
|
+
import { jsonBytes } from "./store.mjs";
|
|
3
|
+
import { checkEnum, checkText, hasOwn, invalid } from "./validate.mjs";
|
|
4
|
+
|
|
5
|
+
const BYTE_BUDGET = 900000;
|
|
6
|
+
|
|
7
|
+
export function pageArgs(context, input) {
|
|
8
|
+
const page = hasOwn(input, "page") ? input.page : 1;
|
|
9
|
+
const pageSize = hasOwn(input, "pageSize") ? input.pageSize : 10;
|
|
10
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > 1000000) invalid(context, "page", "Value must be a positive integer");
|
|
11
|
+
if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 1000) invalid(context, "pageSize", "Value must be between 1 and 1000");
|
|
12
|
+
return { page, pageSize };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Optional filter string: checked for invalid characters and length. */
|
|
16
|
+
export function filterText(context, input, key, max = 200) {
|
|
17
|
+
if (!hasOwn(input, key)) return undefined;
|
|
18
|
+
return checkText(context, input[key], key, max);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Comma list filter (`country=CA,GB`), trimmed, empty entries dropped. */
|
|
22
|
+
export function filterList(context, input, key) {
|
|
23
|
+
const text = filterText(context, input, key, 1000);
|
|
24
|
+
if (text === undefined) return undefined;
|
|
25
|
+
const values = text.split(",").map((part) => part.trim()).filter((part) => part !== "");
|
|
26
|
+
if (values.length === 0) invalid(context, key);
|
|
27
|
+
return values;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function sortArgs(context, input, allowed, fallback) {
|
|
31
|
+
const orderBy = hasOwn(input, "orderBy") ? checkEnum(context, input.orderBy, allowed, "orderBy") : fallback;
|
|
32
|
+
const sortBy = hasOwn(input, "sortBy") ? checkEnum(context, input.sortBy, ["asc", "desc"], "sortBy") : "desc";
|
|
33
|
+
return { orderBy, sortBy };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function compareValues(a, b) {
|
|
37
|
+
if (a === b) return 0;
|
|
38
|
+
if (a === null || a === undefined) return 1;
|
|
39
|
+
if (b === null || b === undefined) return -1;
|
|
40
|
+
if (typeof a === "bigint" || typeof a === "number") return a < b ? -1 : 1;
|
|
41
|
+
const x = String(a).toLowerCase();
|
|
42
|
+
const y = String(b).toLowerCase();
|
|
43
|
+
if (x === y) return String(a) < String(b) ? -1 : 1;
|
|
44
|
+
return x < y ? -1 : 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Sorts in place by `key(item)`; nulls last in both directions; ties broken by id ascending. */
|
|
48
|
+
export function sortItems(items, key, direction) {
|
|
49
|
+
return items.sort((left, right) => {
|
|
50
|
+
const primary = compareValues(key(left), key(right));
|
|
51
|
+
if (primary !== 0) return direction === "asc" ? primary : -primary;
|
|
52
|
+
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One page of `items` as `{ [listKey]: mapped, meta }`, refusing bodies over the byte budget. */
|
|
57
|
+
export function pageOf(context, items, { page, pageSize }, listKey, map = (item) => item) {
|
|
58
|
+
const records = items.length;
|
|
59
|
+
const pages = Math.max(1, Math.ceil(records / pageSize));
|
|
60
|
+
const start = (page - 1) * pageSize;
|
|
61
|
+
const slice = start >= records ? [] : items.slice(start, start + pageSize).map(map);
|
|
62
|
+
const body = { ok: true, [listKey]: slice, meta: { page, pages, records } };
|
|
63
|
+
if (jsonBytes(body) > BYTE_BUDGET) invalid(context, "pageSize", "Response too large, reduce pageSize");
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Synthetic FX, fee and route-minimum schedule from `meta/fx` and `meta/fees`. Not Trolley's pricing or live rates.
|
|
2
|
+
import { convert, convertBack, fromCents, fromMicros, isAmount, isRate, toCents, toMicros } from "./money.mjs";
|
|
3
|
+
import { invalid } from "./validate.mjs";
|
|
4
|
+
|
|
5
|
+
const EURO_ROUTE = ["DE", "FR", "ES", "NL", "IE", "IT", "PT", "BE", "AT", "FI"];
|
|
6
|
+
|
|
7
|
+
function fxRow(context) {
|
|
8
|
+
const row = context.state.get("meta", "fx");
|
|
9
|
+
return row !== null && row.rates !== null && typeof row.rates === "object" ? row : { base: "USD", rates: { USD: "1.000000" } };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function baseRate(context, currency, field) {
|
|
13
|
+
const rates = fxRow(context).rates;
|
|
14
|
+
const value = Object.hasOwn(rates, currency) ? rates[currency] : undefined;
|
|
15
|
+
if (!isRate(value) || toMicros(value) === 0n) invalid(context, field, "Currency is not supported for conversion");
|
|
16
|
+
return toMicros(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Cross rate source → target in micro-units, half up to 6 decimals. */
|
|
20
|
+
export function crossRate(context, source, target, field = "currency") {
|
|
21
|
+
if (source === target) return 1000000n;
|
|
22
|
+
const from = baseRate(context, source, field);
|
|
23
|
+
const to = baseRate(context, target, field);
|
|
24
|
+
return (to * 1000000n * 2n + from) / (from * 2n);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function feesRow(context) {
|
|
28
|
+
const row = context.state.get("meta", "fees");
|
|
29
|
+
return row !== null && typeof row === "object" ? row : { currency: "USD", fixed: {}, minimums: {} };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function scheduleCents(context, table, method, currency) {
|
|
33
|
+
const row = feesRow(context);
|
|
34
|
+
const entries = row[table] !== null && typeof row[table] === "object" ? row[table] : {};
|
|
35
|
+
const value = Object.hasOwn(entries, method) ? entries[method] : "0.00";
|
|
36
|
+
const cents = isAmount(value) ? toCents(value) : 0n;
|
|
37
|
+
const scheduleCurrency = typeof row.currency === "string" ? row.currency : "USD";
|
|
38
|
+
return convert(cents, crossRate(context, scheduleCurrency, currency));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const feeCents = (context, method, currency) => scheduleCents(context, "fixed", method, currency);
|
|
42
|
+
export const minimumCents = (context, method, currency) => scheduleCents(context, "minimums", method, currency);
|
|
43
|
+
|
|
44
|
+
export function routeType(account) {
|
|
45
|
+
if (account === null || account === undefined) return null;
|
|
46
|
+
if (account.type !== "bank-transfer") return account.type;
|
|
47
|
+
if (account.country === "US") return "ach";
|
|
48
|
+
if (account.country === "CA") return "eft";
|
|
49
|
+
if (account.country === "GB") return "bacs";
|
|
50
|
+
if (EURO_ROUTE.includes(account.country)) return "sepa";
|
|
51
|
+
return "wire";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Recomputes a payment row's amounts for `account` from the side the caller fixed (`amountSide`).
|
|
56
|
+
* PayPal pays in the source currency (never converted). Fees are charged in the source currency;
|
|
57
|
+
* `coverFees` moves them from the recipient to the merchant.
|
|
58
|
+
*/
|
|
59
|
+
export function pricePayment(context, row, account) {
|
|
60
|
+
const method = account.type;
|
|
61
|
+
const sourceCurrency = row.sourceCurrency;
|
|
62
|
+
const targetCurrency = method === "paypal" ? sourceCurrency : account.currency;
|
|
63
|
+
const rate = crossRate(context, sourceCurrency, targetCurrency);
|
|
64
|
+
const fee = feeCents(context, method, sourceCurrency);
|
|
65
|
+
const recipientFee = row.coverFees ? 0n : fee;
|
|
66
|
+
let source;
|
|
67
|
+
let target;
|
|
68
|
+
if (row.amountSide === "target" && targetCurrency !== sourceCurrency) {
|
|
69
|
+
target = toCents(row.targetAmount);
|
|
70
|
+
source = convertBack(target, rate) + recipientFee;
|
|
71
|
+
} else {
|
|
72
|
+
source = toCents(row.sourceAmount);
|
|
73
|
+
const net = source > recipientFee ? source - recipientFee : 0n;
|
|
74
|
+
target = convert(net, rate);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
...row,
|
|
78
|
+
accountId: account.id,
|
|
79
|
+
payoutMethod: method,
|
|
80
|
+
sourceAmount: fromCents(source),
|
|
81
|
+
targetAmount: fromCents(target),
|
|
82
|
+
targetCurrency,
|
|
83
|
+
exchangeRate: fromMicros(rate),
|
|
84
|
+
fees: fromCents(fee),
|
|
85
|
+
recipientFees: fromCents(recipientFee),
|
|
86
|
+
merchantFees: fromCents(row.coverFees ? fee : 0n),
|
|
87
|
+
};
|
|
88
|
+
}
|