@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.
Files changed (78) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +219 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +2109 -0
  5. package/firedrill/conformance.suite.json +19 -0
  6. package/firedrill/partner-outage.scenario.json +11 -0
  7. package/firedrill/processing-response-lost.scenario.json +11 -0
  8. package/firedrill/rate-limited.scenario.json +11 -0
  9. package/firedrill/tight-limits.scenario.json +16 -0
  10. package/firedrill/tools/trolley/app/assets/ATTRIBUTION.md +35 -0
  11. package/firedrill/tools/trolley/app/assets/cropped-Site-Icon-512x512-px-1-192x192.png +0 -0
  12. package/firedrill/tools/trolley/app/assets/fonts/OFL.txt +93 -0
  13. package/firedrill/tools/trolley/app/assets/full-logo.svg +1 -0
  14. package/firedrill/tools/trolley/app/assets/logo-black.svg +18 -0
  15. package/firedrill/tools/trolley/app/site/app.js +139 -0
  16. package/firedrill/tools/trolley/app/site/assets/fonts/inter-latin.woff2 +0 -0
  17. package/firedrill/tools/trolley/app/site/assets/trolley-icon.png +0 -0
  18. package/firedrill/tools/trolley/app/site/assets/trolley-logo.svg +1 -0
  19. package/firedrill/tools/trolley/app/site/base.css +99 -0
  20. package/firedrill/tools/trolley/app/site/batch-actions.js +139 -0
  21. package/firedrill/tools/trolley/app/site/components.css +149 -0
  22. package/firedrill/tools/trolley/app/site/icons.js +50 -0
  23. package/firedrill/tools/trolley/app/site/index.html +53 -0
  24. package/firedrill/tools/trolley/app/site/list.js +87 -0
  25. package/firedrill/tools/trolley/app/site/ui.js +181 -0
  26. package/firedrill/tools/trolley/app/site/view-balances.js +30 -0
  27. package/firedrill/tools/trolley/app/site/view-batch.js +86 -0
  28. package/firedrill/tools/trolley/app/site/view-batches.js +78 -0
  29. package/firedrill/tools/trolley/app/site/view-dashboard.js +79 -0
  30. package/firedrill/tools/trolley/app/site/view-methods.js +80 -0
  31. package/firedrill/tools/trolley/app/site/view-recipient.js +124 -0
  32. package/firedrill/tools/trolley/app/site/view-recipients.js +115 -0
  33. package/firedrill/tools/trolley/behavior.mjs +78 -0
  34. package/firedrill/tools/trolley/lib/access.mjs +21 -0
  35. package/firedrill/tools/trolley/lib/ids.mjs +28 -0
  36. package/firedrill/tools/trolley/lib/money.mjs +55 -0
  37. package/firedrill/tools/trolley/lib/paging.mjs +65 -0
  38. package/firedrill/tools/trolley/lib/pricing.mjs +88 -0
  39. package/firedrill/tools/trolley/lib/records.mjs +87 -0
  40. package/firedrill/tools/trolley/lib/serialize.mjs +152 -0
  41. package/firedrill/tools/trolley/lib/store.mjs +61 -0
  42. package/firedrill/tools/trolley/lib/time.mjs +60 -0
  43. package/firedrill/tools/trolley/lib/validate.mjs +111 -0
  44. package/firedrill/tools/trolley/lib/wire.mjs +145 -0
  45. package/firedrill/tools/trolley/ops/accounts-write.mjs +117 -0
  46. package/firedrill/tools/trolley/ops/accounts.mjs +129 -0
  47. package/firedrill/tools/trolley/ops/balances.mjs +23 -0
  48. package/firedrill/tools/trolley/ops/batches.mjs +139 -0
  49. package/firedrill/tools/trolley/ops/payment-build.mjs +79 -0
  50. package/firedrill/tools/trolley/ops/payments.mjs +112 -0
  51. package/firedrill/tools/trolley/ops/processing.mjs +104 -0
  52. package/firedrill/tools/trolley/ops/recipients-list.mjs +58 -0
  53. package/firedrill/tools/trolley/ops/recipients.mjs +127 -0
  54. package/firedrill/tools/trolley/trolley.tool.json +10095 -0
  55. package/firedrill/trolley-accounts.drill.json +205 -0
  56. package/firedrill/trolley-batch-lifecycle.drill.json +522 -0
  57. package/firedrill/trolley-denied.drill.json +74 -0
  58. package/firedrill/trolley-fresh-install.drill.json +68 -0
  59. package/firedrill/trolley-invalid-key.drill.json +743 -0
  60. package/firedrill/trolley-partner-outage.drill.json +97 -0
  61. package/firedrill/trolley-processing-rules.drill.json +138 -0
  62. package/firedrill/trolley-rate-limited.drill.json +118 -0
  63. package/firedrill/trolley-read-only.drill.json +468 -0
  64. package/firedrill/trolley-recipients.drill.json +183 -0
  65. package/firedrill/trolley-response-lost.drill.json +127 -0
  66. package/firedrill/trolley-tight-limits.drill.json +348 -0
  67. package/firedrill/world.json +2539 -0
  68. package/firedrill.json +5 -0
  69. package/package.json +64 -0
  70. package/starter.json +2108 -0
  71. package/test/conformance.mjs +12 -0
  72. package/test/flow-access.mjs +54 -0
  73. package/test/flow-accounts.mjs +63 -0
  74. package/test/flow-batches.mjs +107 -0
  75. package/test/flow-faults.mjs +58 -0
  76. package/test/flow-processing.mjs +41 -0
  77. package/test/flow-recipients.mjs +93 -0
  78. package/test/harness.mjs +87 -0
@@ -0,0 +1,12 @@
1
+ // Trolley Tool conformance target. A scripted Tool test, not a model-driven agent.
2
+ // Node built-ins only: fetch against the Trolley-shaped REST routes (`Authorization: prsign <token>` or `Bearer`) and the
3
+ // canonical Firedrill operation endpoint. Every flow fails loudly on an unexpected status, envelope or field.
4
+ import { main } from "./harness.mjs";
5
+ import "./flow-recipients.mjs";
6
+ import "./flow-accounts.mjs";
7
+ import "./flow-batches.mjs";
8
+ import "./flow-processing.mjs";
9
+ import "./flow-access.mjs";
10
+ import "./flow-faults.mjs";
11
+
12
+ await main();
@@ -0,0 +1,54 @@
1
+ // Access flows: read-only key, another merchant's key, fresh-install actor, actor without grants.
2
+ import { A, B, P, R, api, assert, fails, flow } from "./harness.mjs";
3
+
4
+ const payment = { recipient: { id: R(1) }, amount: "10.00", currency: "USD" };
5
+ /** One schema-valid request per write operation (14). */
6
+ const WRITES = [
7
+ ["POST", "/v1/recipients", { type: "individual", email: "probe@example.com", firstName: "Pro", lastName: "Be" }],
8
+ ["PATCH", `/v1/recipients/${R(1)}`, { phone: "503-555-0199" }],
9
+ ["DELETE", `/v1/recipients/${R(9)}`],
10
+ ["POST", `/v1/recipients/${R(10)}/accounts`, { type: "paypal", emailAddress: "harper.lin.payouts@example.com" }],
11
+ ["PATCH", `/v1/recipients/${R(3)}/accounts/${A(4)}`, { primary: true }],
12
+ ["DELETE", `/v1/recipients/${R(3)}/accounts/${A(4)}`],
13
+ ["POST", "/v1/batches", { description: "Probe" }],
14
+ ["PATCH", `/v1/batches/${B(1)}`, { description: "Probe" }],
15
+ ["DELETE", `/v1/batches/${B(6)}`],
16
+ ["POST", `/v1/batches/${B(2)}/generate-quote`],
17
+ ["POST", `/v1/batches/${B(1)}/start-processing`],
18
+ ["POST", `/v1/batches/${B(1)}/payments`, payment],
19
+ ["PATCH", `/v1/batches/${B(1)}/payments/${P(1)}`, { memo: "Probe" }],
20
+ ["DELETE", `/v1/batches/${B(1)}/payments/${P(1)}`],
21
+ ];
22
+ /** One request per read operation (10, covering both payments.get and balances.list paths). */
23
+ const READS = [
24
+ ["GET", `/v1/recipients/${R(1)}`], ["GET", "/v1/recipients"], ["GET", `/v1/recipients/${R(3)}/accounts`],
25
+ ["GET", `/v1/recipients/${R(3)}/accounts/${A(3)}`], ["GET", `/v1/batches/${B(1)}`], ["GET", "/v1/batches"],
26
+ ["GET", `/v1/batches/${B(1)}/summary`], ["GET", `/v1/batches/${B(1)}/payments`], ["GET", `/v1/batches/${B(1)}/payments/${P(1)}`],
27
+ ["GET", `/v1/payments/${P(1)}`], ["GET", "/v1/balances"], ["GET", "/v1/balances/paymentrails"],
28
+ ];
29
+
30
+ flow("read-only", async () => {
31
+ for (const [method, path] of READS) await api(method, path);
32
+ for (const [method, path, body] of WRITES) await fails(method, path, 403, "not_authorized", { body });
33
+ assert.equal((await api("GET", "/v1/batches")).meta.records, 11);
34
+ assert.equal((await api("GET", "/v1/recipients?status=archived")).meta.records, 1);
35
+ });
36
+
37
+ flow("invalid-key", async () => {
38
+ for (const [method, path, body] of [...READS, ...WRITES]) await fails(method, path, 401, "invalid_api_key", { body });
39
+ });
40
+
41
+ flow("fresh-install", async () => {
42
+ assert.equal((await api("GET", "/v1/recipients")).meta.records, 13);
43
+ assert.equal((await api("GET", "/v1/balances")).balances.length, 3);
44
+ const batch = (await api("POST", "/v1/batches", { body: { description: "Fresh install check" } })).batch;
45
+ assert.equal(batch.status, "open");
46
+ assert.equal(batch.amount, "0.00");
47
+ assert.match(batch.id, /^B-[0-9A-Za-z]{22}$/);
48
+ });
49
+
50
+ flow("denied", async () => {
51
+ await fails("GET", "/v1/recipients", 403, "not_authorized");
52
+ await fails("POST", "/v1/batches", 403, "not_authorized", { body: { description: "Denied" } });
53
+ await fails("POST", `/v1/batches/${B(1)}/start-processing`, 403, "not_authorized");
54
+ });
@@ -0,0 +1,63 @@
1
+ // Recipient accounts flow: list, get, create per payout method, primary switching, immutable fields, disable.
2
+ import { A, R, api, assert, fails, flow } from "./harness.mjs";
3
+
4
+ flow("accounts", async () => {
5
+ await fails("POST", `/v1/recipients/${R(999)}/accounts`, 404, "not_found", { body: { type: "paypal", emailAddress: "nobody@example.com" } });
6
+ await fails("PATCH", `/v1/recipients/${R(3)}/accounts/${A(99)}`, 404, "not_found", { body: { primary: true } });
7
+ const listed = await api("GET", `/v1/recipients/${R(3)}/accounts`);
8
+ assert.deepEqual(listed.accounts.map((account) => account.id), [A(3), A(4)], "active accounts, primary first");
9
+ assert.equal(listed.accounts[0].primary, true);
10
+ assert.equal(listed.accounts[0].accountNum, "*****5521");
11
+ const disabled = (await api("GET", `/v1/recipients/${R(3)}/accounts/${A(5)}`)).account;
12
+ assert.equal(disabled.status, "disabled");
13
+ assert.equal(disabled.disabledAt, "2026-07-02T15:30:00.000Z");
14
+ await fails("GET", `/v1/recipients/${R(3)}/accounts/${A(1)}`, 404, "not_found");
15
+ await fails("GET", `/v1/recipients/${R(999)}/accounts`, 404, "not_found");
16
+
17
+ const bank = { type: "bank-transfer", currency: "USD", country: "US", accountHolderName: "Maya Castellanos", accountNum: "000123456789" };
18
+ await fails("POST", `/v1/recipients/${R(1)}/accounts`, 400, "empty_field", { field: "branchId", body: bank });
19
+ const created = (await api("POST", `/v1/recipients/${R(1)}/accounts`, { body: { ...bank, branchId: "000222333" } })).account;
20
+ assert.equal(created.accountNum, "*****6789");
21
+ assert.equal(created.primary, false, "an existing primary stays primary");
22
+ assert.equal(created.recipientAccountId, created.id);
23
+ assert.equal(created.routeType, "ach");
24
+
25
+ const promoted = (await api("PATCH", `/v1/recipients/${R(1)}/accounts/${created.id}`, { body: { primary: true } })).account;
26
+ assert.equal(promoted.primary, true);
27
+ const relisted = await api("GET", `/v1/recipients/${R(1)}/accounts`);
28
+ assert.deepEqual(relisted.accounts.map((account) => [account.id, account.primary]), [[created.id, true], [A(1), false]]);
29
+ await fails("PATCH", `/v1/recipients/${R(1)}/accounts/${created.id}`, 400, "invalid_field", { field: "accountNum", body: { accountNum: "999999999" } });
30
+ await fails("PATCH", `/v1/recipients/${R(1)}/accounts/${created.id}`, 400, "invalid_field", { field: "primary", body: { primary: false } });
31
+ await fails("PATCH", `/v1/recipients/${R(1)}/accounts/${created.id}`, 400, "invalid_field", { field: "emailAddress", body: { emailAddress: "x@example.com" } });
32
+ await fails("PATCH", `/v1/recipients/${R(1)}/accounts/${created.id}`, 400, "empty_field", { field: "body", body: {} });
33
+ const renamed = (await api("PATCH", `/v1/recipients/${R(1)}/accounts/${created.id}`, { body: { accountHolderName: "Maya R. Castellanos" } })).account;
34
+ assert.equal(renamed.accountHolderName, "Maya R. Castellanos");
35
+
36
+ await fails("POST", `/v1/recipients/${R(1)}/accounts`, 400, "invalid_field", { field: "type", body: { type: "debit-card" } });
37
+ const mailing = { name: "Maya Castellanos", street1: "1 Rue Principale", city: "Québec", region: "QC", postal: "G1R 4P5", country: "CA" };
38
+ await fails("POST", `/v1/recipients/${R(1)}/accounts`, 400, "invalid_field", { field: "mailing.country", body: { type: "check", mailing } });
39
+ await fails("POST", `/v1/recipients/${R(11)}/accounts`, 400, "invalid_status", { body: { type: "paypal", emailAddress: "old.jordan@example.com" } });
40
+ await fails("POST", `/v1/recipients/${R(5)}/accounts`, 400, "invalid_field", { field: "iban", body: { type: "bank-transfer", currency: "EUR", country: "DE", accountHolderName: "L W", iban: "FR7630006000011234567890189" } });
41
+ const iban = (await api("POST", `/v1/recipients/${R(5)}/accounts`, { body: { type: "bank-transfer", currency: "EUR", country: "DE", accountHolderName: "Lukasz Wisniewski", iban: "DE89 3704 0044 0532 0130 00" } })).account;
42
+ assert.equal(iban.iban, "*****3000");
43
+ assert.equal(iban.accountNum, null);
44
+ assert.equal(iban.routeType, "sepa");
45
+
46
+ // Harper Lin has an address but no payout method: adding Venmo makes the recipient active.
47
+ const venmo = (await api("POST", `/v1/recipients/${R(10)}/accounts`, { body: { type: "venmo", phoneNumber: "(206) 555-0110" } })).account;
48
+ assert.equal(venmo.primary, true);
49
+ assert.equal(venmo.phoneNumber, "2065550110");
50
+ assert.equal((await api("GET", `/v1/recipients/${R(10)}`)).recipient.status, "active");
51
+
52
+ // Removing Maya's primary leaves no primary: the recipient becomes incomplete until another account is promoted.
53
+ assert.deepEqual(await api("DELETE", `/v1/recipients/${R(1)}/accounts/${created.id}`), { ok: true });
54
+ assert.equal((await api("GET", `/v1/recipients/${R(1)}`)).recipient.status, "incomplete");
55
+ await fails("DELETE", `/v1/recipients/${R(1)}/accounts/${created.id}`, 400, "invalid_status");
56
+ await fails("PATCH", `/v1/recipients/${R(1)}/accounts/${created.id}`, 400, "invalid_status", { body: { accountHolderName: "Nobody" } });
57
+ await fails("DELETE", `/v1/recipients/${R(1)}/accounts/${A(1)}`, 400, "invalid_status");
58
+ await fails("DELETE", `/v1/recipients/${R(1)}/accounts/${A(99)}`, 404, "not_found");
59
+ const repromoted = (await api("PATCH", `/v1/recipients/${R(1)}/accounts/${A(1)}`, { body: { primary: true } })).account;
60
+ assert.equal(repromoted.status, "primary");
61
+ assert.equal((await api("GET", `/v1/recipients/${R(1)}`)).recipient.status, "active");
62
+ assert.equal((await api("GET", `/v1/recipients/${R(1)}/accounts/${created.id}`)).account.status, "disabled");
63
+ });
@@ -0,0 +1,107 @@
1
+ // Batch lifecycle flow: batches and payments CRUD, summary, quote, processing, terminal-state refusals, balances.
2
+ import { B, P, R, api, assert, fails, flow } from "./harness.mjs";
3
+
4
+ flow("batch-lifecycle", async () => {
5
+ // Declared errors of refused writes (nothing changes).
6
+ await fails("POST", "/v1/batches", 400, "empty_field", { field: "payments[0].recipient", body: { payments: [{ amount: "5.00", currency: "USD" }] } });
7
+ await fails("POST", "/v1/batches", 404, "not_found", { field: "payments[0].recipient", body: { payments: [{ recipient: { id: R(99) }, amount: "5.00", currency: "USD" }] } });
8
+ await fails("POST", "/v1/batches", 400, "invalid_status", { field: "payments[0].recipient", body: { payments: [{ recipient: { id: R(12) }, amount: "5.00", currency: "USD" }] } });
9
+ await fails("PATCH", `/v1/batches/${B(99)}`, 404, "not_found", { body: { description: "x" } });
10
+ await fails("DELETE", `/v1/batches/${B(99)}`, 404, "not_found");
11
+ await fails("POST", `/v1/batches/${B(99)}/generate-quote`, 404, "not_found");
12
+ await fails("GET", `/v1/batches/${B(1)}/payments?status=bogus`, 400, "invalid_field", { field: "status" });
13
+ await fails("PATCH", `/v1/batches/${B(1)}/payments/${P(1)}`, 400, "empty_field", { field: "body", body: {} });
14
+ await fails("PATCH", `/v1/batches/${B(1)}/payments/${P(99)}`, 404, "not_found", { body: { memo: "x" } });
15
+ await fails("DELETE", `/v1/batches/${B(1)}/payments/${P(99)}`, 404, "not_found");
16
+ assert.equal((await api("GET", "/v1/batches")).meta.records, 11);
17
+ const listed = await api("GET", "/v1/batches");
18
+ assert.equal(listed.batches.length, 10);
19
+ assert.deepEqual(listed.meta, { page: 1, pages: 2, records: 11 });
20
+ assert.equal((await api("GET", "/v1/batches?status=open")).meta.records, 6);
21
+ assert.deepEqual((await api("GET", "/v1/batches?search=editors")).batches.map((batch) => batch.id), [B(2)]);
22
+ const byAmount = await api("GET", "/v1/batches?orderBy=amount&sortBy=desc&pageSize=1");
23
+ assert.equal(byAmount.batches[0].id, B(3));
24
+ await fails("GET", "/v1/batches?status=bogus", 400, "invalid_field", { field: "status" });
25
+
26
+ const created = (await api("POST", "/v1/batches", {
27
+ body: {
28
+ description: "Freelance fixes", tags: ["adhoc"],
29
+ payments: [
30
+ { recipient: { email: "priya.raman@example.com" }, amount: "25.00", currency: "USD", memo: "Thumbnail" },
31
+ { recipient: { referenceId: "CR-1004" }, amount: "100.00", currency: "GBP", memo: "Captions" },
32
+ ],
33
+ },
34
+ })).batch;
35
+ assert.equal(created.status, "open");
36
+ assert.equal(created.currency, "USD");
37
+ assert.equal(created.totalPayments, 2);
38
+ assert.equal(created.amount, "154.04", "25.00 + (100.00 GBP / 0.781 + 1.00 fee)");
39
+ const reread = (await api("GET", `/v1/batches/${created.id}`)).batch;
40
+ assert.deepEqual([reread.status, reread.amount, reread.totalPayments, reread.description], ["open", "154.04", 2, "Freelance fixes"]);
41
+ assert.equal((await api("GET", "/v1/batches")).meta.records, 12, "the new batch is listed");
42
+ await fails("POST", "/v1/batches", 400, "invalid_field", { field: "currency", body: { currency: "XYZ" } });
43
+ await fails("GET", `/v1/batches/${B(99)}`, 404, "not_found");
44
+
45
+ const payments = await api("GET", `/v1/batches/${created.id}/payments`);
46
+ assert.equal(payments.payments.length, 2);
47
+ const [thumb, captions] = payments.payments;
48
+ assert.equal(captions.targetAmount, "100.00");
49
+ assert.equal(captions.targetCurrency, "GBP");
50
+ assert.equal(captions.exchangeRate, "0.781000");
51
+ await fails("POST", `/v1/batches/${created.id}/payments`, 404, "not_found", { field: "recipient", body: { recipient: { id: R(99) }, amount: "5.00", currency: "USD" } });
52
+ await fails("POST", `/v1/batches/${created.id}/payments`, 400, "invalid_status", { field: "recipient", body: { recipient: { id: R(12) }, amount: "5.00", currency: "USD" } });
53
+ await fails("POST", `/v1/batches/${created.id}/payments`, 400, "invalid_field", { field: "amount", body: { recipient: { id: R(1) }, amount: "10.5", currency: "USD" } });
54
+ await fails("POST", `/v1/batches/${created.id}/payments`, 400, "empty_field", { field: "recipient", body: { amount: "10.00", currency: "USD" } });
55
+ const added = (await api("POST", `/v1/batches/${created.id}/payments`, { body: { recipient: { id: R(7) }, amount: "12.00", currency: "USD", externalId: "FIX-3", category: "services" } })).payment;
56
+ assert.equal(added.payoutMethod, "venmo");
57
+ assert.equal(added.recipientFees, "0.75");
58
+ await fails("POST", `/v1/batches/${created.id}/payments`, 400, "invalid_field", { field: "externalId", body: { recipient: { id: R(1) }, amount: "10.00", currency: "USD", externalId: "FIX-3" } });
59
+
60
+ const patched = (await api("PATCH", `/v1/batches/${created.id}/payments/${thumb.id}`, { body: { memo: "Thumbnail set", coverFees: true } })).payment;
61
+ assert.equal(patched.memo, "Thumbnail set");
62
+ assert.equal(patched.merchantFees, "1.00");
63
+ await fails("PATCH", `/v1/batches/${created.id}/payments/${thumb.id}`, 400, "invalid_field", { field: "recipient", body: { recipient: { id: R(1) } } });
64
+ assert.equal((await api("GET", `/v1/batches/${created.id}/payments/${thumb.id}`)).payment.memo, "Thumbnail set");
65
+ const direct = (await api("GET", `/v1/payments/${captions.id}`)).payment;
66
+ assert.equal(direct.batch.id, created.id);
67
+ assert.equal(direct.recipient.id, R(4));
68
+ await fails("GET", `/v1/batches/${B(1)}/payments/${captions.id}`, 404, "not_found");
69
+ await fails("GET", `/v1/batches/${B(99)}/payments`, 404, "not_found");
70
+ assert.equal((await api("GET", `/v1/batches/${created.id}/payments?search=captions`)).meta.records, 1);
71
+ assert.deepEqual(await api("DELETE", `/v1/batches/${created.id}/payments/${captions.id}`), { ok: true });
72
+ await fails("GET", `/v1/payments/${captions.id}`, 404, "not_found");
73
+ const updated = (await api("PATCH", `/v1/batches/${created.id}`, { body: { description: "Freelance fixes (September)" } })).batch;
74
+ assert.equal(updated.totalPayments, 2);
75
+ assert.equal(updated.amount, "37.00");
76
+ await fails("PATCH", `/v1/batches/${created.id}`, 400, "invalid_field", { field: "currency", body: { currency: "CAD" } });
77
+
78
+ const summary = (await api("GET", `/v1/batches/${B(1)}/summary`)).batchSummary;
79
+ assert.deepEqual(summary.detail.paypal, { count: 1, totalFees: "1.00", merchantFees: "1.00", debitAmount: "441.00", sendingAmount: "440.00", totalWithheld: "0.00" });
80
+ assert.deepEqual(summary.total, { count: 3, totalFees: "2.75", merchantFees: "1.00", debitAmount: "1241.00", sendingAmount: "1240.00", totalWithheld: "0.00" });
81
+ await fails("GET", `/v1/batches/${B(99)}/summary`, 404, "not_found");
82
+
83
+ const quoted = (await api("POST", `/v1/batches/${B(2)}/generate-quote`, { raw: "{}", headers: { "content-type": "application/json" } })).batch;
84
+ assert.equal(quoted.quoteExpiredAt, "2026-09-15T15:30:00.000Z");
85
+ const fx = (await api("GET", `/v1/batches/${B(2)}/payments`)).payments;
86
+ assert.deepEqual(fx.map((payment) => [payment.targetCurrency, payment.targetAmount]), [["GBP", "624.02"], ["EUR", "591.89"]]);
87
+
88
+ const processed = (await api("POST", `/v1/batches/${B(1)}/start-processing`)).batch;
89
+ assert.equal(processed.status, "complete");
90
+ assert.equal(processed.sentAt, "2026-09-15T14:00:00.000Z");
91
+ const settled = (await api("GET", `/v1/batches/${B(1)}/payments?status=processed`)).payments;
92
+ assert.equal(settled.length, 3);
93
+ const balances = await api("GET", "/v1/balances");
94
+ assert.equal(balances.balances[0].amount, "17179.55");
95
+ assert.equal(balances.serverTime, "2026-09-15T14:00:00.000Z");
96
+ assert.equal((await api("GET", "/v1/balances/paypal")).balances.length, 1);
97
+ await fails("GET", "/v1/balances/venmo", 400, "invalid_field", { field: "kind" });
98
+
99
+ await fails("PATCH", `/v1/batches/${B(1)}/payments/${P(1)}`, 400, "invalid_status", { body: { memo: "late" } });
100
+ await fails("DELETE", `/v1/batches/${B(7)}/payments/${P(11)}`, 400, "invalid_status");
101
+ await fails("PATCH", `/v1/batches/${B(7)}`, 400, "invalid_status", { body: { description: "August" } });
102
+ await fails("DELETE", `/v1/batches/${B(1)}`, 400, "invalid_status");
103
+ await fails("POST", `/v1/batches/${B(1)}/generate-quote`, 406, "invalid_status");
104
+ await fails("POST", `/v1/batches/${B(1)}/start-processing`, 409, "invalid_status");
105
+ assert.deepEqual(await api("DELETE", `/v1/batches/${B(6)}`), { ok: true });
106
+ await fails("GET", `/v1/batches/${B(6)}`, 404, "not_found");
107
+ });
@@ -0,0 +1,58 @@
1
+ // Fault flows: rate limiting, payout partner outage, lost processing response, and the scan bound.
2
+ import { A, B, P, R, api, assert, fails, flow } from "./harness.mjs";
3
+
4
+ flow("rate-limited", async () => {
5
+ await fails("GET", "/v1/recipients", 429, "rate_limit_exceeded");
6
+ await fails("GET", "/v1/batches?status=open", 429, "rate_limit_exceeded");
7
+ await fails("GET", `/v1/batches/${B(1)}/payments`, 429, "rate_limit_exceeded");
8
+ assert.equal((await api("GET", `/v1/recipients/${R(1)}`)).recipient.id, R(1), "retrieves are not rate limited");
9
+ });
10
+
11
+ flow("partner-outage", async () => {
12
+ await fails("POST", `/v1/recipients/${R(10)}/accounts`, 500, "partner_integration_error", { body: { type: "paypal", emailAddress: "harper.lin.payouts@example.com" } });
13
+ assert.equal((await api("GET", `/v1/recipients/${R(10)}`)).recipient.accounts.length, 0, "no account was created");
14
+ await fails("POST", `/v1/batches/${B(2)}/generate-quote`, 500, "partner_integration_error");
15
+ assert.equal((await api("GET", `/v1/batches/${B(2)}`)).batch.quoteExpiredAt, "2026-09-15T12:30:00.000Z");
16
+ });
17
+
18
+ flow("response-lost", async () => {
19
+ await fails("POST", `/v1/batches/${B(1)}/start-processing`, 500, "internal_server_error");
20
+ // The response was lost, but the payout happened: check before retrying.
21
+ const batch = (await api("GET", `/v1/batches/${B(1)}`)).batch;
22
+ assert.equal(batch.status, "complete");
23
+ const balances = await api("GET", "/v1/balances");
24
+ assert.equal(balances.balances[0].amount, "17179.55");
25
+ // A careless retry is refused because the batch is no longer open; nothing is paid twice.
26
+ const retry = await api("POST", `/v1/batches/${B(1)}/start-processing`, { status: RETRY_STATUS });
27
+ assert.equal(retry.ok, false);
28
+ assert.equal((await api("GET", "/v1/balances")).balances[0].amount, "17179.55");
29
+ });
30
+
31
+ const RETRY_STATUS = 409;
32
+
33
+ flow("tight-limits", async () => {
34
+ const bound = (method, path, body) => fails(method, path, 500, "internal_server_error", { body });
35
+ await bound("POST", "/v1/recipients", { type: "individual", email: "bound.probe@example.com", firstName: "Bound", lastName: "Probe" });
36
+ await bound("GET", `/v1/recipients/${R(3)}`);
37
+ await bound("PATCH", `/v1/recipients/${R(1)}`, { email: "maya.c@example.com" });
38
+ await bound("DELETE", `/v1/recipients/${R(2)}`);
39
+ await bound("GET", "/v1/recipients");
40
+ await bound("POST", `/v1/recipients/${R(3)}/accounts`, { type: "paypal", emailAddress: "etienne.second@example.com" });
41
+ await bound("GET", `/v1/recipients/${R(3)}/accounts`);
42
+ await bound("PATCH", `/v1/recipients/${R(3)}/accounts/${A(4)}`, { primary: true });
43
+ await bound("DELETE", `/v1/recipients/${R(3)}/accounts/${A(4)}`);
44
+ await bound("POST", "/v1/batches", { payments: [{ recipient: { email: "maya.castellanos@example.com" }, amount: "10.00", currency: "USD" }] });
45
+ await bound("GET", `/v1/batches/${B(1)}`);
46
+ await bound("PATCH", `/v1/batches/${B(1)}`, { description: "bound" });
47
+ await bound("DELETE", `/v1/batches/${B(1)}`);
48
+ await bound("GET", "/v1/batches");
49
+ await bound("POST", `/v1/batches/${B(1)}/generate-quote`);
50
+ await bound("POST", `/v1/batches/${B(1)}/start-processing`);
51
+ await bound("GET", `/v1/batches/${B(1)}/summary`);
52
+ await bound("POST", `/v1/batches/${B(1)}/payments`, { recipient: { id: R(6) }, amount: "5.00", currency: "USD" });
53
+ await bound("GET", `/v1/batches/${B(1)}/payments`);
54
+ await bound("PATCH", `/v1/batches/${B(1)}/payments/${P(1)}`, { externalId: "BOUND-1" });
55
+ await bound("GET", "/v1/balances");
56
+ // Reads that need no scan still work.
57
+ assert.equal((await api("GET", `/v1/recipients/${R(1)}/accounts/${A(1)}`)).account.id, A(1));
58
+ });
@@ -0,0 +1,41 @@
1
+ // Processing rules flow: expired quote, insufficient funds, route minimum, empty batch, per-payment failures.
2
+ import { B, api, assert, fails, flow } from "./harness.mjs";
3
+
4
+ const balance = async () => (await api("GET", "/v1/balances/paymentrails")).balances.find((row) => row.currency === "USD").amount;
5
+
6
+ flow("processing-rules", async () => {
7
+ await fails("POST", `/v1/batches/${B(2)}/start-processing`, 400, "expired_quote");
8
+ assert.equal((await api("GET", `/v1/batches/${B(2)}`)).batch.status, "open", "a refused call changes nothing");
9
+ await api("POST", `/v1/batches/${B(2)}/generate-quote`);
10
+ assert.equal((await api("POST", `/v1/batches/${B(2)}/start-processing`)).batch.status, "complete");
11
+ assert.equal(await balance(), "16970.55");
12
+
13
+ await fails("POST", `/v1/batches/${B(3)}/start-processing`, 400, "non_sufficient_funds");
14
+ assert.equal(await balance(), "16970.55");
15
+ await fails("POST", `/v1/batches/${B(5)}/start-processing`, 400, "invalid_field", { field: "amount" });
16
+ await fails("POST", `/v1/batches/${B(6)}/start-processing`, 409, "invalid_status");
17
+ await fails("POST", `/v1/batches/${B(99)}/start-processing`, 404, "not_found");
18
+
19
+ const mixed = (await api("POST", `/v1/batches/${B(4)}/start-processing`)).batch;
20
+ assert.equal(mixed.status, "complete");
21
+ const results = (await api("GET", `/v1/batches/${B(4)}/payments`)).payments;
22
+ assert.deepEqual(results.map((payment) => [payment.status, payment.failureMessage]), [["processed", null], ["failed", "Recipient is not active"]]);
23
+ assert.equal(await balance(), "16820.55");
24
+
25
+ // A recipient that becomes incomplete after the payment was added: the payment fails and the batch fails.
26
+ const kai = (await api("POST", "/v1/recipients", {
27
+ body: {
28
+ type: "individual", email: "kai.moreno@example.com", firstName: "Kai", lastName: "Moreno",
29
+ address: { street1: "12 Ocean Ave", city: "San Diego", region: "CA", postalCode: "92101", country: "US" },
30
+ accounts: [{ type: "venmo", phoneNumber: "619-555-0142" }],
31
+ },
32
+ })).recipient;
33
+ assert.equal(kai.status, "active");
34
+ const batch = (await api("POST", "/v1/batches", { body: { description: "Kai one-off", payments: [{ recipient: { id: kai.id }, amount: "40.00", currency: "USD" }] } })).batch;
35
+ const incomplete = (await api("PATCH", `/v1/recipients/${kai.id}`, { body: { address: { street1: "" } } })).recipient;
36
+ assert.equal(incomplete.status, "incomplete");
37
+ const failed = (await api("POST", `/v1/batches/${batch.id}/start-processing`)).batch;
38
+ assert.equal(failed.status, "failed");
39
+ assert.equal(failed.completedAt, "2026-09-15T14:00:00.000Z");
40
+ assert.equal(await balance(), "16820.55", "failed payments debit nothing");
41
+ });
@@ -0,0 +1,93 @@
1
+ // Recipients flow: list paging, search, filters, ordering, validation, create, update, archive and restore.
2
+ import { B, R, api, assert, fails, flow } from "./harness.mjs";
3
+
4
+ flow("recipients", async () => {
5
+ const first = await api("GET", "/v1/recipients");
6
+ assert.equal(first.recipients.length, 10);
7
+ assert.deepEqual(first.meta, { page: 1, pages: 2, records: 13 });
8
+ assert.ok(first.recipients.every((recipient) => recipient.status !== "archived"), "archived recipients are hidden by default");
9
+ const second = await api("GET", "/v1/recipients?page=2", { scheme: "Bearer" });
10
+ assert.equal(second.recipients.length, 3);
11
+ const ids = new Set([...first.recipients, ...second.recipients].map((recipient) => recipient.id));
12
+ assert.equal(ids.size, 13, "pages do not overlap");
13
+ const beyond = await api("GET", "/v1/recipients?page=99");
14
+ assert.deepEqual(beyond.recipients, []);
15
+ assert.deepEqual(beyond.meta, { page: 99, pages: 2, records: 13 });
16
+
17
+ const quarry = await api("GET", "/v1/recipients?search=QUARRY");
18
+ assert.deepEqual(quarry.recipients.map((recipient) => recipient.id), [R(2)]);
19
+ const archived = await api("GET", "/v1/recipients?status=archived");
20
+ assert.deepEqual(archived.recipients.map((recipient) => recipient.id), [R(11)]);
21
+ const countries = await api("GET", "/v1/recipients?country=CA,GB&orderBy=name&sortBy=asc");
22
+ assert.deepEqual(countries.recipients.map((recipient) => recipient.id), [R(3), R(4)].sort((a, b) => (a === R(3) ? 1 : -1)));
23
+ const tagged = await api("GET", "/v1/recipients?tags=editor,video");
24
+ assert.deepEqual(tagged.recipients.map((recipient) => recipient.id), [R(6)]);
25
+ const byName = await api("GET", "/v1/recipients?orderBy=name&sortBy=asc&pageSize=1");
26
+ assert.equal(byName.recipients[0].name, "Estudio Nopal S.A. de C.V.");
27
+ const paypal = await api("GET", "/v1/recipients?payoutMethod=paypal&currency=usd");
28
+ assert.deepEqual(paypal.recipients.map((recipient) => recipient.id).sort(), [R(6), R(13)].sort());
29
+ await fails("GET", "/v1/recipients?pageSize=0", 400, "invalid_field", { field: "pageSize" });
30
+ await fails("GET", "/v1/recipients?pageSize=abc", 400, "invalid_field", { field: "pageSize" });
31
+ await fails("GET", "/v1/recipients?sortBy=up", 400, "invalid_field", { field: "sortBy" });
32
+ await fails("GET", "/v1/recipients?startDate=2026-01-01", 400, "invalid_field", { field: "startDate" });
33
+ await fails("GET", "/v1/recipients?search=%FF", 400, "invalid_field", { field: "search" });
34
+
35
+ const maya = (await api("GET", `/v1/recipients/${R(1)}`)).recipient;
36
+ assert.equal(maya.payoutMethod, "bank-transfer");
37
+ assert.equal(maya.routeType, "ach");
38
+ assert.equal(maya.primaryCurrency, "USD");
39
+ assert.equal(maya.accounts[0].accountNum, "*****2847");
40
+ await fails("GET", `/v1/recipients/${R(999)}`, 404, "not_found");
41
+ await fails("GET", "/v1/recipients/R-nope", 404, "not_found");
42
+
43
+ await fails("POST", "/v1/recipients", 400, "empty_field", { field: "firstName", body: { type: "individual", email: "rowan.pike@example.com", lastName: "Pike" } });
44
+ await fails("POST", "/v1/recipients", 400, "invalid_field", { field: "email", body: { type: "individual", email: "MAYA.castellanos@example.com", firstName: "M", lastName: "C" } });
45
+ await fails("POST", "/v1/recipients", 400, "invalid_field", { field: "email", body: { type: "individual", email: "not-an-email", firstName: "M", lastName: "C" } });
46
+
47
+ const studio = (await api("POST", "/v1/recipients", {
48
+ body: {
49
+ type: "business", email: "payables@northlantern.example.com", name: "North Lantern Studio", referenceId: "CR-2002",
50
+ address: { street1: "400 Mill Rd", city: "Burlington", region: "VT", postalCode: "05401", country: "US" },
51
+ accounts: [{ type: "check", mailing: { name: "North Lantern Studio", street1: "400 Mill Rd", city: "Burlington", region: "VT", postal: "05401", country: "US" } }],
52
+ },
53
+ })).recipient;
54
+ assert.equal(studio.status, "active");
55
+ assert.equal(studio.accounts.length, 1);
56
+ assert.equal(studio.accounts[0].primary, true);
57
+ assert.equal(studio.payoutMethod, "check");
58
+
59
+ const rowanBody = { type: "individual", email: "rowan.pike@example.com", firstName: "Rowan", lastName: "Pike", referenceId: "CR-2001", tags: ["writer"] };
60
+ const rowanKey = { "idempotency-key": "conformance-rowan-1" };
61
+ const rowan = (await api("POST", "/v1/recipients", { body: rowanBody, headers: rowanKey })).recipient;
62
+ assert.equal(rowan.status, "incomplete");
63
+ // Idempotency-Key (Firedrill addition): same key + same body replays the recorded recipient; a different body or an
64
+ // unsupported endpoint answers Trolley's invalid_field on the header; an over-long key is refused before dispatch.
65
+ const replay = (await api("POST", "/v1/recipients", { body: rowanBody, headers: rowanKey })).recipient;
66
+ assert.equal(replay.id, rowan.id, "same key and body replays the recorded outcome");
67
+ await fails("POST", "/v1/recipients", 400, "invalid_field", { field: "Idempotency-Key", body: { ...rowanBody, lastName: "Pyke" }, headers: rowanKey });
68
+ await fails("GET", "/v1/recipients?pageSize=1", 400, "invalid_field", { field: "Idempotency-Key", headers: rowanKey });
69
+ await fails("POST", "/v1/recipients", 400, "invalid_field", { field: "Idempotency-Key", body: rowanBody, headers: { "idempotency-key": "k".repeat(256) } });
70
+ const afterKeys = await api("GET", "/v1/recipients?search=rowan.pike");
71
+ assert.deepEqual(afterKeys.recipients.map((recipient) => recipient.id), [rowan.id], "refused key reuse created nothing");
72
+ assert.equal(rowan.name, "Rowan Pike");
73
+ const addressed = (await api("PATCH", `/v1/recipients/${rowan.id}`, { body: { address: { street1: "8 Quay St", city: "Halifax", region: "NS", postalCode: "B3J 1A1", country: "CA" }, phone: "902-555-0120" } })).recipient;
74
+ assert.equal(addressed.status, "incomplete", "an address without a payout method stays incomplete");
75
+ assert.equal(addressed.address.city, "Halifax");
76
+ const fetched = (await api("GET", `/v1/recipients/${rowan.id}`)).recipient;
77
+ assert.equal(fetched.phone, "902-555-0120");
78
+ await fails("PATCH", `/v1/recipients/${rowan.id}`, 400, "invalid_field", { field: "type", body: { type: "business" } });
79
+ await fails("PATCH", `/v1/recipients/${rowan.id}`, 400, "empty_field", { field: "email", body: { email: "" } });
80
+ await fails("PATCH", `/v1/recipients/${rowan.id}`, 400, "invalid_status", { body: { status: "active" } });
81
+ await fails("PATCH", `/v1/recipients/${rowan.id}`, 400, "invalid_field", { body: { id: "R-0000000000000000000001" } });
82
+ await fails("PATCH", `/v1/recipients/${R(999)}`, 404, "not_found", { body: { phone: "1" } });
83
+
84
+ await fails("DELETE", `/v1/recipients/${R(1)}`, 400, "invalid_status");
85
+ await fails("DELETE", `/v1/recipients/${R(999)}`, 404, "not_found");
86
+ assert.deepEqual(await api("DELETE", `/v1/recipients/${rowan.id}`), { ok: true });
87
+ assert.equal((await api("GET", `/v1/recipients/${rowan.id}`)).recipient.status, "archived");
88
+ assert.equal((await api("GET", "/v1/recipients")).meta.records, 14);
89
+ const restored = (await api("PATCH", `/v1/recipients/${rowan.id}`, { body: { status: "active" } })).recipient;
90
+ assert.equal(restored.status, "incomplete");
91
+ assert.equal((await api("GET", "/v1/recipients?search=CR-200")).meta.records, 2);
92
+ assert.ok(B(1));
93
+ });
@@ -0,0 +1,87 @@
1
+ // Conformance harness: Trolley-shaped HTTP calls against `firedrill serve`, Node built-ins only.
2
+ import assert from "node:assert/strict";
3
+
4
+ export const HTTP = process.env.FIREDRILL_HTTP_URL;
5
+ export const TOKEN = process.env.FIREDRILL_HTTP_TOKEN;
6
+ assert.ok(HTTP && TOKEN, "the http binding (FIREDRILL_HTTP_URL, FIREDRILL_HTTP_TOKEN) is required");
7
+
8
+ const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
9
+ function renderId(prefix, sequence) {
10
+ let n = sequence;
11
+ let out = "";
12
+ while (n > 0) {
13
+ out = ALPHABET[n % 62] + out;
14
+ n = Math.floor(n / 62);
15
+ }
16
+ return `${prefix}-${out.padStart(22, "0")}`;
17
+ }
18
+ // Starter ids (see starter.json): recipients 1–14, accounts 101–113, batches 201–211, payments 301–322.
19
+ export const R = (n) => renderId("R", n);
20
+ export const A = (n) => renderId("A", 100 + n);
21
+ export const B = (n) => renderId("B", 200 + n);
22
+ export const P = (n) => renderId("P", 300 + n);
23
+ export const MERCHANT = "M-4Lk9Qx2Rb7Tn3Vw8Yz1Ca6";
24
+ export { assert };
25
+
26
+ /** Trolley-shaped request; asserts the status and returns the parsed JSON body. */
27
+ export async function api(method, path, { body, status = 200, scheme = "prsign", headers = {}, raw } = {}) {
28
+ const init = { method, headers: { authorization: `${scheme} ${TOKEN}`, ...headers } };
29
+ if (raw !== undefined) {
30
+ init.body = raw;
31
+ } else if (body !== undefined) {
32
+ init.headers["content-type"] = "application/json";
33
+ init.body = JSON.stringify(body);
34
+ }
35
+ const response = await fetch(`${HTTP}${path}`, init);
36
+ const text = await response.text();
37
+ let json;
38
+ try {
39
+ json = text.length > 0 ? JSON.parse(text) : undefined;
40
+ } catch {
41
+ json = undefined;
42
+ }
43
+ if (response.status !== status) {
44
+ throw new Error(`${method} ${path}: expected HTTP ${status}, got ${response.status}: ${text.slice(0, 800)}`);
45
+ }
46
+ if (status === 200) assert.equal(json?.ok, true, `${method} ${path}: success body must carry ok:true`);
47
+ return json;
48
+ }
49
+
50
+ /** Asserts a Trolley error envelope with `code` (and `field` when given). */
51
+ export async function fails(method, path, status, code, { body, field, raw, headers } = {}) {
52
+ const json = await api(method, path, { body, status, raw, headers });
53
+ assert.equal(json?.ok, false, `${method} ${path}: error body must carry ok:false`);
54
+ assert.ok(Array.isArray(json.errors) && json.errors.length >= 1, `${method} ${path}: errors array expected`);
55
+ assert.equal(json.errors[0].code, code, `${method} ${path}: error code ${JSON.stringify(json.errors)}`);
56
+ assert.equal(typeof json.errors[0].message, "string");
57
+ if (field !== undefined) assert.equal(json.errors[0].field, field, `${method} ${path}: error field ${JSON.stringify(json.errors)}`);
58
+ return json.errors[0];
59
+ }
60
+
61
+ /** Canonical operation call through the generic Firedrill HTTP binding. */
62
+ export async function operation(operationId, args = {}) {
63
+ const response = await fetch(`${HTTP}/v1/operations/trolley/${operationId}`, {
64
+ method: "POST",
65
+ headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
66
+ body: JSON.stringify({ arguments: args }),
67
+ });
68
+ const json = await response.json();
69
+ assert.ok(json && json.outcome, `canonical ${operationId}: outcome expected`);
70
+ return json.outcome;
71
+ }
72
+
73
+ const flows = new Map();
74
+ export function flow(name, run) {
75
+ flows.set(name, run);
76
+ }
77
+
78
+ export async function main() {
79
+ let task = "";
80
+ for await (const chunk of process.stdin) task += chunk;
81
+ const instruction = String(JSON.parse(task).instruction ?? "");
82
+ const selected = [...flows.keys()].find((name) => instruction.includes(`the ${name} conformance flow`));
83
+ if (selected === undefined) throw new Error(`Unknown drill instruction: ${instruction}`);
84
+ await flows.get(selected)();
85
+ // The target protocol reads exactly one JSON value from stdout; diagnostics go to stderr.
86
+ process.stdout.write(JSON.stringify({ completed: true, flow: selected }));
87
+ }