@festapp/banksync 0.1.6 → 0.2.2

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 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; optional R2 |
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 only if encrypted backups are enabled;
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. For a fresh database, apply `migrations/0001_schema.sql`. For a production
122
- database whose recorded history ends at `0009`, apply only
123
- `migrations/0010_security_hardening.sql` through a scoped, reviewed migration.
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`. Future migrations
146
- must start at `0011`; never renumber or replace the baseline.
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,54 @@ 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.
282
+
283
+ Successful schema 11 Fio imports immediately sweep the canonical webhook outbox into the delivery queue, including retryable dispatch failures from earlier imports. Periodic reconciliation remains recovery; consumers do not wait for its next tick in the normal path.
@@ -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
- if (amount < 0) return null;
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 idMatch = text.match(/(?:ID pokynu|Transaction ID):\s*([0-9]+)/i);
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
- command_id: null,
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: idMatch ? idMatch[1] ?? null : null,
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
- if (amount < 0) return null;
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: args.transaction
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
- return isPositiveSafeInteger(data.id) && isPositiveSafeInteger(data.bank_account_id) && isPositiveSafeInteger(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));
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) return { date: (/* @__PURE__ */ new Date()).toISOString(), date_offset_min: null };
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 amount = parseAmount2(column(raw, 1));
649
- if (!(amount > 0)) return null;
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: toCents(amount, currency),
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
- if (amount < 0) return null;
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 idMatch = text.match(/(?:ID pokynu|Transaction ID):\s*([0-9]+)/i);
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
- command_id: null,
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: idMatch ? _nullishCoalesce(idMatch[1], () => ( null)) : null,
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
- if (amount < 0) return null;
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: args.transaction
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
- return isPositiveSafeInteger(data.id) && isPositiveSafeInteger(data.bank_account_id) && isPositiveSafeInteger(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));
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) return { date: (/* @__PURE__ */ new Date()).toISOString(), date_offset_min: null };
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 amount = parseAmount2(column(raw, 1));
649
- if (!(amount > 0)) return null;
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: toCents(amount, currency),
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;