@festapp/banksync 0.1.5 → 0.2.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/README.md +64 -8
- package/dist/{chunk-5QXHZGAW.js → chunk-3WM2GGFL.js} +66 -27
- package/dist/{chunk-PBN2BUNR.cjs → chunk-N7BVOSFN.cjs} +66 -27
- package/dist/cloudflare.cjs +358 -117
- package/dist/cloudflare.d.cts +3 -1
- package/dist/cloudflare.d.ts +3 -1
- package/dist/cloudflare.js +303 -62
- package/dist/index.cjs +4 -2
- package/dist/index.d.cts +11 -18
- package/dist/index.d.ts +11 -18
- package/dist/index.js +3 -1
- package/dist/{types-Cb5l2lXq.d.cts → types-DZX_B1qb.d.cts} +8 -2
- package/dist/{types-Cb5l2lXq.d.ts → types-DZX_B1qb.d.ts} +8 -2
- package/migrations/0011_complete_bank_facts.sql +52 -0
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -23,8 +23,8 @@ receive bank activity → authenticate → normalize → deduplicate → deliver
|
|
|
23
23
|
| | |
|
|
24
24
|
|---|---|
|
|
25
25
|
| **Inputs** | Fio Bank email, Fio API, Air Bank email |
|
|
26
|
-
| **Output** | Signed `transaction.received` webhook, version `1` |
|
|
27
|
-
| **Runtime** | Cloudflare Workers, Email Routing, D1 and Queues;
|
|
26
|
+
| **Output** | Signed `transaction.received` webhook, consumer-selected version `1` or `2` |
|
|
27
|
+
| **Runtime** | Cloudflare Workers, Email Routing, D1 and Queues; R2 required for durable email recovery |
|
|
28
28
|
| **Package** | Runtime-neutral helpers plus a separate Cloudflare Worker export |
|
|
29
29
|
| **Safety model** | Tenant ownership, authenticated email identity, durable idempotency and exact-host callback policy |
|
|
30
30
|
|
|
@@ -109,7 +109,7 @@ export type { Env } from "@festapp/banksync/cloudflare";
|
|
|
109
109
|
|
|
110
110
|
- Node.js 20 or newer and pnpm;
|
|
111
111
|
- a Cloudflare account with Workers, D1, Queues and Email Routing;
|
|
112
|
-
- an R2 bucket
|
|
112
|
+
- an R2 bucket for authenticated email recovery or encrypted backups;
|
|
113
113
|
- exact callback hostnames for every intended webhook consumer.
|
|
114
114
|
|
|
115
115
|
### Setup
|
|
@@ -118,9 +118,9 @@ export type { Env } from "@festapp/banksync/cloudflare";
|
|
|
118
118
|
2. Copy [`wrangler.example.toml`](wrangler.example.toml) to `wrangler.toml` and
|
|
119
119
|
replace every resource placeholder.
|
|
120
120
|
3. Create the D1 database and queues named by the configuration.
|
|
121
|
-
4.
|
|
122
|
-
database
|
|
123
|
-
|
|
121
|
+
4. Apply the baseline and forward migrations on a fresh database. An existing
|
|
122
|
+
version-10 database advances only through `0011_complete_bank_facts.sql`.
|
|
123
|
+
Preserve recorded migration history and use a scoped reviewed rollout.
|
|
124
124
|
5. Configure `ADMIN_SECRET`, `WEBHOOK_KEK`, `ENCRYPTION_KEY_V1`, and—when R2
|
|
125
125
|
backups are enabled—the selected `BACKUP_ENCRYPTION_KEY_Vn` with
|
|
126
126
|
`pnpm wrangler secret put <NAME>`.
|
|
@@ -142,8 +142,8 @@ keys outside R2 and prove restore before rotation. Never commit `wrangler.toml`,
|
|
|
142
142
|
|
|
143
143
|
Fresh databases use the single canonical baseline `0001_schema.sql`, which
|
|
144
144
|
creates schema version 10. Existing databases retain their recorded `0001`–`0009`
|
|
145
|
-
history and advance through `0010_security_hardening.sql
|
|
146
|
-
|
|
145
|
+
history and advance through `0010_security_hardening.sql`, then
|
|
146
|
+
`0011_complete_bank_facts.sql` to version 11. Never renumber or replace the baseline.
|
|
147
147
|
|
|
148
148
|
BankSync D1 is intentionally independent of consumer billing databases.
|
|
149
149
|
BankSync emits authenticated transaction facts. Each consumer owns settlement,
|
|
@@ -216,6 +216,13 @@ pnpm pack --dry-run
|
|
|
216
216
|
builds, and package-export verification. Provider-live tests are intentionally
|
|
217
217
|
outside the default suite and must never use production bank credentials.
|
|
218
218
|
|
|
219
|
+
Dependabot minor and patch updates for development dependencies and GitHub
|
|
220
|
+
Actions automatically merge after all required checks pass on an up-to-date
|
|
221
|
+
branch. Major updates and production dependency updates require manual review.
|
|
222
|
+
TypeScript major updates are held back until the declaration build supports
|
|
223
|
+
the newer compiler API. The auto-merge workflow reads verified Dependabot
|
|
224
|
+
metadata only; it never checks out or runs pull-request code.
|
|
225
|
+
|
|
219
226
|
Stable releases are created from protected `v*` tags by GitHub Actions, publish
|
|
220
227
|
to npm through OIDC trusted publishing, and attach the exact same tarball,
|
|
221
228
|
checksum, provenance and SBOM to the GitHub Release.
|
|
@@ -223,3 +230,52 @@ checksum, provenance and SBOM to the GitHub Release.
|
|
|
223
230
|
## License
|
|
224
231
|
|
|
225
232
|
[MIT](LICENSE) © Festapp
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
## Version 0.2 integration contract
|
|
236
|
+
|
|
237
|
+
Existing consumers default to event version 1. Register a new prepared consumer
|
|
238
|
+
with `event_version: "2"`; its receiver calls `verifyWebhook({...args,
|
|
239
|
+
eventVersion: "2"})`. Receipt version remains 1. V1 receives only incoming facts
|
|
240
|
+
in its original shape; archived delivery payloads are immutable.
|
|
241
|
+
|
|
242
|
+
V2 adds `payer_reference`, `raw_vs`, signed `amount_cents`, `direction`,
|
|
243
|
+
`identity_kind` and `identity_provenance`. Decimal amounts and numeric bank IDs
|
|
244
|
+
are range-checked without rounding. Similar VS/amount/date never deduplicates
|
|
245
|
+
real movements. Transport IDs are account-scoped. Fio `ID pokynu` is a command,
|
|
246
|
+
not a movement; unproven email IDs are observations. Financial consumers must
|
|
247
|
+
quarantine observations until their own authorized reconciliation establishes
|
|
248
|
+
bank identity. `both` is unavailable to v2 pending provider correlation proof.
|
|
249
|
+
|
|
250
|
+
On schema 11, all Fio manual/queue/cron imports share physical-account and full
|
|
251
|
+
credential-hash leases. Imports use bounded `periods` reads, an encrypted durable
|
|
252
|
+
batch, and a checkpoint committed only after every row succeeds. They never
|
|
253
|
+
advance `/last`. The bootstrap covers 90 calendar dates; subsequent pulls overlap
|
|
254
|
+
three days. Older unresolved windows require operator/bank authorization rather
|
|
255
|
+
than silent truncation. Receiving-account and statement currency mismatches leave
|
|
256
|
+
the batch open.
|
|
257
|
+
|
|
258
|
+
A new connection can be created with `ingest_enabled:false`. Persist mapping and
|
|
259
|
+
verify its owner subscription before `PUT /bank-accounts/:id/ingest-state` with
|
|
260
|
+
`{enabled:true}`. Account identity cannot change under existing movements.
|
|
261
|
+
`GET .../ingest-state` provides the full credential digest for timeout recovery,
|
|
262
|
+
never the credential. Creation idempotency receipts are retained permanently.
|
|
263
|
+
|
|
264
|
+
New v2 email connections require `AUTHENTICATED_EMAIL_SPOOL=on` and `BACKUPS`.
|
|
265
|
+
Strict bank authentication precedes encrypted R2 persistence; persistence precedes
|
|
266
|
+
D1 account lookup. Original MIME bytes and trusted envelope evidence survive D1
|
|
267
|
+
outages. Scheduled cursor recovery visits failed messages without starving newer
|
|
268
|
+
ones. Authenticated parse failures stay quarantined. No active legacy recipient
|
|
269
|
+
migration is implied. New provider email automation requires real sanitized bank
|
|
270
|
+
fixtures and a genuine authenticated ingress canary.
|
|
271
|
+
|
|
272
|
+
`GET /bank-accounts/:id/transaction-export` returns complete account facts with
|
|
273
|
+
`cursor`, fixed `high_water` and `complete`; persist both coordinates.
|
|
274
|
+
`POST /admin/cutover-reconcile` is admin-only, requires a manifest SHA-256 and
|
|
275
|
+
an exact owner consumer/account/ID window, creates only missing v2 intents, and
|
|
276
|
+
never rewrites an archived payload or globally changes subscription intervals.
|
|
277
|
+
Unresolved or quarantined facts are retained beyond the usual 90-day cleanup.
|
|
278
|
+
Publishing this package does not deploy the shared Worker, mutate D1, enable bank
|
|
279
|
+
polling, or authorize replay of another consumer's history.
|
|
280
|
+
|
|
281
|
+
Canonical v2 transactions preserve bank-provided VS and payer reference without inferring an order identifier. Each consumer (Festapp, Mendelio, or another application) owns payment matching and business rules. The existing v1 RF-to-VS projection remains only as an explicit backwards-compatible adapter.
|
|
@@ -28,6 +28,15 @@ function toCents(amount, currency) {
|
|
|
28
28
|
}
|
|
29
29
|
return Math.round(amount * 10 ** dec);
|
|
30
30
|
}
|
|
31
|
+
function decimalToCents(raw, currency) {
|
|
32
|
+
if (MINOR_UNIT_DECIMALS[currency] !== 2) throw new Error("unsupported_currency_minor_unit");
|
|
33
|
+
const normalized = raw.replace(/\s/g, "").replace(",", ".");
|
|
34
|
+
const match = /^([+-]?)(\d+)(?:\.(\d{1,2}))?$/.exec(normalized);
|
|
35
|
+
if (!match) throw new Error("invalid_exact_amount");
|
|
36
|
+
const cents = (BigInt(match[2]) * 100n + BigInt((match[3] ?? "").padEnd(2, "0"))) * (match[1] === "-" ? -1n : 1n);
|
|
37
|
+
if (cents > BigInt(Number.MAX_SAFE_INTEGER) || cents < BigInt(Number.MIN_SAFE_INTEGER)) throw new Error("amount_out_of_range");
|
|
38
|
+
return Number(cents);
|
|
39
|
+
}
|
|
31
40
|
|
|
32
41
|
// src/parser.ts
|
|
33
42
|
var BANK_CODES = {
|
|
@@ -200,15 +209,15 @@ function parseEmail(text, provider) {
|
|
|
200
209
|
const currency = normalizeCurrency(rawCurrencyStr);
|
|
201
210
|
const amount = parseAmount(amountField.rawAmount);
|
|
202
211
|
if (isNaN(amount)) return null;
|
|
203
|
-
|
|
204
|
-
const amount_cents = toCents(amount, currency);
|
|
212
|
+
const amount_cents = decimalToCents(amountField.rawAmount, currency);
|
|
205
213
|
const accountMatch = text.match(/(?:Protiúčet|Protiucet|Account):\s*([0-9\/\s]+)/i);
|
|
206
214
|
const vsMatch = text.match(/VS:\s*([0-9]+)/i);
|
|
207
215
|
const ksMatch = text.match(/KS:\s*([0-9]+)/i);
|
|
208
216
|
const ssMatch = text.match(/SS:\s*([0-9]+)/i);
|
|
209
217
|
const msgMatch = text.match(/(?:Zpráva pro příjemce|Message):\s*(.*)/i);
|
|
210
218
|
const nameMatch = text.match(/(?:Název protiúčtu|Account Name):\s*(.*)/i);
|
|
211
|
-
const
|
|
219
|
+
const commandMatch = text.match(/ID pokynu:\s*([0-9]+)/i);
|
|
220
|
+
const idMatch = text.match(/ID pohybu:\s*([0-9]+)/i);
|
|
212
221
|
const dateMatch = text.match(
|
|
213
222
|
/Datum(?:\s+pohybu|\s+provedení|\s+zaúčtování)?:\s*(\d{2}\.\d{2}\.\d{4}(?:\s+\d{2}:\d{2}(?::\d{2})?(?:\s*[+-]\d{2}:?\d{2})?)?)/i
|
|
214
223
|
);
|
|
@@ -236,11 +245,16 @@ function parseEmail(text, provider) {
|
|
|
236
245
|
transaction_type: null,
|
|
237
246
|
performed_by: null,
|
|
238
247
|
comment: null,
|
|
239
|
-
|
|
248
|
+
payer_reference: null,
|
|
249
|
+
raw_vs: vsMatch ? vsMatch[1] ?? null : null,
|
|
250
|
+
direction: amount_cents > 0 ? "incoming" : amount_cents < 0 ? "outgoing" : "zero",
|
|
251
|
+
identity_kind: "observation",
|
|
252
|
+
identity_provenance: "authenticated_email_unverified_movement",
|
|
253
|
+
command_id: commandMatch ? commandMatch[1] ?? null : null,
|
|
240
254
|
source: "email",
|
|
241
255
|
date,
|
|
242
256
|
date_offset_min,
|
|
243
|
-
transaction_id:
|
|
257
|
+
transaction_id: null,
|
|
244
258
|
external_id: null
|
|
245
259
|
};
|
|
246
260
|
}
|
|
@@ -253,8 +267,7 @@ function parseEmail(text, provider) {
|
|
|
253
267
|
if (!amountStr) return null;
|
|
254
268
|
const amount = parseAmount(amountField.rawAmount);
|
|
255
269
|
if (isNaN(amount)) return null;
|
|
256
|
-
|
|
257
|
-
const amount_cents = toCents(amount, currency);
|
|
270
|
+
const amount_cents = decimalToCents(amountField.rawAmount, currency);
|
|
258
271
|
const counterparty = parseAirbankCounterparty(text);
|
|
259
272
|
const vsMatch = text.match(/(?:Variabilní symbol|\bVS\b)\s*:\s*([0-9]+)/i);
|
|
260
273
|
const ksMatch = text.match(/(?:Konstantní symbol|\bKS\b)\s*:\s*([0-9]+)/i);
|
|
@@ -290,6 +303,11 @@ function parseEmail(text, provider) {
|
|
|
290
303
|
transaction_type: null,
|
|
291
304
|
performed_by: null,
|
|
292
305
|
comment: null,
|
|
306
|
+
payer_reference: null,
|
|
307
|
+
raw_vs: vsMatch ? vsMatch[1] ?? null : null,
|
|
308
|
+
direction: amount_cents > 0 ? "incoming" : amount_cents < 0 ? "outgoing" : "zero",
|
|
309
|
+
identity_kind: "observation",
|
|
310
|
+
identity_provenance: "authenticated_email_unverified_movement",
|
|
293
311
|
command_id: null,
|
|
294
312
|
source: "email",
|
|
295
313
|
date,
|
|
@@ -400,12 +418,17 @@ function concatBytes(...parts) {
|
|
|
400
418
|
return out;
|
|
401
419
|
}
|
|
402
420
|
function buildWebhookEnvelope(args) {
|
|
421
|
+
const keys = ["id", "bank_account_id", "amount_cents", "currency", "counter_account", "bank_code", "bank_name", "vs", "ks", "ss", "message", "sender_name", "user_identification", "transaction_type", "performed_by", "comment", "command_id", "source", "date", "date_offset_min", "transaction_id", "external_id"];
|
|
422
|
+
if (args.event_version === "2") keys.push("payer_reference", "raw_vs", "direction", "identity_kind", "identity_provenance");
|
|
423
|
+
const data = Object.fromEntries(keys.map((key) => [key, args.transaction[key]]));
|
|
424
|
+
if (args.event_version !== "2") data.vs = resolveVariableSymbol(args.transaction);
|
|
425
|
+
if (args.event_version !== "2" && data.amount_cents <= 0) throw new Error("v1_incoming_only");
|
|
403
426
|
return {
|
|
404
427
|
event: "transaction.received",
|
|
405
|
-
event_version: "1",
|
|
428
|
+
event_version: args.event_version ?? "1",
|
|
406
429
|
delivery_id: args.delivery_id,
|
|
407
430
|
pairing_code: args.pairing_code,
|
|
408
|
-
data
|
|
431
|
+
data
|
|
409
432
|
};
|
|
410
433
|
}
|
|
411
434
|
async function signWebhook(args) {
|
|
@@ -452,7 +475,7 @@ function isNullableString(value) {
|
|
|
452
475
|
function isPositiveSafeInteger(value) {
|
|
453
476
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
454
477
|
}
|
|
455
|
-
function isTransaction(value) {
|
|
478
|
+
function isTransaction(value, version) {
|
|
456
479
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
457
480
|
const data = value;
|
|
458
481
|
const nullableStrings = [
|
|
@@ -472,7 +495,8 @@ function isTransaction(value) {
|
|
|
472
495
|
"transaction_id",
|
|
473
496
|
"external_id"
|
|
474
497
|
];
|
|
475
|
-
|
|
498
|
+
const v2Valid = version === "1" || ["CZK", "EUR", "USD"].includes(String(data.currency)) && typeof data.date === "string" && /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(data.date) && Number.isFinite(Date.parse(data.date)) && isNullableString(data.raw_vs) && isNullableString(data.payer_reference) && data.direction === (data.amount_cents > 0 ? "incoming" : data.amount_cents < 0 ? "outgoing" : "zero") && ["movement", "observation", "historical_unverified"].includes(String(data.identity_kind)) && typeof data.identity_provenance === "string" && data.identity_provenance.length > 0 && (data.identity_kind !== "movement" || typeof data.transaction_id === "string" && data.transaction_id.length > 0 && data.identity_provenance === "fio_api_column22" && data.source === "fio_api");
|
|
499
|
+
return v2Valid && isPositiveSafeInteger(data.id) && isPositiveSafeInteger(data.bank_account_id) && (version === "1" ? isPositiveSafeInteger(data.amount_cents) : typeof data.amount_cents === "number" && Number.isSafeInteger(data.amount_cents)) && typeof data.currency === "string" && /^[A-Z]{3}$/.test(data.currency) && nullableStrings.every((field) => isNullableString(data[field])) && (data.source === "email" || data.source === "fio_api") && typeof data.date === "string" && data.date.length > 0 && (data.date_offset_min === null || typeof data.date_offset_min === "number" && Number.isSafeInteger(data.date_offset_min));
|
|
476
500
|
}
|
|
477
501
|
async function verifyWebhook(args) {
|
|
478
502
|
if (!/^\d{10}$/.test(args.timestamp)) throw new WebhookVerificationError("timestamp_invalid");
|
|
@@ -527,8 +551,8 @@ async function verifyWebhook(args) {
|
|
|
527
551
|
throw new WebhookVerificationError("body_invalid");
|
|
528
552
|
}
|
|
529
553
|
if (value.event !== "transaction.received") throw new WebhookVerificationError("event_unsupported");
|
|
530
|
-
if (value.event_version !== "1") throw new WebhookVerificationError("event_version_unsupported");
|
|
531
|
-
if (typeof value.pairing_code !== "string" || !/^[0-9a-f]{10}$/i.test(value.pairing_code) || !isTransaction(value.data)) {
|
|
554
|
+
if (value.event_version !== (args.eventVersion ?? "1")) throw new WebhookVerificationError("event_version_unsupported");
|
|
555
|
+
if (typeof value.pairing_code !== "string" || !/^[0-9a-f]{10}$/i.test(value.pairing_code) || !isTransaction(value.data, args.eventVersion ?? "1")) {
|
|
532
556
|
throw new WebhookVerificationError("body_invalid");
|
|
533
557
|
}
|
|
534
558
|
return envelope;
|
|
@@ -561,15 +585,16 @@ var FioTransientFailure = class extends FioApiError {
|
|
|
561
585
|
function endpoint(path, token) {
|
|
562
586
|
return `${FIO_BASE}/${path}/${encodeURIComponent(token)}`;
|
|
563
587
|
}
|
|
564
|
-
async function fioRequest(op, token, directUrl, proxy, date) {
|
|
588
|
+
async function fioRequest(op, token, directUrl, proxy, date, toDate) {
|
|
565
589
|
if (proxy?.url && proxy.secret) {
|
|
566
590
|
return fetch(proxy.url, {
|
|
567
591
|
method: "POST",
|
|
568
592
|
headers: { "content-type": "application/json", "x-fio-proxy-secret": proxy.secret },
|
|
569
|
-
body: JSON.stringify(date === void 0 ? { op, token } : { op, token, date })
|
|
593
|
+
body: JSON.stringify(date === void 0 ? { op, token } : { op, token, date, ...toDate ? { toDate } : {} }),
|
|
594
|
+
signal: AbortSignal.timeout(2e4)
|
|
570
595
|
});
|
|
571
596
|
}
|
|
572
|
-
return fetch(directUrl);
|
|
597
|
+
return fetch(directUrl, { signal: AbortSignal.timeout(2e4) });
|
|
573
598
|
}
|
|
574
599
|
function retryAfterSeconds(headers) {
|
|
575
600
|
const raw = headers.get("Retry-After");
|
|
@@ -578,7 +603,7 @@ function retryAfterSeconds(headers) {
|
|
|
578
603
|
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
579
604
|
}
|
|
580
605
|
async function ensureFioResponse(res) {
|
|
581
|
-
if (res.status === 429) {
|
|
606
|
+
if (res.status === 429 || res.status === 409) {
|
|
582
607
|
throw new FioRateLimited(res.status, retryAfterSeconds(res.headers));
|
|
583
608
|
}
|
|
584
609
|
if (res.status >= 500) {
|
|
@@ -605,14 +630,10 @@ async function setFioPointer(token, yyyyMmDd, proxy) {
|
|
|
605
630
|
function column(raw, idx) {
|
|
606
631
|
const value = raw[`column${idx}`]?.value;
|
|
607
632
|
if (value === null || value === void 0) return null;
|
|
633
|
+
if (typeof value === "number" && (idx === 22 || idx === 17) && !Number.isSafeInteger(value)) throw new Error("unsafe_fio_identity");
|
|
608
634
|
const s = String(value).trim();
|
|
609
635
|
return s.length > 0 ? s : null;
|
|
610
636
|
}
|
|
611
|
-
function parseAmount2(raw) {
|
|
612
|
-
if (raw === null) return 0;
|
|
613
|
-
const normalized = raw.replace(/\s/g, "").replace(",", ".");
|
|
614
|
-
return Number.parseFloat(normalized);
|
|
615
|
-
}
|
|
616
637
|
function parseOffsetMinutes(raw) {
|
|
617
638
|
const m = raw.match(/([+-])(\d{2}):?(\d{2})$/);
|
|
618
639
|
if (!m) return null;
|
|
@@ -620,7 +641,7 @@ function parseOffsetMinutes(raw) {
|
|
|
620
641
|
return sign * (Number.parseInt(m[2] ?? "0", 10) * 60 + Number.parseInt(m[3] ?? "0", 10));
|
|
621
642
|
}
|
|
622
643
|
function parseFioDate(raw) {
|
|
623
|
-
if (!raw)
|
|
644
|
+
if (!raw) throw new Error("missing_fio_date");
|
|
624
645
|
const isoDateOnly = raw.match(/^(\d{4}-\d{2}-\d{2})(?:[+-]\d{2}:?\d{2})?$/);
|
|
625
646
|
if (isoDateOnly) {
|
|
626
647
|
return {
|
|
@@ -645,12 +666,17 @@ function parseFioDate(raw) {
|
|
|
645
666
|
throw new Error(`invalid_fio_date: ${JSON.stringify(raw)}`);
|
|
646
667
|
}
|
|
647
668
|
function mapFioTransaction(raw) {
|
|
648
|
-
const
|
|
649
|
-
if (
|
|
669
|
+
const rawAmount = column(raw, 1);
|
|
670
|
+
if (rawAmount === null) throw new Error("missing_fio_amount");
|
|
650
671
|
const currency = normalizeCurrency(column(raw, 14) ?? "");
|
|
651
672
|
const { date, date_offset_min } = parseFioDate(column(raw, 0));
|
|
652
673
|
return {
|
|
653
|
-
amount_cents:
|
|
674
|
+
amount_cents: decimalToCents(rawAmount, currency),
|
|
675
|
+
payer_reference: column(raw, 27),
|
|
676
|
+
raw_vs: column(raw, 5),
|
|
677
|
+
direction: decimalToCents(rawAmount, currency) > 0 ? "incoming" : decimalToCents(rawAmount, currency) < 0 ? "outgoing" : "zero",
|
|
678
|
+
identity_kind: column(raw, 22) ? "movement" : "observation",
|
|
679
|
+
identity_provenance: "fio_api_column22",
|
|
654
680
|
currency,
|
|
655
681
|
counter_account: column(raw, 2),
|
|
656
682
|
bank_code: column(raw, 3),
|
|
@@ -672,10 +698,22 @@ function mapFioTransaction(raw) {
|
|
|
672
698
|
external_id: null
|
|
673
699
|
};
|
|
674
700
|
}
|
|
701
|
+
async function fetchFioStatement(token, from, to, proxy) {
|
|
702
|
+
for (const date of [from, to]) if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date))) throw new Error("invalid_statement_window");
|
|
703
|
+
if (from > to || Date.parse(to) - Date.parse(from) > 90 * 864e5) throw new Error("unbounded_statement_window");
|
|
704
|
+
const response = await fioRequest("periods", token, `${endpoint("periods", token)}/${from}/${to}/transactions.json`, proxy, from, to);
|
|
705
|
+
await ensureFioResponse(response);
|
|
706
|
+
const document = await response.json();
|
|
707
|
+
const statement = document.accountStatement;
|
|
708
|
+
if (!statement?.info) throw new Error("missing_statement_info");
|
|
709
|
+
const rows = statement.transactionList?.transaction ?? [];
|
|
710
|
+
return { info: statement.info, transactions: Array.isArray(rows) ? rows : [rows] };
|
|
711
|
+
}
|
|
675
712
|
|
|
676
713
|
export {
|
|
677
714
|
normalizeCurrency,
|
|
678
715
|
toCents,
|
|
716
|
+
decimalToCents,
|
|
679
717
|
BANK_CODES,
|
|
680
718
|
detectProvider,
|
|
681
719
|
parseEmail,
|
|
@@ -692,5 +730,6 @@ export {
|
|
|
692
730
|
FioTransientFailure,
|
|
693
731
|
fetchNewTransactions,
|
|
694
732
|
setFioPointer,
|
|
695
|
-
mapFioTransaction
|
|
733
|
+
mapFioTransaction,
|
|
734
|
+
fetchFioStatement
|
|
696
735
|
};
|
|
@@ -28,6 +28,15 @@ function toCents(amount, currency) {
|
|
|
28
28
|
}
|
|
29
29
|
return Math.round(amount * 10 ** dec);
|
|
30
30
|
}
|
|
31
|
+
function decimalToCents(raw, currency) {
|
|
32
|
+
if (MINOR_UNIT_DECIMALS[currency] !== 2) throw new Error("unsupported_currency_minor_unit");
|
|
33
|
+
const normalized = raw.replace(/\s/g, "").replace(",", ".");
|
|
34
|
+
const match = /^([+-]?)(\d+)(?:\.(\d{1,2}))?$/.exec(normalized);
|
|
35
|
+
if (!match) throw new Error("invalid_exact_amount");
|
|
36
|
+
const cents = (BigInt(match[2]) * 100n + BigInt((_nullishCoalesce(match[3], () => ( ""))).padEnd(2, "0"))) * (match[1] === "-" ? -1n : 1n);
|
|
37
|
+
if (cents > BigInt(Number.MAX_SAFE_INTEGER) || cents < BigInt(Number.MIN_SAFE_INTEGER)) throw new Error("amount_out_of_range");
|
|
38
|
+
return Number(cents);
|
|
39
|
+
}
|
|
31
40
|
|
|
32
41
|
// src/parser.ts
|
|
33
42
|
var BANK_CODES = {
|
|
@@ -200,15 +209,15 @@ function parseEmail(text, provider) {
|
|
|
200
209
|
const currency = normalizeCurrency(rawCurrencyStr);
|
|
201
210
|
const amount = parseAmount(amountField.rawAmount);
|
|
202
211
|
if (isNaN(amount)) return null;
|
|
203
|
-
|
|
204
|
-
const amount_cents = toCents(amount, currency);
|
|
212
|
+
const amount_cents = decimalToCents(amountField.rawAmount, currency);
|
|
205
213
|
const accountMatch = text.match(/(?:Protiúčet|Protiucet|Account):\s*([0-9\/\s]+)/i);
|
|
206
214
|
const vsMatch = text.match(/VS:\s*([0-9]+)/i);
|
|
207
215
|
const ksMatch = text.match(/KS:\s*([0-9]+)/i);
|
|
208
216
|
const ssMatch = text.match(/SS:\s*([0-9]+)/i);
|
|
209
217
|
const msgMatch = text.match(/(?:Zpráva pro příjemce|Message):\s*(.*)/i);
|
|
210
218
|
const nameMatch = text.match(/(?:Název protiúčtu|Account Name):\s*(.*)/i);
|
|
211
|
-
const
|
|
219
|
+
const commandMatch = text.match(/ID pokynu:\s*([0-9]+)/i);
|
|
220
|
+
const idMatch = text.match(/ID pohybu:\s*([0-9]+)/i);
|
|
212
221
|
const dateMatch = text.match(
|
|
213
222
|
/Datum(?:\s+pohybu|\s+provedení|\s+zaúčtování)?:\s*(\d{2}\.\d{2}\.\d{4}(?:\s+\d{2}:\d{2}(?::\d{2})?(?:\s*[+-]\d{2}:?\d{2})?)?)/i
|
|
214
223
|
);
|
|
@@ -236,11 +245,16 @@ function parseEmail(text, provider) {
|
|
|
236
245
|
transaction_type: null,
|
|
237
246
|
performed_by: null,
|
|
238
247
|
comment: null,
|
|
239
|
-
|
|
248
|
+
payer_reference: null,
|
|
249
|
+
raw_vs: vsMatch ? _nullishCoalesce(vsMatch[1], () => ( null)) : null,
|
|
250
|
+
direction: amount_cents > 0 ? "incoming" : amount_cents < 0 ? "outgoing" : "zero",
|
|
251
|
+
identity_kind: "observation",
|
|
252
|
+
identity_provenance: "authenticated_email_unverified_movement",
|
|
253
|
+
command_id: commandMatch ? _nullishCoalesce(commandMatch[1], () => ( null)) : null,
|
|
240
254
|
source: "email",
|
|
241
255
|
date,
|
|
242
256
|
date_offset_min,
|
|
243
|
-
transaction_id:
|
|
257
|
+
transaction_id: null,
|
|
244
258
|
external_id: null
|
|
245
259
|
};
|
|
246
260
|
}
|
|
@@ -253,8 +267,7 @@ function parseEmail(text, provider) {
|
|
|
253
267
|
if (!amountStr) return null;
|
|
254
268
|
const amount = parseAmount(amountField.rawAmount);
|
|
255
269
|
if (isNaN(amount)) return null;
|
|
256
|
-
|
|
257
|
-
const amount_cents = toCents(amount, currency);
|
|
270
|
+
const amount_cents = decimalToCents(amountField.rawAmount, currency);
|
|
258
271
|
const counterparty = parseAirbankCounterparty(text);
|
|
259
272
|
const vsMatch = text.match(/(?:Variabilní symbol|\bVS\b)\s*:\s*([0-9]+)/i);
|
|
260
273
|
const ksMatch = text.match(/(?:Konstantní symbol|\bKS\b)\s*:\s*([0-9]+)/i);
|
|
@@ -290,6 +303,11 @@ function parseEmail(text, provider) {
|
|
|
290
303
|
transaction_type: null,
|
|
291
304
|
performed_by: null,
|
|
292
305
|
comment: null,
|
|
306
|
+
payer_reference: null,
|
|
307
|
+
raw_vs: vsMatch ? _nullishCoalesce(vsMatch[1], () => ( null)) : null,
|
|
308
|
+
direction: amount_cents > 0 ? "incoming" : amount_cents < 0 ? "outgoing" : "zero",
|
|
309
|
+
identity_kind: "observation",
|
|
310
|
+
identity_provenance: "authenticated_email_unverified_movement",
|
|
293
311
|
command_id: null,
|
|
294
312
|
source: "email",
|
|
295
313
|
date,
|
|
@@ -400,12 +418,17 @@ function concatBytes(...parts) {
|
|
|
400
418
|
return out;
|
|
401
419
|
}
|
|
402
420
|
function buildWebhookEnvelope(args) {
|
|
421
|
+
const keys = ["id", "bank_account_id", "amount_cents", "currency", "counter_account", "bank_code", "bank_name", "vs", "ks", "ss", "message", "sender_name", "user_identification", "transaction_type", "performed_by", "comment", "command_id", "source", "date", "date_offset_min", "transaction_id", "external_id"];
|
|
422
|
+
if (args.event_version === "2") keys.push("payer_reference", "raw_vs", "direction", "identity_kind", "identity_provenance");
|
|
423
|
+
const data = Object.fromEntries(keys.map((key) => [key, args.transaction[key]]));
|
|
424
|
+
if (args.event_version !== "2") data.vs = resolveVariableSymbol(args.transaction);
|
|
425
|
+
if (args.event_version !== "2" && data.amount_cents <= 0) throw new Error("v1_incoming_only");
|
|
403
426
|
return {
|
|
404
427
|
event: "transaction.received",
|
|
405
|
-
event_version: "1",
|
|
428
|
+
event_version: _nullishCoalesce(args.event_version, () => ( "1")),
|
|
406
429
|
delivery_id: args.delivery_id,
|
|
407
430
|
pairing_code: args.pairing_code,
|
|
408
|
-
data
|
|
431
|
+
data
|
|
409
432
|
};
|
|
410
433
|
}
|
|
411
434
|
async function signWebhook(args) {
|
|
@@ -452,7 +475,7 @@ function isNullableString(value) {
|
|
|
452
475
|
function isPositiveSafeInteger(value) {
|
|
453
476
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
454
477
|
}
|
|
455
|
-
function isTransaction(value) {
|
|
478
|
+
function isTransaction(value, version) {
|
|
456
479
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
457
480
|
const data = value;
|
|
458
481
|
const nullableStrings = [
|
|
@@ -472,7 +495,8 @@ function isTransaction(value) {
|
|
|
472
495
|
"transaction_id",
|
|
473
496
|
"external_id"
|
|
474
497
|
];
|
|
475
|
-
|
|
498
|
+
const v2Valid = version === "1" || ["CZK", "EUR", "USD"].includes(String(data.currency)) && typeof data.date === "string" && /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(data.date) && Number.isFinite(Date.parse(data.date)) && isNullableString(data.raw_vs) && isNullableString(data.payer_reference) && data.direction === (data.amount_cents > 0 ? "incoming" : data.amount_cents < 0 ? "outgoing" : "zero") && ["movement", "observation", "historical_unverified"].includes(String(data.identity_kind)) && typeof data.identity_provenance === "string" && data.identity_provenance.length > 0 && (data.identity_kind !== "movement" || typeof data.transaction_id === "string" && data.transaction_id.length > 0 && data.identity_provenance === "fio_api_column22" && data.source === "fio_api");
|
|
499
|
+
return v2Valid && isPositiveSafeInteger(data.id) && isPositiveSafeInteger(data.bank_account_id) && (version === "1" ? isPositiveSafeInteger(data.amount_cents) : typeof data.amount_cents === "number" && Number.isSafeInteger(data.amount_cents)) && typeof data.currency === "string" && /^[A-Z]{3}$/.test(data.currency) && nullableStrings.every((field) => isNullableString(data[field])) && (data.source === "email" || data.source === "fio_api") && typeof data.date === "string" && data.date.length > 0 && (data.date_offset_min === null || typeof data.date_offset_min === "number" && Number.isSafeInteger(data.date_offset_min));
|
|
476
500
|
}
|
|
477
501
|
async function verifyWebhook(args) {
|
|
478
502
|
if (!/^\d{10}$/.test(args.timestamp)) throw new WebhookVerificationError("timestamp_invalid");
|
|
@@ -527,8 +551,8 @@ async function verifyWebhook(args) {
|
|
|
527
551
|
throw new WebhookVerificationError("body_invalid");
|
|
528
552
|
}
|
|
529
553
|
if (value.event !== "transaction.received") throw new WebhookVerificationError("event_unsupported");
|
|
530
|
-
if (value.event_version !== "1") throw new WebhookVerificationError("event_version_unsupported");
|
|
531
|
-
if (typeof value.pairing_code !== "string" || !/^[0-9a-f]{10}$/i.test(value.pairing_code) || !isTransaction(value.data)) {
|
|
554
|
+
if (value.event_version !== (_nullishCoalesce(args.eventVersion, () => ( "1")))) throw new WebhookVerificationError("event_version_unsupported");
|
|
555
|
+
if (typeof value.pairing_code !== "string" || !/^[0-9a-f]{10}$/i.test(value.pairing_code) || !isTransaction(value.data, _nullishCoalesce(args.eventVersion, () => ( "1")))) {
|
|
532
556
|
throw new WebhookVerificationError("body_invalid");
|
|
533
557
|
}
|
|
534
558
|
return envelope;
|
|
@@ -561,15 +585,16 @@ var FioTransientFailure = class extends FioApiError {
|
|
|
561
585
|
function endpoint(path, token) {
|
|
562
586
|
return `${FIO_BASE}/${path}/${encodeURIComponent(token)}`;
|
|
563
587
|
}
|
|
564
|
-
async function fioRequest(op, token, directUrl, proxy, date) {
|
|
588
|
+
async function fioRequest(op, token, directUrl, proxy, date, toDate) {
|
|
565
589
|
if (_optionalChain([proxy, 'optionalAccess', _3 => _3.url]) && proxy.secret) {
|
|
566
590
|
return fetch(proxy.url, {
|
|
567
591
|
method: "POST",
|
|
568
592
|
headers: { "content-type": "application/json", "x-fio-proxy-secret": proxy.secret },
|
|
569
|
-
body: JSON.stringify(date === void 0 ? { op, token } : { op, token, date })
|
|
593
|
+
body: JSON.stringify(date === void 0 ? { op, token } : { op, token, date, ...toDate ? { toDate } : {} }),
|
|
594
|
+
signal: AbortSignal.timeout(2e4)
|
|
570
595
|
});
|
|
571
596
|
}
|
|
572
|
-
return fetch(directUrl);
|
|
597
|
+
return fetch(directUrl, { signal: AbortSignal.timeout(2e4) });
|
|
573
598
|
}
|
|
574
599
|
function retryAfterSeconds(headers) {
|
|
575
600
|
const raw = headers.get("Retry-After");
|
|
@@ -578,7 +603,7 @@ function retryAfterSeconds(headers) {
|
|
|
578
603
|
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
579
604
|
}
|
|
580
605
|
async function ensureFioResponse(res) {
|
|
581
|
-
if (res.status === 429) {
|
|
606
|
+
if (res.status === 429 || res.status === 409) {
|
|
582
607
|
throw new FioRateLimited(res.status, retryAfterSeconds(res.headers));
|
|
583
608
|
}
|
|
584
609
|
if (res.status >= 500) {
|
|
@@ -605,14 +630,10 @@ async function setFioPointer(token, yyyyMmDd, proxy) {
|
|
|
605
630
|
function column(raw, idx) {
|
|
606
631
|
const value = _optionalChain([raw, 'access', _7 => _7[`column${idx}`], 'optionalAccess', _8 => _8.value]);
|
|
607
632
|
if (value === null || value === void 0) return null;
|
|
633
|
+
if (typeof value === "number" && (idx === 22 || idx === 17) && !Number.isSafeInteger(value)) throw new Error("unsafe_fio_identity");
|
|
608
634
|
const s = String(value).trim();
|
|
609
635
|
return s.length > 0 ? s : null;
|
|
610
636
|
}
|
|
611
|
-
function parseAmount2(raw) {
|
|
612
|
-
if (raw === null) return 0;
|
|
613
|
-
const normalized = raw.replace(/\s/g, "").replace(",", ".");
|
|
614
|
-
return Number.parseFloat(normalized);
|
|
615
|
-
}
|
|
616
637
|
function parseOffsetMinutes(raw) {
|
|
617
638
|
const m = raw.match(/([+-])(\d{2}):?(\d{2})$/);
|
|
618
639
|
if (!m) return null;
|
|
@@ -620,7 +641,7 @@ function parseOffsetMinutes(raw) {
|
|
|
620
641
|
return sign * (Number.parseInt(_nullishCoalesce(m[2], () => ( "0")), 10) * 60 + Number.parseInt(_nullishCoalesce(m[3], () => ( "0")), 10));
|
|
621
642
|
}
|
|
622
643
|
function parseFioDate(raw) {
|
|
623
|
-
if (!raw)
|
|
644
|
+
if (!raw) throw new Error("missing_fio_date");
|
|
624
645
|
const isoDateOnly = raw.match(/^(\d{4}-\d{2}-\d{2})(?:[+-]\d{2}:?\d{2})?$/);
|
|
625
646
|
if (isoDateOnly) {
|
|
626
647
|
return {
|
|
@@ -645,12 +666,17 @@ function parseFioDate(raw) {
|
|
|
645
666
|
throw new Error(`invalid_fio_date: ${JSON.stringify(raw)}`);
|
|
646
667
|
}
|
|
647
668
|
function mapFioTransaction(raw) {
|
|
648
|
-
const
|
|
649
|
-
if (
|
|
669
|
+
const rawAmount = column(raw, 1);
|
|
670
|
+
if (rawAmount === null) throw new Error("missing_fio_amount");
|
|
650
671
|
const currency = normalizeCurrency(_nullishCoalesce(column(raw, 14), () => ( "")));
|
|
651
672
|
const { date, date_offset_min } = parseFioDate(column(raw, 0));
|
|
652
673
|
return {
|
|
653
|
-
amount_cents:
|
|
674
|
+
amount_cents: decimalToCents(rawAmount, currency),
|
|
675
|
+
payer_reference: column(raw, 27),
|
|
676
|
+
raw_vs: column(raw, 5),
|
|
677
|
+
direction: decimalToCents(rawAmount, currency) > 0 ? "incoming" : decimalToCents(rawAmount, currency) < 0 ? "outgoing" : "zero",
|
|
678
|
+
identity_kind: column(raw, 22) ? "movement" : "observation",
|
|
679
|
+
identity_provenance: "fio_api_column22",
|
|
654
680
|
currency,
|
|
655
681
|
counter_account: column(raw, 2),
|
|
656
682
|
bank_code: column(raw, 3),
|
|
@@ -672,6 +698,19 @@ function mapFioTransaction(raw) {
|
|
|
672
698
|
external_id: null
|
|
673
699
|
};
|
|
674
700
|
}
|
|
701
|
+
async function fetchFioStatement(token, from, to, proxy) {
|
|
702
|
+
for (const date of [from, to]) if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date))) throw new Error("invalid_statement_window");
|
|
703
|
+
if (from > to || Date.parse(to) - Date.parse(from) > 90 * 864e5) throw new Error("unbounded_statement_window");
|
|
704
|
+
const response = await fioRequest("periods", token, `${endpoint("periods", token)}/${from}/${to}/transactions.json`, proxy, from, to);
|
|
705
|
+
await ensureFioResponse(response);
|
|
706
|
+
const document = await response.json();
|
|
707
|
+
const statement = document.accountStatement;
|
|
708
|
+
if (!_optionalChain([statement, 'optionalAccess', _9 => _9.info])) throw new Error("missing_statement_info");
|
|
709
|
+
const rows = _nullishCoalesce(_optionalChain([statement, 'access', _10 => _10.transactionList, 'optionalAccess', _11 => _11.transaction]), () => ( []));
|
|
710
|
+
return { info: statement.info, transactions: Array.isArray(rows) ? rows : [rows] };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
|
|
675
714
|
|
|
676
715
|
|
|
677
716
|
|
|
@@ -693,4 +732,4 @@ function mapFioTransaction(raw) {
|
|
|
693
732
|
|
|
694
733
|
|
|
695
734
|
|
|
696
|
-
exports.normalizeCurrency = normalizeCurrency; exports.toCents = toCents; exports.BANK_CODES = BANK_CODES; exports.detectProvider = detectProvider; exports.parseEmail = parseEmail; exports.encodeRf = encodeRf; exports.decodeRf = decodeRf; exports.resolveVariableSymbol = resolveVariableSymbol; exports.extractReferenceCandidates = extractReferenceCandidates; exports.buildWebhookEnvelope = buildWebhookEnvelope; exports.signWebhook = signWebhook; exports.WebhookVerificationError = WebhookVerificationError; exports.verifyWebhook = verifyWebhook; exports.FioApiError = FioApiError; exports.FioRateLimited = FioRateLimited; exports.FioTransientFailure = FioTransientFailure; exports.fetchNewTransactions = fetchNewTransactions; exports.setFioPointer = setFioPointer; exports.mapFioTransaction = mapFioTransaction;
|
|
735
|
+
exports.normalizeCurrency = normalizeCurrency; exports.toCents = toCents; exports.decimalToCents = decimalToCents; exports.BANK_CODES = BANK_CODES; exports.detectProvider = detectProvider; exports.parseEmail = parseEmail; exports.encodeRf = encodeRf; exports.decodeRf = decodeRf; exports.resolveVariableSymbol = resolveVariableSymbol; exports.extractReferenceCandidates = extractReferenceCandidates; exports.buildWebhookEnvelope = buildWebhookEnvelope; exports.signWebhook = signWebhook; exports.WebhookVerificationError = WebhookVerificationError; exports.verifyWebhook = verifyWebhook; exports.FioApiError = FioApiError; exports.FioRateLimited = FioRateLimited; exports.FioTransientFailure = FioTransientFailure; exports.fetchNewTransactions = fetchNewTransactions; exports.setFioPointer = setFioPointer; exports.mapFioTransaction = mapFioTransaction; exports.fetchFioStatement = fetchFioStatement;
|