@hyperscale0/adl 1.0.0-alpha.2 → 1.0.0-beta.10
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/CHANGELOG.md +46 -2
- package/LICENSE.md +356 -0
- package/NOTICE.md +8 -0
- package/README.md +29 -140
- package/conformance/cases.json +50 -764
- package/conformance/subject-adapter.ts +31 -0
- package/dist/boundary-fixture.d.ts +24 -0
- package/dist/boundary-fixture.d.ts.map +1 -0
- package/dist/boundary-fixture.js +34 -0
- package/dist/boundary-fixture.js.map +1 -0
- package/dist/index.d.ts +68 -156
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +100 -77
- package/dist/index.js.map +1 -1
- package/dist/vocabulary.d.ts +3 -57
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +12 -84
- package/dist/vocabulary.js.map +1 -1
- package/docs/assets/adl.svg +11 -0
- package/docs/assets/brand/adl-horizontal-white.svg +1 -0
- package/docs/assets/brand/adl-horizontal.svg +1 -0
- package/docs/assets/brand/adl-stacked-white.svg +1 -0
- package/docs/assets/brand/adl-stacked.svg +1 -0
- package/docs/assets/brand/manifest.json +30 -0
- package/docs/authoring-guide.md +37 -416
- package/package.json +17 -12
- package/spec/manifest.schema.json +646 -439
- package/LICENSE +0 -661
- package/LICENSING.md +0 -51
- package/TRADEMARKS.md +0 -30
- package/dist/conformance.d.ts +0 -41
- package/dist/conformance.d.ts.map +0 -1
- package/dist/conformance.js +0 -486
- package/dist/conformance.js.map +0 -1
- package/examples/meridian-bank/README.md +0 -65
- package/examples/meridian-bank/example.spec.ts +0 -162
- package/examples/meridian-bank/fixtures/payout-status-replay.json +0 -19
- package/examples/meridian-bank/fixtures/payout-status-settled.json +0 -19
- package/examples/meridian-bank/fixtures/payout-submit-accepted.json +0 -26
- package/examples/meridian-bank/fixtures/payout-submit-declined.json +0 -25
- package/examples/meridian-bank/fixtures/statement-camt053.json +0 -45
- package/examples/meridian-bank/fixtures/webhook-payout-settled.json +0 -11
- package/examples/meridian-bank/index.ts +0 -262
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
# Meridian Bank
|
|
2
|
-
|
|
3
|
-
A complete, certified ADL adapter for a bank that does not exist. Every endpoint,
|
|
4
|
-
status token, charge narrative, and account number here is invented, and the
|
|
5
|
-
domain `meridian-bank.example` is reserved by RFC 2606 so nothing in this
|
|
6
|
-
directory can point at a real institution.
|
|
7
|
-
|
|
8
|
-
It exists for two reasons: it is the thing to copy when you write your own,
|
|
9
|
-
and it is the proof that a third party can pass conformance with nothing but
|
|
10
|
-
the published package.
|
|
11
|
-
|
|
12
|
-
## What is here
|
|
13
|
-
|
|
14
|
-
| File | What it is |
|
|
15
|
-
| ----------------- | ------------------------------------------------------------------------------------------- |
|
|
16
|
-
| `index.ts` | The adapter: vocabulary, profile, config, two capabilities. Read it top to bottom. |
|
|
17
|
-
| `fixtures/*.json` | Meridian's own bytes: two command responses, two status enquiries, a statement, a callback. |
|
|
18
|
-
| `example.spec.ts` | Conformance, plus the checks that keep the declaration honest against the fixtures. |
|
|
19
|
-
|
|
20
|
-
## Meridian, briefly
|
|
21
|
-
|
|
22
|
-
An imaginary European bank with a REST API:
|
|
23
|
-
|
|
24
|
-
- **Errors ride the HTTP status.** A rejected payment is a 4xx with prose in
|
|
25
|
-
the body, which is `envelope: "http_status_error_body"`. A bank that answered
|
|
26
|
-
200 with a rejection token in the body would be `http_200_body_status`
|
|
27
|
-
instead, and the class is declared per operation because real providers are
|
|
28
|
-
inconsistent across their own endpoints.
|
|
29
|
-
- **A payment is re-read by the reference Meridian minted**, so
|
|
30
|
-
`statusEnquiry.keys` is `provider_reference` and the path template carries
|
|
31
|
-
`{providerReference}`. That is the read-after-write: a submit whose response
|
|
32
|
-
we never saw is re-read, never resubmitted.
|
|
33
|
-
- **Incoming credits are learned from statements**, so the `bank_credit`
|
|
34
|
-
capability declares `egress: "none"` and records what observation saw.
|
|
35
|
-
- **Fees and tax post separately** as `MERIDIAN FEE ...` and `MERIDIAN TAX ...`
|
|
36
|
-
debits, which is why both narrative prefixes are declared: unrecognized, they
|
|
37
|
-
would land as unexplained statement lines.
|
|
38
|
-
- **The statement echoes our reference**, so `statementDebitReference` is
|
|
39
|
-
`customer_reference`. Reading Meridian's own entry id instead would make
|
|
40
|
-
every payout we instructed look like an unexplained debit.
|
|
41
|
-
|
|
42
|
-
## Run it
|
|
43
|
-
|
|
44
|
-
From the package root:
|
|
45
|
-
|
|
46
|
-
```bash
|
|
47
|
-
bun run conformance -- ./examples/meridian-bank
|
|
48
|
-
bun test examples/meridian-bank
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
## Copying it
|
|
52
|
-
|
|
53
|
-
1. Copy the directory and rename `meridian` throughout. The provider key is
|
|
54
|
-
lowercase and machine-shaped (`^[a-z][a-z0-9_.-]{0,79}$`).
|
|
55
|
-
2. Replace the vocabulary with the operations and resources your product
|
|
56
|
-
actually uses.
|
|
57
|
-
3. Replace every profile fact with what your bank does. Do not carry a
|
|
58
|
-
Meridian value across because it looked plausible: a fact you have not
|
|
59
|
-
established is declared absent, and conformance will tell you which ones
|
|
60
|
-
are still open.
|
|
61
|
-
4. Replace the fixtures with real captures from your provider's sandbox, and
|
|
62
|
-
keep `example.spec.ts` pointed at them. A declaration that certifies but
|
|
63
|
-
cannot read its own sandbox capture is not finished.
|
|
64
|
-
|
|
65
|
-
`../../docs/authoring-guide.md` explains every field.
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The example's own test, and the shape yours should take: conformance is
|
|
3
|
-
* the gate, and the fixtures keep the declaration honest against the bytes
|
|
4
|
-
* the bank actually sends. A declaration that certifies but cannot read its
|
|
5
|
-
* own sandbox capture is not finished.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
9
|
-
|
|
10
|
-
import { partnerBankConformanceFindings } from "@hyperscale0/adl";
|
|
11
|
-
import { describe, expect, test } from "bun:test";
|
|
12
|
-
|
|
13
|
-
import { meridianAdapters, meridianProfile } from "./index.js";
|
|
14
|
-
|
|
15
|
-
interface ExchangeFixture {
|
|
16
|
-
readonly kind: "command" | "enquiry";
|
|
17
|
-
readonly operation: string;
|
|
18
|
-
readonly response: {
|
|
19
|
-
readonly httpStatus: number;
|
|
20
|
-
readonly body: Record<string, unknown>;
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface StatementFixture {
|
|
25
|
-
readonly format: string;
|
|
26
|
-
readonly entries: readonly {
|
|
27
|
-
readonly amount: string;
|
|
28
|
-
readonly customerReference: string | null;
|
|
29
|
-
readonly narrative: string;
|
|
30
|
-
}[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface NotificationFixture {
|
|
34
|
-
readonly eventKind: string;
|
|
35
|
-
readonly payload: Record<string, unknown>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function fixture<T>(name: string): T {
|
|
39
|
-
return JSON.parse(
|
|
40
|
-
readFileSync(new URL(`./fixtures/${name}.json`, import.meta.url), "utf8"),
|
|
41
|
-
) as T;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const exchanges = [
|
|
45
|
-
fixture<ExchangeFixture>("payout-submit-accepted"),
|
|
46
|
-
fixture<ExchangeFixture>("payout-submit-declined"),
|
|
47
|
-
fixture<ExchangeFixture>("payout-status-settled"),
|
|
48
|
-
fixture<ExchangeFixture>("payout-status-replay"),
|
|
49
|
-
];
|
|
50
|
-
const statement = fixture<StatementFixture>("statement-camt053");
|
|
51
|
-
const notification = fixture<NotificationFixture>("webhook-payout-settled");
|
|
52
|
-
|
|
53
|
-
const [payoutAdapter] = meridianAdapters;
|
|
54
|
-
if (!payoutAdapter) throw new Error("payout adapter missing");
|
|
55
|
-
|
|
56
|
-
/** The same normalization a runtime uses to match a status token. */
|
|
57
|
-
function normalize(token: string): string {
|
|
58
|
-
return token.trim().toUpperCase();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const canonicalStateFor = new Map(
|
|
62
|
-
Object.entries(meridianProfile.lifecycleVocabulary).map(
|
|
63
|
-
([spelling, state]) => [normalize(spelling), state as string],
|
|
64
|
-
),
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
describe("meridian bank adapter", () => {
|
|
68
|
-
test("every capability certifies with zero findings", () => {
|
|
69
|
-
for (const adapter of meridianAdapters) {
|
|
70
|
-
expect({
|
|
71
|
-
capability: adapter.capability,
|
|
72
|
-
findings: partnerBankConformanceFindings(adapter),
|
|
73
|
-
}).toEqual({ capability: adapter.capability, findings: [] });
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("the lifecycle vocabulary spells every token the bank sends", () => {
|
|
78
|
-
for (const exchange of exchanges) {
|
|
79
|
-
const token = exchange.response.body["status"];
|
|
80
|
-
expect(typeof token).toBe("string");
|
|
81
|
-
expect(canonicalStateFor.get(normalize(token as string))).toBeDefined();
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("the declared envelope class is how the fixtures actually read", () => {
|
|
86
|
-
// http_status_error_body: the HTTP status carries the outcome and a
|
|
87
|
-
// failure body is prose. Reading the body of a 2xx for a rejection, or
|
|
88
|
-
// treating a 4xx as a lost response, would strand the payment.
|
|
89
|
-
expect(payoutAdapter.operationMap["payout.submit"]?.envelope).toBe(
|
|
90
|
-
"http_status_error_body",
|
|
91
|
-
);
|
|
92
|
-
for (const exchange of exchanges) {
|
|
93
|
-
if (exchange.kind !== "command") continue;
|
|
94
|
-
const state = canonicalStateFor.get(
|
|
95
|
-
normalize(exchange.response.body["status"] as string),
|
|
96
|
-
);
|
|
97
|
-
if (state === "failed") {
|
|
98
|
-
expect(exchange.response.httpStatus).toBeGreaterThanOrEqual(400);
|
|
99
|
-
expect(typeof exchange.response.body["message"]).toBe("string");
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
expect(exchange.response.httpStatus).toBeLessThan(400);
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("the statement format is declared and has a fetch window", () => {
|
|
107
|
-
const formats: readonly string[] = meridianProfile.statementFormats;
|
|
108
|
-
expect(formats).toContain(statement.format);
|
|
109
|
-
const windows: Record<string, unknown> = meridianProfile.windows.statements;
|
|
110
|
-
expect(windows[statement.format]).toBeDefined();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("the charge narratives recognize the bank's own debits", () => {
|
|
114
|
-
const startsWithAny = (narrative: string, prefixes: readonly string[]) =>
|
|
115
|
-
prefixes.some((prefix) => normalize(narrative).startsWith(prefix));
|
|
116
|
-
|
|
117
|
-
const { charge, vat } = meridianProfile.charges.narratives;
|
|
118
|
-
const chargeLines = statement.entries.filter((entry) =>
|
|
119
|
-
startsWithAny(entry.narrative, charge),
|
|
120
|
-
);
|
|
121
|
-
const vatLines = statement.entries.filter((entry) =>
|
|
122
|
-
startsWithAny(entry.narrative, vat),
|
|
123
|
-
);
|
|
124
|
-
expect(chargeLines).toHaveLength(1);
|
|
125
|
-
expect(vatLines).toHaveLength(1);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("an instructed payout is attributable on the statement", () => {
|
|
129
|
-
// statementDebitReference says which reference comes back. Every debit
|
|
130
|
-
// that is not one of the bank's own charge lines must carry it, or the
|
|
131
|
-
// ledger cannot prove the debit was ours.
|
|
132
|
-
expect(meridianProfile.statementDebitReference).toBe("customer_reference");
|
|
133
|
-
const chargePrefixes = [
|
|
134
|
-
...meridianProfile.charges.narratives.charge,
|
|
135
|
-
...meridianProfile.charges.narratives.vat,
|
|
136
|
-
];
|
|
137
|
-
const instructedDebits = statement.entries.filter(
|
|
138
|
-
(entry) =>
|
|
139
|
-
entry.amount.startsWith("-") &&
|
|
140
|
-
!chargePrefixes.some((prefix) =>
|
|
141
|
-
normalize(entry.narrative).startsWith(prefix),
|
|
142
|
-
),
|
|
143
|
-
);
|
|
144
|
-
expect(instructedDebits).not.toHaveLength(0);
|
|
145
|
-
for (const debit of instructedDebits) {
|
|
146
|
-
expect(typeof debit.customerReference).toBe("string");
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("the webhook plan describes the callback the bank sends", () => {
|
|
151
|
-
const plan = Object.entries(payoutAdapter.webhookMap).find(
|
|
152
|
-
([eventKind]) => eventKind === notification.eventKind,
|
|
153
|
-
)?.[1];
|
|
154
|
-
expect(plan).toBeDefined();
|
|
155
|
-
if (!plan) return;
|
|
156
|
-
for (const field of plan.requiredPayloadFields) {
|
|
157
|
-
expect(notification.payload[field]).toBeDefined();
|
|
158
|
-
}
|
|
159
|
-
expect(notification.payload[plan.resourceIdField]).toBeDefined();
|
|
160
|
-
expect(notification.payload[plan.timestampField]).toBeDefined();
|
|
161
|
-
});
|
|
162
|
-
});
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"kind": "enquiry",
|
|
3
|
-
"operation": "payout.submit",
|
|
4
|
-
"request": {
|
|
5
|
-
"method": "GET",
|
|
6
|
-
"path": "/v2/payments/MER-9F3K2"
|
|
7
|
-
},
|
|
8
|
-
"response": {
|
|
9
|
-
"httpStatus": 200,
|
|
10
|
-
"body": {
|
|
11
|
-
"providerReference": "MER-9F3K2",
|
|
12
|
-
"paymentReference": "HS-PAY-0007",
|
|
13
|
-
"status": "REPLAY_SETTLED",
|
|
14
|
-
"originalProviderReference": "MER-9F3K2",
|
|
15
|
-
"valueDate": "2026-03-03",
|
|
16
|
-
"settledAt": "2026-03-03T09:04:41Z"
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"kind": "enquiry",
|
|
3
|
-
"operation": "payout.submit",
|
|
4
|
-
"request": {
|
|
5
|
-
"method": "GET",
|
|
6
|
-
"path": "/v2/payments/MER-9F3K2"
|
|
7
|
-
},
|
|
8
|
-
"response": {
|
|
9
|
-
"httpStatus": 200,
|
|
10
|
-
"body": {
|
|
11
|
-
"providerReference": "MER-9F3K2",
|
|
12
|
-
"paymentReference": "HS-PAY-0007",
|
|
13
|
-
"status": "SETTLED",
|
|
14
|
-
"valueDate": "2026-03-03",
|
|
15
|
-
"settledAt": "2026-03-03T09:04:41Z",
|
|
16
|
-
"chargeAmount": "1.50"
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"kind": "command",
|
|
3
|
-
"operation": "payout.submit",
|
|
4
|
-
"request": {
|
|
5
|
-
"method": "POST",
|
|
6
|
-
"path": "/v2/payments",
|
|
7
|
-
"body": {
|
|
8
|
-
"instructionId": "HS-INS-0007",
|
|
9
|
-
"endToEndId": "HS-E2E-0007",
|
|
10
|
-
"paymentReference": "HS-PAY-0007",
|
|
11
|
-
"amount": "2500.00",
|
|
12
|
-
"currency": "EUR",
|
|
13
|
-
"creditorName": "Acme Ltd",
|
|
14
|
-
"creditorAccount": "IE29MERI93115212345678",
|
|
15
|
-
"requestedValueDate": "2026-03-03"
|
|
16
|
-
}
|
|
17
|
-
},
|
|
18
|
-
"response": {
|
|
19
|
-
"httpStatus": 202,
|
|
20
|
-
"body": {
|
|
21
|
-
"providerReference": "MER-9F3K2",
|
|
22
|
-
"status": "QUEUED",
|
|
23
|
-
"receivedAt": "2026-03-02T13:20:05Z"
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"kind": "command",
|
|
3
|
-
"operation": "payout.submit",
|
|
4
|
-
"request": {
|
|
5
|
-
"method": "POST",
|
|
6
|
-
"path": "/v2/payments",
|
|
7
|
-
"body": {
|
|
8
|
-
"instructionId": "HS-INS-0008",
|
|
9
|
-
"endToEndId": "HS-E2E-0008",
|
|
10
|
-
"paymentReference": "HS-PAY-0008",
|
|
11
|
-
"amount": "40.00",
|
|
12
|
-
"currency": "EUR",
|
|
13
|
-
"creditorName": "Closed Account Ltd",
|
|
14
|
-
"creditorAccount": "IE29MERI93115299999999",
|
|
15
|
-
"requestedValueDate": "2026-03-03"
|
|
16
|
-
}
|
|
17
|
-
},
|
|
18
|
-
"response": {
|
|
19
|
-
"httpStatus": 422,
|
|
20
|
-
"body": {
|
|
21
|
-
"status": "DECLINED",
|
|
22
|
-
"message": "creditor account is closed"
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"kind": "statement",
|
|
3
|
-
"format": "camt_053",
|
|
4
|
-
"account": "IE29MERI93115200000001",
|
|
5
|
-
"statementDate": "2026-03-03",
|
|
6
|
-
"entries": [
|
|
7
|
-
{
|
|
8
|
-
"entryReference": "MER-ENT-88213",
|
|
9
|
-
"customerReference": "HS-PAY-0007",
|
|
10
|
-
"amount": "-2500.00",
|
|
11
|
-
"currency": "EUR",
|
|
12
|
-
"narrative": "PAYMENT TO ACME LTD",
|
|
13
|
-
"bookingDate": "2026-03-03",
|
|
14
|
-
"valueDate": "2026-03-03"
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
"entryReference": "MER-ENT-88214",
|
|
18
|
-
"customerReference": null,
|
|
19
|
-
"amount": "-1.50",
|
|
20
|
-
"currency": "EUR",
|
|
21
|
-
"narrative": "MERIDIAN FEE HS-PAY-0007",
|
|
22
|
-
"bookingDate": "2026-03-03",
|
|
23
|
-
"valueDate": "2026-03-03"
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
"entryReference": "MER-ENT-88215",
|
|
27
|
-
"customerReference": null,
|
|
28
|
-
"amount": "-0.35",
|
|
29
|
-
"currency": "EUR",
|
|
30
|
-
"narrative": "MERIDIAN TAX HS-PAY-0007",
|
|
31
|
-
"bookingDate": "2026-03-03",
|
|
32
|
-
"valueDate": "2026-03-03"
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
"entryReference": "MER-ENT-88301",
|
|
36
|
-
"customerReference": null,
|
|
37
|
-
"amount": "1200.00",
|
|
38
|
-
"currency": "EUR",
|
|
39
|
-
"narrative": "INBOUND TRANSFER FROM BUYER 4471",
|
|
40
|
-
"financialAddressId": "fa_2299",
|
|
41
|
-
"bookingDate": "2026-03-03",
|
|
42
|
-
"valueDate": "2026-03-03"
|
|
43
|
-
}
|
|
44
|
-
]
|
|
45
|
-
}
|
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Meridian Bank: a complete, certified adapter for a bank that does not
|
|
3
|
-
* exist. Every endpoint, status token, and charge narrative below is
|
|
4
|
-
* invented. It is here to be read top to bottom and copied.
|
|
5
|
-
*
|
|
6
|
-
* The order of the file is the order you write one:
|
|
7
|
-
*
|
|
8
|
-
* 1. the vocabulary -- the closed operation, resource, and webhook unions
|
|
9
|
-
* your product uses;
|
|
10
|
-
* 2. the profile -- what the bank does, as facts;
|
|
11
|
-
* 3. the config -- where the credentials live, never what they are;
|
|
12
|
-
* 4. the adapters -- one per capability, validated at import.
|
|
13
|
-
*
|
|
14
|
-
* `docs/authoring-guide.md` explains every field. `fixtures/` holds the
|
|
15
|
-
* bank's own bytes, and `example.spec.ts` proves the declaration matches
|
|
16
|
-
* them.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
createProviderAdapterRegistry,
|
|
21
|
-
type PartnerBankAdapterProfile,
|
|
22
|
-
type ProviderAdapterVocabulary,
|
|
23
|
-
type ProviderConfig,
|
|
24
|
-
} from "@hyperscale0/adl";
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The slice of the platform vocabulary this bank serves, spelled as closed
|
|
28
|
-
* literal unions. Nothing here comes from a private import: the operation and
|
|
29
|
-
* resource names are the published ones.
|
|
30
|
-
*/
|
|
31
|
-
export interface MeridianVocabulary extends ProviderAdapterVocabulary {
|
|
32
|
-
readonly capability: "payout_execution" | "bank_credit";
|
|
33
|
-
readonly domain: "payouts" | "deposits";
|
|
34
|
-
readonly meter: "external_payout";
|
|
35
|
-
readonly obligation:
|
|
36
|
-
| "payout_submission"
|
|
37
|
-
| "payout_execution"
|
|
38
|
-
| "payout_return"
|
|
39
|
-
| "bank_credit";
|
|
40
|
-
readonly operation:
|
|
41
|
-
| "payout.submit"
|
|
42
|
-
| "payout.complete"
|
|
43
|
-
| "payout.fail"
|
|
44
|
-
| "payout.return"
|
|
45
|
-
| "deposit.record";
|
|
46
|
-
readonly resource: "payout" | "deposit";
|
|
47
|
-
readonly webhookOperation: "payout.complete" | "deposit.record";
|
|
48
|
-
readonly webhookResource: "payout" | "deposit";
|
|
49
|
-
readonly webhookResourceId: "payoutId" | "depositId";
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const defineMeridianAdapters =
|
|
53
|
-
createProviderAdapterRegistry<MeridianVocabulary>();
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Meridian as its documentation and its sandbox describe it. Each fact is
|
|
57
|
-
* something a runtime would otherwise have to guess, and a guess here is a
|
|
58
|
-
* stranded payment.
|
|
59
|
-
*/
|
|
60
|
-
export const meridianProfile = {
|
|
61
|
-
kind: "partner_bank",
|
|
62
|
-
wireCodec: "rest_json",
|
|
63
|
-
auth: {
|
|
64
|
-
// Machine-to-machine client credentials, plus a detached signature over
|
|
65
|
-
// the request body that Meridian verifies with the channel certificate.
|
|
66
|
-
envelope: "oauth2_client_credentials",
|
|
67
|
-
signing: "detached_signature",
|
|
68
|
-
},
|
|
69
|
-
idempotencySpine: {
|
|
70
|
-
// Meridian grades a replay on the instruction id we mint, and refuses a
|
|
71
|
-
// second payment carrying a payment reference it has already settled.
|
|
72
|
-
dedupeKeys: ["instructionId", "paymentReference"],
|
|
73
|
-
instructionId: { maxLength: 32 },
|
|
74
|
-
endToEndId: { maxLength: 32 },
|
|
75
|
-
paymentReference: { maxLength: 20 },
|
|
76
|
-
},
|
|
77
|
-
/**
|
|
78
|
-
* Meridian's own status tokens on the left, the canonical states on the
|
|
79
|
-
* right. Runtimes match after trimming and upper-casing, so a token that
|
|
80
|
-
* only differs by case or padding needs no second entry.
|
|
81
|
-
*
|
|
82
|
-
* Both replay verdicts are spelled: without them a crash-recovery
|
|
83
|
-
* resubmission reads as a fresh failure and the payout is instructed twice.
|
|
84
|
-
*/
|
|
85
|
-
lifecycleVocabulary: {
|
|
86
|
-
QUEUED: "received",
|
|
87
|
-
IN_REVIEW: "received",
|
|
88
|
-
AUTHORISED: "accepted",
|
|
89
|
-
SETTLED: "processed",
|
|
90
|
-
DECLINED: "failed",
|
|
91
|
-
RECALLED: "returned",
|
|
92
|
-
REPLAY_SETTLED: "duplicate_original_succeeded",
|
|
93
|
-
REPLAY_DECLINED: "duplicate_original_rejected",
|
|
94
|
-
},
|
|
95
|
-
executedReadBack: {
|
|
96
|
-
fxRate: "final_status_only",
|
|
97
|
-
// Meridian never moves a payment to another rail behind our back.
|
|
98
|
-
railSubstitution: "none",
|
|
99
|
-
},
|
|
100
|
-
charges: {
|
|
101
|
-
// The bank raises its fee and its tax as two separate debits, each
|
|
102
|
-
// recognizable only by the wording it prints. Prefixes, because the
|
|
103
|
-
// trailing part names the payment being charged for.
|
|
104
|
-
narratives: {
|
|
105
|
-
charge: ["MERIDIAN FEE"],
|
|
106
|
-
vat: ["MERIDIAN TAX"],
|
|
107
|
-
},
|
|
108
|
-
posting: "post_hoc",
|
|
109
|
-
vat: "separate_line",
|
|
110
|
-
},
|
|
111
|
-
windows: {
|
|
112
|
-
paymentCutoff: {
|
|
113
|
-
time: "16:30",
|
|
114
|
-
// The bank's wall clock, not the platform's.
|
|
115
|
-
timeZone: "Europe/Dublin",
|
|
116
|
-
beforeCutoffValueDate: "D+0",
|
|
117
|
-
afterCutoffValueDate: "D+1",
|
|
118
|
-
},
|
|
119
|
-
valueDateMaxDaysAhead: 30,
|
|
120
|
-
statements: {
|
|
121
|
-
// End of day pages over history, so it declares how far back the
|
|
122
|
-
// archive goes and how many days one request may cover.
|
|
123
|
-
camt_053: {
|
|
124
|
-
availability: "T-1",
|
|
125
|
-
lookbackDays: 90,
|
|
126
|
-
statementDaysPerRequest: 7,
|
|
127
|
-
},
|
|
128
|
-
// Intraday is today or nothing, so it owes no paging facts.
|
|
129
|
-
camt_052: { availability: "today_only" },
|
|
130
|
-
},
|
|
131
|
-
},
|
|
132
|
-
// Meridian echoes the reference we sent, not its own entry id. Reading the
|
|
133
|
-
// entry id instead would leave every payout we instructed looking like an
|
|
134
|
-
// unexplained debit.
|
|
135
|
-
statementDebitReference: "customer_reference",
|
|
136
|
-
statementFormats: ["camt_053", "camt_052"],
|
|
137
|
-
// The callback is a hint that a poll is due. Polling is the fact.
|
|
138
|
-
notificationMechanisms: ["poll", "webhook"],
|
|
139
|
-
financialAddressProvisioning: {
|
|
140
|
-
mechanism: "api",
|
|
141
|
-
quota: "none",
|
|
142
|
-
pricing: "none",
|
|
143
|
-
},
|
|
144
|
-
limitFacts: {
|
|
145
|
-
access: "read_only",
|
|
146
|
-
dimensions: ["daily", "per_transaction"],
|
|
147
|
-
},
|
|
148
|
-
} as const satisfies PartnerBankAdapterProfile;
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* A locator, never a value. The registry validator refuses a blank locator
|
|
152
|
-
* and has no field a secret could be written into.
|
|
153
|
-
*/
|
|
154
|
-
const meridianConfig = {
|
|
155
|
-
baseUrl: "https://api.meridian-bank.example/v2",
|
|
156
|
-
credentialRef: {
|
|
157
|
-
source: "environment",
|
|
158
|
-
name: "MERIDIAN_BANK_CREDENTIALS",
|
|
159
|
-
},
|
|
160
|
-
} as const satisfies ProviderConfig;
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* One adapter per capability, both carrying the same bank profile. The
|
|
164
|
-
* registry validates shape at import; `example.spec.ts` runs conformance.
|
|
165
|
-
*/
|
|
166
|
-
export const meridianAdapters = defineMeridianAdapters([
|
|
167
|
-
{
|
|
168
|
-
provider: "meridian_bank",
|
|
169
|
-
capability: "payout_execution",
|
|
170
|
-
domain: "payouts",
|
|
171
|
-
egress: "rest",
|
|
172
|
-
operationMap: {
|
|
173
|
-
"payout.submit": {
|
|
174
|
-
// Read-after-write: a submit whose response we never saw is re-read
|
|
175
|
-
// here by the reference Meridian minted, never resubmitted blind.
|
|
176
|
-
statusEnquiry: {
|
|
177
|
-
keys: "provider_reference",
|
|
178
|
-
pathTemplate: "/v2/payments/{providerReference}",
|
|
179
|
-
},
|
|
180
|
-
// Meridian answers on the HTTP status and puts prose in the body of a
|
|
181
|
-
// failure, so a 4xx is the rejection and the body is for humans.
|
|
182
|
-
envelope: "http_status_error_body",
|
|
183
|
-
operation: "payout.submit",
|
|
184
|
-
obligationKind: "payout_submission",
|
|
185
|
-
resourceKind: "payout",
|
|
186
|
-
resourceIdPath: "payoutId",
|
|
187
|
-
direction: "tenant_initiated",
|
|
188
|
-
meter: "external_payout",
|
|
189
|
-
},
|
|
190
|
-
// The three endings. Nothing is sent for them: they record what the
|
|
191
|
-
// poll lane observed, so they carry no envelope of their own.
|
|
192
|
-
"payout.complete": {
|
|
193
|
-
operation: "payout.complete",
|
|
194
|
-
obligationKind: "payout_execution",
|
|
195
|
-
resourceKind: "payout",
|
|
196
|
-
resourceIdPath: "payoutId",
|
|
197
|
-
direction: "system_settlement",
|
|
198
|
-
},
|
|
199
|
-
"payout.fail": {
|
|
200
|
-
operation: "payout.fail",
|
|
201
|
-
obligationKind: "payout_execution",
|
|
202
|
-
resourceKind: "payout",
|
|
203
|
-
resourceIdPath: "payoutId",
|
|
204
|
-
direction: "system_settlement",
|
|
205
|
-
},
|
|
206
|
-
"payout.return": {
|
|
207
|
-
operation: "payout.return",
|
|
208
|
-
obligationKind: "payout_return",
|
|
209
|
-
resourceKind: "payout",
|
|
210
|
-
resourceIdPath: "payoutId",
|
|
211
|
-
direction: "system_settlement",
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
webhookMap: {
|
|
215
|
-
"payout.settled": {
|
|
216
|
-
eventKind: "payout.settled",
|
|
217
|
-
operationName: "payout.complete",
|
|
218
|
-
requiredPayloadFields: ["providerReference", "valueDate"],
|
|
219
|
-
optionalPayloadFields: ["chargeAmount"],
|
|
220
|
-
requiredFieldGroups: [],
|
|
221
|
-
resourceIdField: "payoutId",
|
|
222
|
-
resourceKind: "payout",
|
|
223
|
-
timestampField: "completedAt",
|
|
224
|
-
},
|
|
225
|
-
},
|
|
226
|
-
bindings: { payout: "claim" },
|
|
227
|
-
config: meridianConfig,
|
|
228
|
-
profile: meridianProfile,
|
|
229
|
-
},
|
|
230
|
-
{
|
|
231
|
-
provider: "meridian_bank",
|
|
232
|
-
capability: "bank_credit",
|
|
233
|
-
domain: "deposits",
|
|
234
|
-
// Incoming credits are learned by reading statements, so this capability
|
|
235
|
-
// sends Meridian nothing.
|
|
236
|
-
egress: "none",
|
|
237
|
-
operationMap: {
|
|
238
|
-
"deposit.record": {
|
|
239
|
-
operation: "deposit.record",
|
|
240
|
-
obligationKind: "bank_credit",
|
|
241
|
-
resourceKind: "deposit",
|
|
242
|
-
resourceIdPath: "depositId",
|
|
243
|
-
direction: "system_settlement",
|
|
244
|
-
},
|
|
245
|
-
},
|
|
246
|
-
webhookMap: {
|
|
247
|
-
"deposit.received": {
|
|
248
|
-
eventKind: "deposit.received",
|
|
249
|
-
operationName: "deposit.record",
|
|
250
|
-
requiredPayloadFields: ["financialAddressId", "amount", "currency"],
|
|
251
|
-
optionalPayloadFields: ["remitterName"],
|
|
252
|
-
requiredFieldGroups: [],
|
|
253
|
-
resourceIdField: "depositId",
|
|
254
|
-
resourceKind: "deposit",
|
|
255
|
-
timestampField: "receivedAt",
|
|
256
|
-
},
|
|
257
|
-
},
|
|
258
|
-
bindings: { deposit: "strict" },
|
|
259
|
-
config: meridianConfig,
|
|
260
|
-
profile: meridianProfile,
|
|
261
|
-
},
|
|
262
|
-
]);
|