@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.
@@ -1,5 +1,5 @@
1
1
  import { D1Database, Queue, R2Bucket, ForwardableEmailMessage, ExecutionContext, MessageBatch, ScheduledEvent } from '@cloudflare/workers-types';
2
- import { W as WebhookEnvelope } from './types-Cb5l2lXq.cjs';
2
+ import { W as WebhookEnvelope } from './types-DZX_B1qb.cjs';
3
3
 
4
4
  interface EmailEnvelopeEvidence {
5
5
  mailFrom: string;
@@ -61,6 +61,8 @@ interface Env extends VersionedSecretEnv {
61
61
  BACKUPS?: R2Bucket;
62
62
  /** Trusted authserv-id observed and verified from Cloudflare Email Routing. */
63
63
  EMAIL_AUTHSERV_ID?: string;
64
+ /** Explicit capability for the shared encrypted authenticated ingress spool. */
65
+ AUTHENTICATED_EMAIL_SPOOL?: 'on';
64
66
  /** Exact comma-separated callback hostnames allowed in production. */
65
67
  CALLBACK_HOST_ALLOWLIST?: string;
66
68
  /** Active application-layer backup key version. */
@@ -1,5 +1,5 @@
1
1
  import { D1Database, Queue, R2Bucket, ForwardableEmailMessage, ExecutionContext, MessageBatch, ScheduledEvent } from '@cloudflare/workers-types';
2
- import { W as WebhookEnvelope } from './types-Cb5l2lXq.js';
2
+ import { W as WebhookEnvelope } from './types-DZX_B1qb.js';
3
3
 
4
4
  interface EmailEnvelopeEvidence {
5
5
  mailFrom: string;
@@ -61,6 +61,8 @@ interface Env extends VersionedSecretEnv {
61
61
  BACKUPS?: R2Bucket;
62
62
  /** Trusted authserv-id observed and verified from Cloudflare Email Routing. */
63
63
  EMAIL_AUTHSERV_ID?: string;
64
+ /** Explicit capability for the shared encrypted authenticated ingress spool. */
65
+ AUTHENTICATED_EMAIL_SPOOL?: 'on';
64
66
  /** Exact comma-separated callback hostnames allowed in production. */
65
67
  CALLBACK_HOST_ALLOWLIST?: string;
66
68
  /** Active application-layer backup key version. */
@@ -3,13 +3,14 @@ import {
3
3
  FioTransientFailure,
4
4
  buildWebhookEnvelope,
5
5
  detectProvider,
6
+ fetchFioStatement,
6
7
  fetchNewTransactions,
7
8
  mapFioTransaction,
8
9
  parseEmail,
9
10
  resolveVariableSymbol,
10
11
  setFioPointer,
11
12
  signWebhook
12
- } from "./chunk-5QXHZGAW.js";
13
+ } from "./chunk-3WM2GGFL.js";
13
14
 
14
15
  // src/mime.ts
15
16
  import PostalMime from "postal-mime";
@@ -313,7 +314,7 @@ function logError(event, err, data) {
313
314
  }
314
315
 
315
316
  // src/db.ts
316
- var SUPPORTED_SCHEMA_VERSIONS = ["10"];
317
+ var SUPPORTED_SCHEMA_VERSIONS = ["10", "11"];
317
318
  var _checkedDbs = /* @__PURE__ */ new Set();
318
319
  async function assertSchemaVersion(db) {
319
320
  if (_checkedDbs.has(db)) return;
@@ -542,6 +543,7 @@ var SQL_CREATE_CONSUMER = `INSERT INTO webhook_consumers (app_id, callback_url,
542
543
  async function createConsumer(db, args) {
543
544
  const row = await db.prepare(SQL_CREATE_CONSUMER).bind(args.app_id, args.callback_url, args.secret_cipher, args.secret_hash, args.secret_prefix).first();
544
545
  if (!row) throw new Error("createConsumer: no row returned");
546
+ if (args.event_version === "2") await db.prepare("UPDATE webhook_consumers SET event_version='2' WHERE id=?").bind(row.id).run();
545
547
  return row;
546
548
  }
547
549
  var SQL_FIND_CONSUMER_BY_APP_ID = `SELECT id, app_id, callback_url, secret_cipher, secret_hash, secret_prefix, prev_secret_cipher, prev_expires_at, admin_key_hash, admin_key_prefix, created_at FROM webhook_consumers WHERE app_id = ?`;
@@ -601,45 +603,30 @@ INSERT OR IGNORE INTO transactions (
601
603
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
602
604
  `;
603
605
  var SQL_SELECT_TRANSACTION_BY_ID = `SELECT 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 FROM transactions WHERE id = ?`;
604
- var SQL_FIND_DUPLICATE_EXT = `SELECT id FROM transactions WHERE external_id = ? AND external_id IS NOT NULL LIMIT 1`;
605
- var SQL_FIND_DUPLICATE_FIO = `SELECT id FROM transactions WHERE bank_account_id = ? AND transaction_id = ? AND transaction_id IS NOT NULL LIMIT 1`;
606
- var SQL_FIND_DUPLICATE_FUZZY = `
607
- SELECT id
608
- FROM transactions
609
- WHERE bank_account_id = ?
610
- AND vs = ?
611
- AND amount_cents = ?
612
- AND currency = ?
613
- AND substr(date, 1, 10) BETWEEN date(?, '-3 days') AND date(?, '+3 days')
614
- LIMIT 1
615
- `;
616
- function fuzzyDateDay(date) {
617
- return /^\d{4}-\d{2}-\d{2}/.test(date) ? date.slice(0, 10) : null;
618
- }
619
- async function findFuzzyDuplicate(db, bankAccountId, p) {
620
- if (!p.vs) return false;
621
- const dateDay = fuzzyDateDay(p.date);
622
- if (!dateDay) return false;
623
- const hit = await db.prepare(SQL_FIND_DUPLICATE_FUZZY).bind(bankAccountId, p.vs, p.amount_cents, p.currency, dateDay, dateDay).first();
624
- return hit !== null;
625
- }
606
+ var SQL_FIND_DUPLICATE_EXT = `SELECT id FROM transactions WHERE bank_account_id = ? AND external_id = ? AND external_id IS NOT NULL LIMIT 1`;
607
+ var SQL_FIND_DUPLICATE_FIO = `SELECT * FROM transactions WHERE bank_account_id = ? AND transaction_id = ? AND transaction_id IS NOT NULL AND source='fio_api' LIMIT 1`;
626
608
  async function insertTransaction(db, args) {
609
+ const schema = await db.prepare(`SELECT value FROM schema_meta WHERE key='version'`).first();
610
+ const v2 = schema?.value === "11";
627
611
  const p = {
628
612
  ...args.payload,
629
- vs: resolveVariableSymbol(args.payload)
613
+ vs: v2 ? args.payload.raw_vs ?? args.payload.vs ?? null : resolveVariableSymbol(args.payload)
630
614
  };
631
- if (p.transaction_id != null) {
615
+ if (p.transaction_id != null && p.source === "fio_api") {
632
616
  const hit = await db.prepare(SQL_FIND_DUPLICATE_FIO).bind(args.bank_account_id, p.transaction_id).first();
633
- if (hit) return { status: "skipped", reason: "duplicate_transaction_id" };
617
+ if (hit) {
618
+ const facts = hit;
619
+ if (facts.amount_cents !== p.amount_cents || facts.currency !== p.currency || facts.date.slice(0, 10) !== p.date.slice(0, 10) || facts.identity_kind === "movement" && (facts.raw_vs !== (p.raw_vs ?? args.payload.vs ?? null) || facts.payer_reference !== (p.payer_reference ?? null))) throw new Error("bank_movement_fact_conflict");
620
+ return { status: "skipped", reason: "duplicate_transaction_id" };
621
+ }
634
622
  }
635
623
  if (p.external_id != null) {
636
- const hit = await db.prepare(SQL_FIND_DUPLICATE_EXT).bind(p.external_id).first();
624
+ const hit = await db.prepare(SQL_FIND_DUPLICATE_EXT).bind(args.bank_account_id, p.external_id).first();
637
625
  if (hit) return { status: "skipped", reason: "duplicate_external_id" };
638
626
  }
639
- if (await findFuzzyDuplicate(db, args.bank_account_id, p)) {
640
- return { status: "skipped", reason: "fuzzy_duplicate" };
641
- }
642
- const result = await db.prepare(SQL_INSERT_TRANSACTION).bind(
627
+ if (p.amount_cents <= 0 && !v2) throw new Error("signed_facts_require_schema11");
628
+ const sql = v2 ? SQL_INSERT_TRANSACTION.replace("external_id\n)", "external_id, payer_reference, raw_vs, direction, identity_kind, identity_provenance\n)").replace("?)\n", "?, ?, ?, ?, ?, ?)\n") : SQL_INSERT_TRANSACTION;
629
+ const values = [
643
630
  args.bank_account_id,
644
631
  p.amount_cents,
645
632
  p.currency,
@@ -661,23 +648,32 @@ async function insertTransaction(db, args) {
661
648
  p.date_offset_min ?? null,
662
649
  p.transaction_id ?? null,
663
650
  p.external_id ?? null
664
- ).run();
651
+ ];
652
+ if (v2) values.push(
653
+ p.payer_reference ?? null,
654
+ args.payload.raw_vs ?? args.payload.vs ?? null,
655
+ p.amount_cents > 0 ? "incoming" : p.amount_cents < 0 ? "outgoing" : "zero",
656
+ p.identity_kind ?? (p.source === "fio_api" && p.transaction_id ? "movement" : "observation"),
657
+ p.identity_provenance ?? (p.source === "fio_api" ? "fio_api_column22" : "authenticated_email_unverified_movement")
658
+ );
659
+ const result = await db.prepare(sql).bind(...values).run();
665
660
  if (result.meta.changes === 0) {
666
- if (p.transaction_id != null) {
661
+ if (p.transaction_id != null && p.source === "fio_api") {
667
662
  const hit = await db.prepare(SQL_FIND_DUPLICATE_FIO).bind(args.bank_account_id, p.transaction_id).first();
668
- if (hit) return { status: "skipped", reason: "duplicate_transaction_id" };
663
+ if (hit) {
664
+ const facts = hit;
665
+ if (facts.amount_cents !== p.amount_cents || facts.currency !== p.currency || facts.date.slice(0, 10) !== p.date.slice(0, 10) || facts.identity_kind === "movement" && (facts.raw_vs !== (p.raw_vs ?? args.payload.vs ?? null) || facts.payer_reference !== (p.payer_reference ?? null))) throw new Error("bank_movement_fact_conflict");
666
+ return { status: "skipped", reason: "duplicate_transaction_id" };
667
+ }
669
668
  }
670
669
  if (p.external_id != null) {
671
- const hit = await db.prepare(SQL_FIND_DUPLICATE_EXT).bind(p.external_id).first();
670
+ const hit = await db.prepare(SQL_FIND_DUPLICATE_EXT).bind(args.bank_account_id, p.external_id).first();
672
671
  if (hit) return { status: "skipped", reason: "duplicate_external_id" };
673
672
  }
674
- if (await findFuzzyDuplicate(db, args.bank_account_id, p)) {
675
- return { status: "skipped", reason: "fuzzy_duplicate" };
676
- }
677
- return { status: "skipped", reason: "fuzzy_duplicate" };
673
+ throw new Error("transaction_insert_constraint_conflict");
678
674
  }
679
675
  const rowId = result.meta.last_row_id;
680
- const row = await db.prepare(SQL_SELECT_TRANSACTION_BY_ID).bind(rowId).first();
676
+ const row = await db.prepare(v2 ? "SELECT * FROM transactions WHERE id = ?" : SQL_SELECT_TRANSACTION_BY_ID).bind(rowId).first();
681
677
  if (!row) throw new Error("insertTransaction: row vanished after insert");
682
678
  return { status: "inserted", transaction: row };
683
679
  }
@@ -880,8 +876,14 @@ async function getStatusData(db) {
880
876
  var SQL_PRUNE_PARSE_LOG = `DELETE FROM parse_log WHERE created_at < datetime('now', '-30 days')`;
881
877
  var SQL_PRUNE_WEBHOOK_LOG = `DELETE FROM webhook_log WHERE created_at < datetime('now', '-90 days')`;
882
878
  var SQL_PRUNE_EVENT_LOG = `DELETE FROM event_log WHERE created_at < datetime('now', '-30 days')`;
883
- var SQL_PRUNE_TRANSACTIONS = `DELETE FROM transactions WHERE created_at < datetime('now', '-90 days')`;
884
- var SQL_PRUNE_IDEMPOTENCY_KEYS = `DELETE FROM idempotency_keys WHERE created_at < datetime('now', '-24 hours')`;
879
+ var SQL_PRUNE_TRANSACTIONS = `DELETE FROM transactions WHERE created_at < datetime('now', '-90 days')
880
+ AND NOT EXISTS (SELECT 1 FROM webhook_delivery_jobs j WHERE j.transaction_id=transactions.id
881
+ AND (j.status<>'delivered' OR j.business_outcome LIKE 'quarantined_%'))
882
+ AND NOT EXISTS (SELECT 1 FROM webhook_subscriptions s WHERE s.bank_account_id=transactions.bank_account_id
883
+ AND datetime(s.created_at)<=datetime(transactions.created_at)
884
+ AND (s.deleted_at IS NULL OR datetime(transactions.created_at)<datetime(s.deleted_at))
885
+ AND NOT EXISTS (SELECT 1 FROM webhook_delivery_jobs j WHERE j.transaction_id=transactions.id AND j.consumer_app_id=s.consumer_app_id))`;
886
+ var SQL_PRUNE_IDEMPOTENCY_KEYS = `DELETE FROM idempotency_keys WHERE request_path <> '/bank-accounts' AND created_at < datetime('now', '-24 hours')`;
885
887
  var SQL_PRUNE_ADMIN_AUDIT_LOG = `DELETE FROM admin_audit_log WHERE created_at < datetime('now', '-90 days')`;
886
888
  var SQL_CLEAR_EXPIRED_PREV_SECRETS = `UPDATE webhook_consumers SET prev_secret_cipher = NULL, prev_expires_at = NULL WHERE prev_expires_at < datetime('now')`;
887
889
  async function pruneRetention(db) {
@@ -925,6 +927,7 @@ function parseBody(schema, body) {
925
927
  }
926
928
  }
927
929
  var CreateBankAccountSchema = v.object({
930
+ ingest_enabled: v.optional(v.boolean()),
928
931
  account_number: v.pipe(v.string(), v.minLength(3), v.maxLength(64)),
929
932
  account_type: v.optional(v.picklist(["FIO", "AIRBANK"])),
930
933
  ingest_mode: v.optional(v.picklist(["email", "api", "both"])),
@@ -960,6 +963,7 @@ var UpdateFioTokenSchema = v.pipe(
960
963
  )
961
964
  );
962
965
  var CreateConsumerSchema = v.object({
966
+ event_version: v.optional(v.picklist(["1", "2"])),
963
967
  app_id: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-_]{1,63}$/)),
964
968
  callback_url: v.pipe(v.string(), v.url(), v.regex(/^https:\/\//))
965
969
  });
@@ -1197,13 +1201,18 @@ async function sha256Hex(input) {
1197
1201
  function newDispatchToken() {
1198
1202
  return crypto.randomUUID();
1199
1203
  }
1200
- async function ensureDeliveryJobs(db, transactionId) {
1204
+ async function ensureDeliveryJobs(db, transactionId, cutover) {
1205
+ const schema = await db.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
1206
+ const expanded = schema?.value === "11";
1207
+ if (cutover && !expanded) throw new Error("cutover_requires_schema11");
1201
1208
  const candidates = await db.prepare(`
1202
- SELECT t.*, b.pairing_code, s.consumer_app_id
1209
+ SELECT t.*, b.pairing_code, s.consumer_app_id${expanded ? ", c.event_version" : ""}
1203
1210
  FROM transactions t
1204
1211
  JOIN bank_accounts b ON b.id = t.bank_account_id
1205
1212
  JOIN webhook_subscriptions s ON s.bank_account_id = t.bank_account_id
1213
+ ${expanded ? "JOIN webhook_consumers c ON c.app_id = s.consumer_app_id" : ""}
1206
1214
  WHERE (? IS NULL OR t.id = ?)
1215
+ AND ${expanded ? "(c.event_version='2' OR t.amount_cents > 0)" : "t.amount_cents > 0"}
1207
1216
  AND NOT EXISTS (
1208
1217
  SELECT 1 FROM webhook_delivery_jobs j
1209
1218
  WHERE j.transaction_id = t.id AND j.consumer_app_id = s.consumer_app_id
@@ -1212,16 +1221,17 @@ async function ensureDeliveryJobs(db, transactionId) {
1212
1221
  -- Intent is decided by the subscription interval at ingest, not by the
1213
1222
  -- current active-subscription view. Exact UTC datetime (not julianday) so
1214
1223
  -- a same-second create/delete/transaction is deterministic.
1215
- AND datetime(s.created_at) <= datetime(t.created_at)
1216
- AND (s.deleted_at IS NULL OR datetime(t.created_at) < datetime(s.deleted_at))
1217
- `).bind(transactionId ?? null, transactionId ?? null).all();
1224
+ ${cutover ? "AND t.bank_account_id=? AND s.consumer_app_id=? AND t.id>=? AND t.id<=? AND s.deleted_at IS NULL AND c.event_version='2'" : "AND datetime(s.created_at) <= datetime(t.created_at) AND (s.deleted_at IS NULL OR datetime(t.created_at) < datetime(s.deleted_at))"}
1225
+ `).bind(transactionId ?? null, transactionId ?? null, ...cutover ? [cutover.accountId, cutover.consumerAppId, cutover.fromId, cutover.throughId] : []).all();
1218
1226
  let created = 0;
1219
1227
  for (const row of candidates.results) {
1228
+ const { pairing_code, consumer_app_id, event_version, created_at: _createdAt, ...transaction } = row;
1220
1229
  const deliveryId = ulid();
1221
1230
  const envelope = buildWebhookEnvelope({
1222
1231
  delivery_id: deliveryId,
1223
1232
  pairing_code: row.pairing_code,
1224
- transaction: row
1233
+ transaction,
1234
+ event_version: row.event_version ?? "1"
1225
1235
  });
1226
1236
  const payload = JSON.stringify(envelope);
1227
1237
  const result = await db.prepare(`
@@ -2659,6 +2669,75 @@ async function runBackupTick(db, cfg) {
2659
2669
  };
2660
2670
  }
2661
2671
 
2672
+ // src/bankRecovery.ts
2673
+ async function recoverBankAccount(db, account, env, proxy) {
2674
+ const enabled = await db.prepare("SELECT ingest_enabled FROM bank_accounts WHERE id=?").bind(account.id).first();
2675
+ if (enabled?.ingest_enabled === 0) throw new Error("bank_connection_paused");
2676
+ const credential = await decryptSecret(account.api_token_cipher, account.api_token_key_ver, env);
2677
+ const keys = [await sha256Hex2(`fio-account:${account.account_number.replace(/\s/g, "").toUpperCase()}`), await sha256Hex2(`fio-token:${credential}`)].sort();
2678
+ const fence = crypto.randomUUID();
2679
+ const held = [];
2680
+ try {
2681
+ for (const key of keys) {
2682
+ const claim = await db.prepare(`INSERT INTO bank_poll_leases (credential_hash,token,lease_until,next_allowed_at)
2683
+ VALUES (?,?,datetime('now','+120 seconds'),datetime('now','+30 seconds'))
2684
+ ON CONFLICT(credential_hash) DO UPDATE SET token=excluded.token, lease_until=excluded.lease_until,
2685
+ next_allowed_at=excluded.next_allowed_at
2686
+ WHERE datetime(bank_poll_leases.lease_until)<=datetime('now') AND datetime(bank_poll_leases.next_allowed_at)<=datetime('now')`).bind(key, fence).run();
2687
+ if (claim.meta.changes !== 1) throw new Error("bank_poll_busy");
2688
+ held.push(key);
2689
+ }
2690
+ async function renew() {
2691
+ for (const key of held) {
2692
+ const result = await db.prepare(`UPDATE bank_poll_leases SET lease_until=datetime('now','+120 seconds')
2693
+ WHERE credential_hash=? AND token=? AND datetime(lease_until)>datetime('now')`).bind(key, fence).run();
2694
+ if (result.meta.changes !== 1) throw new Error("bank_poll_lease_lost");
2695
+ }
2696
+ }
2697
+ const open = await db.prepare(`SELECT id,from_date,to_date,cipher,key_version FROM bank_recovery_batches
2698
+ WHERE bank_account_id=? AND state<>'completed' ORDER BY created_at,id LIMIT 1`).bind(account.id).first();
2699
+ const checkpoint = await db.prepare("SELECT api_reconciled_through FROM bank_accounts WHERE id=?").bind(account.id).first();
2700
+ const end = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2701
+ const start = new Date(checkpoint?.api_reconciled_through ?? Date.parse(end) - 86 * 864e5);
2702
+ start.setUTCDate(start.getUTCDate() - 3);
2703
+ if (!open && Date.parse(end) - start.getTime() > 89 * 864e5) throw new Error("recovery_window_requires_bank_unlock");
2704
+ const batch = open ?? { id: crypto.randomUUID(), from_date: start.toISOString().slice(0, 10), to_date: end, cipher: null, key_version: null };
2705
+ if (!open) await db.prepare(`INSERT INTO bank_recovery_batches(id,bank_account_id,from_date,to_date,state) VALUES(?,?,?,?,'fetching')`).bind(batch.id, account.id, batch.from_date, batch.to_date).run();
2706
+ await db.prepare(`UPDATE bank_accounts SET api_last_fetch_at=datetime('now') WHERE id=?`).bind(account.id).run();
2707
+ let statement;
2708
+ if (batch.cipher !== null && batch.key_version !== null) statement = JSON.parse(await decryptSecret(batch.cipher, batch.key_version, env));
2709
+ else {
2710
+ statement = await fetchFioStatement(credential, batch.from_date, batch.to_date, proxy);
2711
+ await renew();
2712
+ const encrypted = await encryptSecret(JSON.stringify(statement), env);
2713
+ await db.prepare(`UPDATE bank_recovery_batches SET cipher=?,key_version=?,state='spooled' WHERE id=?`).bind(encrypted.cipher, encrypted.keyVersion, batch.id).run();
2714
+ }
2715
+ const identity = String(statement.info?.iban ?? "").replace(/\s/g, "").toUpperCase();
2716
+ const domestic = `${statement.info?.accountId ?? ""}/${statement.info?.bankId ?? ""}`;
2717
+ const expected = account.account_number.replace(/\s/g, "").toUpperCase();
2718
+ if (expected !== identity && expected !== domestic) throw new Error("fio_receiving_account_mismatch");
2719
+ let inserted = 0, skipped = 0;
2720
+ for (const raw of statement.transactions) {
2721
+ await renew();
2722
+ const mapped = mapFioTransaction(raw);
2723
+ if (!mapped || mapped.identity_kind !== "movement") throw new Error("fio_unverified_movement");
2724
+ if (mapped.currency !== String(statement.info?.currency ?? "").toUpperCase()) throw new Error("fio_statement_currency_mismatch");
2725
+ const result = await insertTransaction(db, { bank_account_id: account.id, payload: mapped });
2726
+ if (result.status === "inserted") inserted++;
2727
+ else skipped++;
2728
+ }
2729
+ await renew();
2730
+ await db.batch([
2731
+ db.prepare(`UPDATE bank_recovery_batches SET state='completed',completed_at=datetime('now') WHERE id=?`).bind(batch.id),
2732
+ db.prepare(`UPDATE bank_accounts SET api_last_success_at=datetime('now'),api_last_error=NULL,api_backfill_done=1,
2733
+ api_reconciled_through=?,api_pointer_initialized=1 WHERE id=?`).bind(batch.to_date, account.id)
2734
+ ]);
2735
+ return { inserted, skipped };
2736
+ } finally {
2737
+ for (const key of held) await db.prepare(`UPDATE bank_poll_leases SET lease_until=datetime('now'),next_allowed_at=datetime('now','+30 seconds') WHERE credential_hash=? AND token=?`).bind(key, fence).run();
2738
+ }
2739
+ }
2740
+
2662
2741
  // src/cloudflare.ts
2663
2742
  function fioProxyConfig(env) {
2664
2743
  const url2 = env.FIO_PROXY_URL?.trim();
@@ -2674,10 +2753,36 @@ function cfRoutingConfig(env) {
2674
2753
  }
2675
2754
  async function processEmail(rawStream, env, envelope) {
2676
2755
  let bodyText;
2756
+ let spoolId;
2757
+ const reader = rawStream.getReader();
2758
+ const parts = [];
2759
+ let byteCount = 0;
2760
+ while (true) {
2761
+ const item = await reader.read();
2762
+ if (item.done) break;
2763
+ byteCount += item.value.byteLength;
2764
+ if (byteCount > 512 * 1024) {
2765
+ await reader.cancel();
2766
+ throw new Error("email_body_limit");
2767
+ }
2768
+ parts.push(item.value);
2769
+ }
2770
+ const rawArray = new Uint8Array(byteCount);
2771
+ let offset = 0;
2772
+ for (const part of parts) {
2773
+ rawArray.set(part, offset);
2774
+ offset += part.length;
2775
+ }
2776
+ const rawBytes = rawArray.buffer;
2777
+ let binary = "";
2778
+ for (let i = 0; i < rawArray.length; i += 8192) binary += String.fromCharCode(...rawArray.subarray(i, i + 8192));
2779
+ const bytesBase64 = btoa(binary);
2780
+ const rawMime = new TextDecoder().decode(rawBytes);
2781
+ let r2SpoolKey;
2677
2782
  try {
2678
2783
  let extracted;
2679
2784
  try {
2680
- extracted = await extractEmailBody(rawStream);
2785
+ extracted = await extractEmailBody(new Blob([rawBytes]).stream());
2681
2786
  bodyText = extracted.text;
2682
2787
  } catch (err) {
2683
2788
  const rawText = await new Response(rawStream).text().catch(() => "(failed to read raw)");
@@ -2697,31 +2802,59 @@ async function processEmail(rawStream, env, envelope) {
2697
2802
  log("email_rejected_authentication", { reason });
2698
2803
  return;
2699
2804
  }
2805
+ if (env.AUTHENTICATED_EMAIL_SPOOL === "on") {
2806
+ if (!env.BACKUPS) throw new Error("authenticated_spool_binding_required");
2807
+ const encrypted = await encryptSecret(JSON.stringify({ bytesBase64, envelope }), env);
2808
+ r2SpoolKey = `authenticated-email-pending/${await sha256Hex2(`${identity.recipient}:${bytesBase64}`)}`;
2809
+ const existing = await env.BACKUPS.head(r2SpoolKey);
2810
+ if (!existing) await env.BACKUPS.put(r2SpoolKey, JSON.stringify(encrypted));
2811
+ }
2700
2812
  const allowlist = parseAllowlist(env.SENDER_ALLOWLIST);
2701
2813
  const pairingCode = extractPairingCode(identity.recipient);
2702
2814
  const account = pairingCode ? await findBankAccountByPairingCode(env.DB, pairingCode) : null;
2815
+ const schema = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
2816
+ if (account && schema?.value === "11") {
2817
+ spoolId = await sha256Hex2(`${account.id}:${extracted.messageId ?? await sha256Hex2(rawMime)}`);
2818
+ const digest = await sha256Hex2(rawMime);
2819
+ const encrypted = await encryptSecret(JSON.stringify({ bytesBase64, envelope }), env);
2820
+ await env.DB.prepare(`INSERT INTO authenticated_email_spool(id,bank_account_id,body_sha256,cipher,key_version)
2821
+ VALUES(?,?,?,?,?) ON CONFLICT(id) DO NOTHING`).bind(spoolId, account.id, digest, encrypted.cipher, encrypted.keyVersion).run();
2822
+ const stored = await env.DB.prepare("SELECT body_sha256 FROM authenticated_email_spool WHERE id=?").bind(spoolId).first();
2823
+ if (stored?.body_sha256 !== digest) throw new Error("email_transport_body_conflict");
2824
+ }
2825
+ async function completeSpool() {
2826
+ if (spoolId) await env.DB.prepare("UPDATE authenticated_email_spool SET state='completed',completed_at=datetime('now') WHERE id=?").bind(spoolId).run();
2827
+ if (r2SpoolKey) await env.BACKUPS.delete(r2SpoolKey);
2828
+ }
2829
+ if (account && schema?.value === "11") {
2830
+ const enabled = await env.DB.prepare("SELECT ingest_enabled FROM bank_accounts WHERE id=?").bind(account.id).first();
2831
+ if (enabled?.ingest_enabled === 0) throw new Error("email_connection_paused");
2832
+ }
2703
2833
  const outcome = classifyEmail(extracted, identity, account, allowlist);
2704
2834
  if (outcome.received) {
2705
2835
  log("email_received", { pairing_code: pairingCode, message_id: extracted.messageId ?? null });
2706
2836
  await writeEvent(env.DB, { event_type: "email_received", bank_account_id: account.id, detail: { message_id: extracted.messageId ?? null } });
2707
2837
  }
2708
2838
  if (outcome.kind === "reject" || outcome.kind === "skip") {
2839
+ if (r2SpoolKey && (outcome.reason.startsWith("parse_failed") || outcome.reason.startsWith("unknown_") || outcome.reason.startsWith("no_pairing"))) throw new Error("authenticated_email_quarantined");
2709
2840
  await insertParseLog(env.DB, {
2710
2841
  bank_account_id: outcome.bankAccountId ?? null,
2711
2842
  error_message: outcome.reason,
2712
2843
  raw_data: sanitizeDiagnosticRaw(extracted.text),
2713
2844
  external_id: extracted.messageId ?? null
2714
2845
  });
2846
+ await completeSpool();
2715
2847
  return;
2716
2848
  }
2717
2849
  const acct = outcome.account;
2850
+ if (!outcome.parsed.date) throw new Error("email_missing_bank_date");
2718
2851
  let result;
2719
2852
  try {
2720
2853
  result = await insertTransaction(env.DB, {
2721
2854
  bank_account_id: acct.id,
2722
2855
  payload: {
2723
2856
  ...outcome.parsed,
2724
- date: outcome.parsed.date ?? (/* @__PURE__ */ new Date()).toISOString(),
2857
+ date: outcome.parsed.date,
2725
2858
  external_id: extracted.messageId ?? null
2726
2859
  }
2727
2860
  });
@@ -2732,17 +2865,19 @@ async function processEmail(rawStream, env, envelope) {
2732
2865
  raw_data: sanitizeDiagnosticRaw(JSON.stringify({ ...outcome.parsed, external_id: extracted.messageId ?? null })),
2733
2866
  external_id: extracted.messageId ?? null
2734
2867
  });
2735
- return;
2868
+ throw new Error("email_db_insert_failed");
2736
2869
  }
2737
2870
  if (result.status === "skipped") {
2738
2871
  log("tx_skipped", { reason: result.reason, bank_account_id: acct.id });
2739
2872
  await writeEvent(env.DB, { event_type: "tx_skipped", bank_account_id: acct.id, detail: { reason: result.reason } });
2873
+ await completeSpool();
2740
2874
  return;
2741
2875
  }
2742
2876
  const tx = result.transaction;
2743
2877
  log("tx_inserted", { bank_account_id: acct.id, tx_id: tx.id, vs: tx.vs, amount_cents: tx.amount_cents, currency: tx.currency });
2744
2878
  await writeEvent(env.DB, { event_type: "tx_inserted", bank_account_id: acct.id, detail: { tx_id: tx.id } });
2745
2879
  await createWebhookDeliveryCoordinator(env).observeTransaction(tx.id);
2880
+ await completeSpool();
2746
2881
  } catch (err) {
2747
2882
  try {
2748
2883
  await insertParseLog(env.DB, {
@@ -2752,6 +2887,7 @@ async function processEmail(rawStream, env, envelope) {
2752
2887
  } catch {
2753
2888
  }
2754
2889
  logError("email_unhandled_exception", err, {});
2890
+ throw new Error("email_ingest_incomplete");
2755
2891
  }
2756
2892
  }
2757
2893
  function sanitizeDiagnosticRaw(value) {
@@ -2823,6 +2959,12 @@ async function runBankApiSync(env, account) {
2823
2959
  }
2824
2960
  const isBackfill = !account.api_backfill_done;
2825
2961
  try {
2962
+ const schema = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
2963
+ if (schema?.value === "11") {
2964
+ const result = await recoverBankAccount(env.DB, account, env, fioProxyConfig(env));
2965
+ const delivery = await createWebhookDeliveryCoordinator(env).sweep();
2966
+ return { bank_account_id: account.id, provider: account.account_type, inserted: result.inserted, skipped_duplicate: result.skipped, skipped_outgoing: 0, parse_errors: 0, queued_webhooks: delivery.queued, backfill: isBackfill, deferred: false };
2967
+ }
2826
2968
  const token = await decryptSecret(account.api_token_cipher, account.api_token_key_ver, env);
2827
2969
  if (isBackfill && account.api_last_success_at === null) {
2828
2970
  await markBankAccountApiFetchStarted(env.DB, account.id);
@@ -2865,7 +3007,7 @@ async function runBankApiSync(env, account) {
2865
3007
  });
2866
3008
  continue;
2867
3009
  }
2868
- if (mapped === null) {
3010
+ if (mapped === null || mapped.amount_cents <= 0) {
2869
3011
  skippedOutgoing++;
2870
3012
  await insertParseLog(env.DB, {
2871
3013
  bank_account_id: account.id,
@@ -2923,14 +3065,7 @@ async function runBankApiSync(env, account) {
2923
3065
  }
2924
3066
  async function runDueBankApiSyncs(env) {
2925
3067
  const accounts = await listDueApiFetchAccounts(env.DB, apiMinIntervalS(env));
2926
- const processedTokenPrefixes = /* @__PURE__ */ new Set();
2927
3068
  for (const account of accounts) {
2928
- const tokenKey = account.api_token_prefix ?? `account:${account.id}`;
2929
- if (processedTokenPrefixes.has(tokenKey)) {
2930
- log("api_sync_skipped_duplicate_token_prefix", { bank_account_id: account.id, account_type: account.account_type });
2931
- continue;
2932
- }
2933
- processedTokenPrefixes.add(tokenKey);
2934
3069
  try {
2935
3070
  const result = await runBankApiSync(env, account);
2936
3071
  log("api_sync_completed", {
@@ -3219,6 +3354,11 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3219
3354
  if (input.fio_api_token !== void 0 && (input.account_type ?? "FIO") !== "FIO") {
3220
3355
  return jsonResponse({ error: "fio_api_token_supported_only_for_fio" }, 400);
3221
3356
  }
3357
+ const expanded = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
3358
+ if (input.ingest_enabled === false && expanded?.value !== "11") return jsonResponse({ error: "paused_ingest_requires_schema11" }, 409);
3359
+ const capability = expanded?.value === "11" ? await env.DB.prepare("SELECT event_version FROM webhook_consumers WHERE app_id=?").bind(input.owner_app_id).first() : null;
3360
+ if (capability?.event_version === "2" && (input.ingest_mode === "email" || input.ingest_mode === "both") && (env.AUTHENTICATED_EMAIL_SPOOL !== "on" || !env.BACKUPS)) return jsonResponse({ error: "durable_email_capability_not_configured" }, 503);
3361
+ if (capability?.event_version === "2" && input.ingest_mode === "both") return jsonResponse({ error: "both_requires_verified_correlation" }, 409);
3222
3362
  const pairingCode = await generateUniquePairingCode(env.DB);
3223
3363
  const ingestMode = input.ingest_mode ?? (input.fio_api_token ? "api" : "email");
3224
3364
  const createEmailRoute = ingestMode === "email" || ingestMode === "both";
@@ -3242,6 +3382,11 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3242
3382
  } catch (err) {
3243
3383
  throw err;
3244
3384
  }
3385
+ const schemaVersion = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
3386
+ if (input.ingest_enabled === false) {
3387
+ if (schemaVersion?.value !== "11") throw new Error("paused_ingest_requires_schema11");
3388
+ await env.DB.prepare("UPDATE bank_accounts SET ingest_enabled=0,api_fetch_enabled=0 WHERE id=?").bind(account.id).run();
3389
+ }
3245
3390
  if (outboxId !== null) {
3246
3391
  const cfRuleId = await provisionRoute(env.DB, cfRoutingConfig(env), {
3247
3392
  outboxId,
@@ -3269,6 +3414,7 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3269
3414
  if (existing instanceof Response) return existing;
3270
3415
  const input = parseOr400(UpdateBankAccountSchema, bodyText);
3271
3416
  if (input instanceof Response) return input;
3417
+ if (input.account_number !== void 0 && input.account_number !== existing.account_number && await env.DB.prepare("SELECT 1 FROM transactions WHERE bank_account_id=? LIMIT 1").bind(id).first()) return jsonResponse({ error: "account_identity_immutable" }, 409);
3272
3418
  const updated = await updateBankAccount(env.DB, id, input);
3273
3419
  if (!updated) return jsonResponse({ error: "not_found" }, 404);
3274
3420
  return jsonResponse(updated);
@@ -3298,6 +3444,25 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3298
3444
  if (!deleted) return jsonResponse({ error: "not_found" }, 404);
3299
3445
  return new Response(null, { status: 204 });
3300
3446
  }
3447
+ const stateMatch = url2.pathname.match(/^\/bank-accounts\/(\d+)\/ingest-state$/);
3448
+ if (stateMatch && (req.method === "GET" || req.method === "PUT")) {
3449
+ const id = Number(stateMatch[1]);
3450
+ const existing = await requireOwnedAccount(env, id, authCtx);
3451
+ if (existing instanceof Response) return existing;
3452
+ const schema = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
3453
+ if (schema?.value !== "11") return jsonResponse({ error: "schema11_required" }, 409);
3454
+ if (req.method === "PUT") {
3455
+ let input;
3456
+ try {
3457
+ input = JSON.parse(bodyText);
3458
+ } catch {
3459
+ return jsonResponse({ error: "invalid_json" }, 400);
3460
+ }
3461
+ if (typeof input.enabled !== "boolean") return jsonResponse({ error: "enabled_required" }, 400);
3462
+ await env.DB.prepare("UPDATE bank_accounts SET ingest_enabled=? WHERE id=?").bind(input.enabled ? 1 : 0, id).run();
3463
+ }
3464
+ return jsonResponse(await env.DB.prepare("SELECT ingest_enabled,api_token_hash FROM bank_accounts WHERE id=?").bind(id).first());
3465
+ }
3301
3466
  const fioTokenMatch = url2.pathname.match(/^\/bank-accounts\/(\d+)\/fio-token$/);
3302
3467
  if (fioTokenMatch && req.method === "PUT") {
3303
3468
  const id = Number(fioTokenMatch[1]);
@@ -3320,6 +3485,8 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3320
3485
  ingest_mode: input.ingest_mode ?? (existing.ingest_mode === "email" ? "api" : existing.ingest_mode)
3321
3486
  });
3322
3487
  if (!updated2) return jsonResponse({ error: "not_found" }, 404);
3488
+ const schemaVersion = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
3489
+ if (schemaVersion?.value === "11") await env.DB.prepare("UPDATE bank_accounts SET api_token_hash=? WHERE id=?").bind(await sha256Hex2(rawToken), id).run();
3323
3490
  log("bank_account_api_token_updated", { bank_account_id: id, account_type: existing.account_type, api_token_prefix: updated2.api_token_prefix });
3324
3491
  return jsonResponse(updated2);
3325
3492
  }
@@ -3404,6 +3571,8 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3404
3571
  if (authCtx.type === "tenant") return forbidden();
3405
3572
  const input = parseOr400(CreateConsumerSchema, bodyText);
3406
3573
  if (input instanceof Response) return input;
3574
+ const schemaVersion = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
3575
+ if (input.event_version === "2" && schemaVersion?.value !== "11") return jsonResponse({ error: "v2_requires_schema11" }, 409);
3407
3576
  let callbackUrl;
3408
3577
  try {
3409
3578
  callbackUrl = validateCallbackUrl(input.callback_url, { environment: env.ENV, allowlist: env.CALLBACK_HOST_ALLOWLIST });
@@ -3417,6 +3586,7 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3417
3586
  const secretCipher = await webhookEncrypt(plain, env.WEBHOOK_KEK);
3418
3587
  const consumer = await createConsumer(env.DB, {
3419
3588
  app_id: input.app_id,
3589
+ event_version: input.event_version ?? "1",
3420
3590
  callback_url: callbackUrl,
3421
3591
  secret_cipher: secretCipher,
3422
3592
  secret_hash: secretHash,
@@ -3566,6 +3736,34 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3566
3736
  const next_cursor = entries.length === limit ? entries[entries.length - 1].id : null;
3567
3737
  return jsonResponse({ jobs: entries, next_cursor });
3568
3738
  }
3739
+ const exportMatch = url2.pathname.match(/^\/bank-accounts\/(\d+)\/transaction-export$/);
3740
+ if (exportMatch && req.method === "GET") {
3741
+ const id = Number(exportMatch[1]);
3742
+ const account = await requireOwnedAccount(env, id, authCtx);
3743
+ if (account instanceof Response) return account;
3744
+ const maximum = await env.DB.prepare("SELECT COALESCE(MAX(id),0) AS id FROM transactions WHERE bank_account_id=?").bind(id).first();
3745
+ const high = Number(url2.searchParams.get("high_water") ?? maximum?.id ?? 0);
3746
+ const cursor = Number(url2.searchParams.get("cursor") ?? 0);
3747
+ const limit = Number(url2.searchParams.get("limit") ?? 200);
3748
+ if (!Number.isSafeInteger(high) || high < 0 || !Number.isSafeInteger(cursor) || cursor < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 200) return jsonResponse({ error: "invalid_cursor" }, 400);
3749
+ const rows = await env.DB.prepare("SELECT * FROM transactions WHERE bank_account_id=? AND id>? AND id<=? ORDER BY id LIMIT ?").bind(id, cursor, high, limit).all();
3750
+ return jsonResponse({ bank_account_id: id, high_water: high, cursor: rows.results.at(-1)?.id ?? cursor, complete: rows.results.length < limit, transactions: rows.results });
3751
+ }
3752
+ if (url2.pathname === "/admin/cutover-reconcile" && req.method === "POST") {
3753
+ if (authCtx.type !== "admin") return forbidden();
3754
+ let input;
3755
+ try {
3756
+ input = JSON.parse(bodyText);
3757
+ } catch {
3758
+ return jsonResponse({ error: "invalid_json" }, 400);
3759
+ }
3760
+ if (typeof input.manifest_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(input.manifest_sha256) || typeof input.consumer_app_id !== "string" || ![input.account_id, input.from_id, input.through_id].every((n) => Number.isSafeInteger(n) && n > 0) || input.through_id < input.from_id) return jsonResponse({ error: "invalid_manifest_window" }, 400);
3761
+ const account = await findBankAccountById(env.DB, input.account_id);
3762
+ if (account?.owner_app_id !== input.consumer_app_id) return forbidden();
3763
+ const created = await ensureDeliveryJobs(env.DB, void 0, { accountId: input.account_id, consumerAppId: input.consumer_app_id, fromId: input.from_id, throughId: input.through_id });
3764
+ await writeEvent(env.DB, { event_type: "cutover_reconcile", bank_account_id: input.account_id, detail: { consumer: input.consumer_app_id, manifest_sha256: input.manifest_sha256, from_id: input.from_id, through_id: input.through_id, created } });
3765
+ return jsonResponse({ created });
3766
+ }
3569
3767
  if (url2.pathname === "/transactions" && req.method === "GET") {
3570
3768
  const since = url2.searchParams.get("since") ?? (/* @__PURE__ */ new Date(0)).toISOString();
3571
3769
  const limitStr = url2.searchParams.get("limit");
@@ -3708,6 +3906,37 @@ var cloudflare_default = {
3708
3906
  }
3709
3907
  }
3710
3908
  if (runReconciliation) {
3909
+ if (env.AUTHENTICATED_EMAIL_SPOOL === "on" && env.BACKUPS) {
3910
+ const cursor = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='email_spool_cursor'").first();
3911
+ const page = await env.BACKUPS.list({ prefix: "authenticated-email-pending/", limit: 10, ...cursor?.value ? { cursor: cursor.value } : {} });
3912
+ for (const object2 of page.objects) {
3913
+ try {
3914
+ const stored = await env.BACKUPS.get(object2.key);
3915
+ if (!stored) continue;
3916
+ const encrypted = JSON.parse(await stored.text());
3917
+ const message = JSON.parse(await decryptSecret(encrypted.cipher, encrypted.keyVersion, env));
3918
+ const bytes = Uint8Array.from(atob(message.bytesBase64), (c) => c.charCodeAt(0));
3919
+ await processEmail(new Blob([bytes]).stream(), env, message.envelope);
3920
+ } catch {
3921
+ log("authenticated_email_recovery_pending", { spool_key: object2.key });
3922
+ }
3923
+ }
3924
+ await env.DB.prepare("INSERT INTO schema_meta(key,value) VALUES('email_spool_cursor',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(page.truncated ? page.cursor : "").run();
3925
+ }
3926
+ const schema = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
3927
+ if (schema?.value === "11") {
3928
+ const pending = await env.DB.prepare("SELECT id,cipher,key_version FROM authenticated_email_spool WHERE state='pending' ORDER BY attempts,created_at LIMIT 10").all();
3929
+ for (const row of pending.results) {
3930
+ try {
3931
+ await env.DB.prepare("UPDATE authenticated_email_spool SET attempts=attempts+1 WHERE id=?").bind(row.id).run();
3932
+ const message = JSON.parse(await decryptSecret(row.cipher, row.key_version, env));
3933
+ const restored = Uint8Array.from(atob(message.bytesBase64), (c) => c.charCodeAt(0));
3934
+ await processEmail(new Blob([restored]).stream(), env, message.envelope);
3935
+ } catch {
3936
+ log("email_recovery_pending", { spool_id: row.id });
3937
+ }
3938
+ }
3939
+ }
3711
3940
  try {
3712
3941
  const swept = await createWebhookDeliveryCoordinator(env).sweep();
3713
3942
  if (swept.created > 0 || swept.considered > 0) {