@festapp/banksync 0.1.6 → 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.
@@ -1,4 +1,4 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } async function _asyncNullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return await rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
2
2
 
3
3
 
4
4
 
@@ -9,7 +9,8 @@
9
9
 
10
10
 
11
11
 
12
- var _chunkPBN2BUNRcjs = require('./chunk-PBN2BUNR.cjs');
12
+
13
+ var _chunkN7BVOSFNcjs = require('./chunk-N7BVOSFN.cjs');
13
14
 
14
15
  // src/mime.ts
15
16
  var _postalmime = require('postal-mime'); var _postalmime2 = _interopRequireDefault(_postalmime);
@@ -66,13 +67,13 @@ function classifyEmail(extracted, identity, account, allowlist) {
66
67
  if (account.ingest_mode === "api") {
67
68
  return { kind: "reject", reason: "email_ingest_disabled", bankAccountId: account.id, received: false };
68
69
  }
69
- const provider = _chunkPBN2BUNRcjs.detectProvider.call(void 0, extracted.text, account.account_number);
70
+ const provider = _chunkN7BVOSFNcjs.detectProvider.call(void 0, extracted.text, account.account_number);
70
71
  if (provider === "unknown") {
71
72
  return { kind: "reject", reason: `unknown_provider: ${account.account_number}`, bankAccountId: account.id, received: true };
72
73
  }
73
74
  let parsed;
74
75
  try {
75
- parsed = _chunkPBN2BUNRcjs.parseEmail.call(void 0, extracted.text, provider);
76
+ parsed = _chunkN7BVOSFNcjs.parseEmail.call(void 0, extracted.text, provider);
76
77
  } catch (err) {
77
78
  return { kind: "reject", reason: `${err}`.replace(/^Error:\s*/, ""), bankAccountId: account.id, received: true };
78
79
  }
@@ -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 = _optionalChain([schema, 'optionalAccess', _16 => _16.value]) === "11";
627
611
  const p = {
628
612
  ...args.payload,
629
- vs: _chunkPBN2BUNRcjs.resolveVariableSymbol.call(void 0, args.payload)
613
+ vs: v2 ? _nullishCoalesce(_nullishCoalesce(args.payload.raw_vs, () => ( args.payload.vs)), () => ( null)) : _chunkN7BVOSFNcjs.resolveVariableSymbol.call(void 0, 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 !== (_nullishCoalesce(_nullishCoalesce(p.raw_vs, () => ( args.payload.vs)), () => ( null))) || facts.payer_reference !== (_nullishCoalesce(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
  _nullishCoalesce(p.date_offset_min, () => ( null)),
662
649
  _nullishCoalesce(p.transaction_id, () => ( null)),
663
650
  _nullishCoalesce(p.external_id, () => ( null))
664
- ).run();
651
+ ];
652
+ if (v2) values.push(
653
+ _nullishCoalesce(p.payer_reference, () => ( null)),
654
+ _nullishCoalesce(_nullishCoalesce(args.payload.raw_vs, () => ( args.payload.vs)), () => ( null)),
655
+ p.amount_cents > 0 ? "incoming" : p.amount_cents < 0 ? "outgoing" : "zero",
656
+ _nullishCoalesce(p.identity_kind, () => ( (p.source === "fio_api" && p.transaction_id ? "movement" : "observation"))),
657
+ _nullishCoalesce(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 !== (_nullishCoalesce(_nullishCoalesce(p.raw_vs, () => ( args.payload.vs)), () => ( null))) || facts.payer_reference !== (_nullishCoalesce(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
  }
@@ -725,11 +721,11 @@ var SQL_LIST_SUBSCRIPTIONS_ALL = `SELECT id, bank_account_id, consumer_app_id, c
725
721
  var SQL_LIST_SUBSCRIPTIONS_BY_APP = `SELECT id, bank_account_id, consumer_app_id, created_at FROM webhook_subscriptions WHERE consumer_app_id = ? ORDER BY id`;
726
722
  var SQL_LIST_SUBSCRIPTIONS_BY_ACCOUNT = `SELECT id, bank_account_id, consumer_app_id, created_at FROM webhook_subscriptions WHERE bank_account_id = ? ORDER BY id`;
727
723
  async function listSubscriptions(db, filters) {
728
- if (_optionalChain([filters, 'optionalAccess', _16 => _16.app_id])) {
724
+ if (_optionalChain([filters, 'optionalAccess', _17 => _17.app_id])) {
729
725
  const r2 = await db.prepare(SQL_LIST_SUBSCRIPTIONS_BY_APP).bind(filters.app_id).all();
730
726
  return r2.results;
731
727
  }
732
- if (_optionalChain([filters, 'optionalAccess', _17 => _17.bank_account_id]) != null) {
728
+ if (_optionalChain([filters, 'optionalAccess', _18 => _18.bank_account_id]) != null) {
733
729
  const r2 = await db.prepare(SQL_LIST_SUBSCRIPTIONS_BY_ACCOUNT).bind(filters.bank_account_id).all();
734
730
  return r2.results;
735
731
  }
@@ -830,8 +826,8 @@ async function getStatusData(db) {
830
826
  db.prepare(SQL_PER_CONSUMER_ERRORS_24H).all(),
831
827
  db.prepare(SQL_PER_CONSUMER_DELIVERED).all()
832
828
  ]);
833
- const failuresCount = _nullishCoalesce(_optionalChain([failures, 'optionalAccess', _18 => _18.cnt]), () => ( 0));
834
- const insertedCount = _nullishCoalesce(_optionalChain([txInserted24h, 'optionalAccess', _19 => _19.cnt]), () => ( 0));
829
+ const failuresCount = _nullishCoalesce(_optionalChain([failures, 'optionalAccess', _19 => _19.cnt]), () => ( 0));
830
+ const insertedCount = _nullishCoalesce(_optionalChain([txInserted24h, 'optionalAccess', _20 => _20.cnt]), () => ( 0));
835
831
  const total24h = failuresCount + insertedCount;
836
832
  const tx7dMap = new Map(txPer7d.results.map((r) => [r.bank_account_id, r.cnt]));
837
833
  const parseErrMap = new Map(parseErrPerAcct.results.map((r) => [r.bank_account_id, r.cnt]));
@@ -844,11 +840,11 @@ async function getStatusData(db) {
844
840
  service: {
845
841
  parse_failures_24h: failuresCount,
846
842
  parse_failure_rate_24h: total24h > 0 ? Math.round(failuresCount / total24h * 1e4) / 1e4 : 0,
847
- unknown_currency_24h: _nullishCoalesce(_optionalChain([unknownCurr, 'optionalAccess', _20 => _20.cnt]), () => ( 0)),
848
- outgoing_filtered_24h: _nullishCoalesce(_optionalChain([outgoing, 'optionalAccess', _21 => _21.cnt]), () => ( 0)),
849
- unknown_provider_24h: _nullishCoalesce(_optionalChain([unknownProv, 'optionalAccess', _22 => _22.cnt]), () => ( 0)),
850
- unmatched_24h: _nullishCoalesce(_optionalChain([unmatched, 'optionalAccess', _23 => _23.cnt]), () => ( 0)),
851
- not_transaction_24h: _nullishCoalesce(_optionalChain([notTransaction, 'optionalAccess', _24 => _24.cnt]), () => ( 0))
843
+ unknown_currency_24h: _nullishCoalesce(_optionalChain([unknownCurr, 'optionalAccess', _21 => _21.cnt]), () => ( 0)),
844
+ outgoing_filtered_24h: _nullishCoalesce(_optionalChain([outgoing, 'optionalAccess', _22 => _22.cnt]), () => ( 0)),
845
+ unknown_provider_24h: _nullishCoalesce(_optionalChain([unknownProv, 'optionalAccess', _23 => _23.cnt]), () => ( 0)),
846
+ unmatched_24h: _nullishCoalesce(_optionalChain([unmatched, 'optionalAccess', _24 => _24.cnt]), () => ( 0)),
847
+ not_transaction_24h: _nullishCoalesce(_optionalChain([notTransaction, 'optionalAccess', _25 => _25.cnt]), () => ( 0))
852
848
  },
853
849
  bank_accounts: accountRows.results.map((a) => ({
854
850
  id: a.id,
@@ -868,20 +864,26 @@ async function getStatusData(db) {
868
864
  queues: {
869
865
  main_pending: 0,
870
866
  // Cloudflare Analytics binding not available in basic setup
871
- delivery_active: _nullishCoalesce(_optionalChain([deliveryActive, 'optionalAccess', _25 => _25.cnt]), () => ( 0)),
872
- delivery_stalled: _nullishCoalesce(_optionalChain([deliveryStalled, 'optionalAccess', _26 => _26.cnt]), () => ( 0)),
873
- delivery_terminal: _nullishCoalesce(_optionalChain([deliveryTerminal, 'optionalAccess', _27 => _27.cnt]), () => ( 0)),
874
- oldest_undelivered_at: _nullishCoalesce(_optionalChain([oldestUndelivered, 'optionalAccess', _28 => _28.ts]), () => ( null)),
875
- pending_delivery_alerts: _nullishCoalesce(_optionalChain([pendingAlerts, 'optionalAccess', _29 => _29.cnt]), () => ( 0)),
876
- api_rate_limited_24h: _nullishCoalesce(_optionalChain([apiRateLimited, 'optionalAccess', _30 => _30.cnt]), () => ( 0))
867
+ delivery_active: _nullishCoalesce(_optionalChain([deliveryActive, 'optionalAccess', _26 => _26.cnt]), () => ( 0)),
868
+ delivery_stalled: _nullishCoalesce(_optionalChain([deliveryStalled, 'optionalAccess', _27 => _27.cnt]), () => ( 0)),
869
+ delivery_terminal: _nullishCoalesce(_optionalChain([deliveryTerminal, 'optionalAccess', _28 => _28.cnt]), () => ( 0)),
870
+ oldest_undelivered_at: _nullishCoalesce(_optionalChain([oldestUndelivered, 'optionalAccess', _29 => _29.ts]), () => ( null)),
871
+ pending_delivery_alerts: _nullishCoalesce(_optionalChain([pendingAlerts, 'optionalAccess', _30 => _30.cnt]), () => ( 0)),
872
+ api_rate_limited_24h: _nullishCoalesce(_optionalChain([apiRateLimited, 'optionalAccess', _31 => _31.cnt]), () => ( 0))
877
873
  }
878
874
  };
879
875
  }
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) {
@@ -918,13 +920,14 @@ function parseBody(schema, body) {
918
920
  return v.parse(schema, body);
919
921
  } catch (err) {
920
922
  if (err instanceof v.ValiError) {
921
- const message = `validation_failed: ${_nullishCoalesce(_optionalChain([err, 'access', _31 => _31.issues, 'access', _32 => _32[0], 'optionalAccess', _33 => _33.message]), () => ( "unknown"))}`;
923
+ const message = `validation_failed: ${_nullishCoalesce(_optionalChain([err, 'access', _32 => _32.issues, 'access', _33 => _33[0], 'optionalAccess', _34 => _34.message]), () => ( "unknown"))}`;
922
924
  throw new ValidationError(err.issues, message);
923
925
  }
924
926
  throw err;
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 = _optionalChain([schema, 'optionalAccess', _35 => _35.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(_nullishCoalesce(transactionId, () => ( null)), _nullishCoalesce(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(_nullishCoalesce(transactionId, () => ( null)), _nullishCoalesce(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.ulid.call(void 0, );
1221
- const envelope = _chunkPBN2BUNRcjs.buildWebhookEnvelope.call(void 0, {
1230
+ const envelope = _chunkN7BVOSFNcjs.buildWebhookEnvelope.call(void 0, {
1222
1231
  delivery_id: deliveryId,
1223
1232
  pairing_code: row.pairing_code,
1224
- transaction: row
1233
+ transaction,
1234
+ event_version: _nullishCoalesce(row.event_version, () => ( "1"))
1225
1235
  });
1226
1236
  const payload = JSON.stringify(envelope);
1227
1237
  const result = await db.prepare(`
@@ -1348,8 +1358,8 @@ async function recordDeliveryOutcome(db, args) {
1348
1358
  WHERE id = ? AND generation = ? AND status <> 'delivered'
1349
1359
  `).bind(
1350
1360
  httpStatus,
1351
- _nullishCoalesce(_optionalChain([args, 'access', _34 => _34.receipt, 'optionalAccess', _35 => _35.outcome]), () => ( null)),
1352
- _nullishCoalesce(_optionalChain([args, 'access', _36 => _36.receipt, 'optionalAccess', _37 => _37.receipt_version]), () => ( null)),
1361
+ _nullishCoalesce(_optionalChain([args, 'access', _36 => _36.receipt, 'optionalAccess', _37 => _37.outcome]), () => ( null)),
1362
+ _nullishCoalesce(_optionalChain([args, 'access', _38 => _38.receipt, 'optionalAccess', _39 => _39.receipt_version]), () => ( null)),
1353
1363
  args.receipt ? JSON.stringify(args.receipt) : null,
1354
1364
  deliveryJobId,
1355
1365
  generation
@@ -1539,7 +1549,7 @@ async function httpResult(response, deliveryId, usedPrevSecret, primaryStatus) {
1539
1549
  return "receipt" in parsed ? { kind: "http", httpStatus: response.status, usedPrevSecret, primaryStatus, receipt: parsed.receipt } : { kind: "http", httpStatus: response.status, usedPrevSecret, primaryStatus, receiptError: parsed.error };
1540
1550
  }
1541
1551
  async function postSigned(url2, envelope, secret) {
1542
- const signed = await _chunkPBN2BUNRcjs.signWebhook.call(void 0, { envelope, secret });
1552
+ const signed = await _chunkN7BVOSFNcjs.signWebhook.call(void 0, { envelope, secret });
1543
1553
  const controller = new AbortController();
1544
1554
  const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1545
1555
  try {
@@ -1694,7 +1704,7 @@ function createWebhookDeliveryCoordinator(env, deps = {}) {
1694
1704
  },
1695
1705
  async sweep(options) {
1696
1706
  const created = await ensureDeliveryJobs(db);
1697
- const dispatch2 = await dispatchDueDeliveryJobs(env, _nullishCoalesce(_optionalChain([options, 'optionalAccess', _38 => _38.limit]), () => ( 50)));
1707
+ const dispatch2 = await dispatchDueDeliveryJobs(env, _nullishCoalesce(_optionalChain([options, 'optionalAccess', _40 => _40.limit]), () => ( 50)));
1698
1708
  return { created, ...dispatch2 };
1699
1709
  },
1700
1710
  handleAttempt(message, ctx) {
@@ -1785,7 +1795,7 @@ async function createRule(cfg, pairingCode) {
1785
1795
  body: JSON.stringify(body)
1786
1796
  });
1787
1797
  const json = await res.json();
1788
- if (!res.ok || !json.success || !_optionalChain([json, 'access', _39 => _39.result, 'optionalAccess', _40 => _40.id])) {
1798
+ if (!res.ok || !json.success || !_optionalChain([json, 'access', _41 => _41.result, 'optionalAccess', _42 => _42.id])) {
1789
1799
  const errs = _nullishCoalesce(json.errors, () => ( []));
1790
1800
  throw new CfRoutingError(
1791
1801
  res.status,
@@ -1893,7 +1903,7 @@ async function markCompleted(db, outboxId, cfRuleIdForCreate) {
1893
1903
  ).bind(_nullishCoalesce(cfRuleIdForCreate, () => ( null)), outboxId);
1894
1904
  if (cfRuleIdForCreate) {
1895
1905
  const row = await db.prepare(`SELECT bank_account_id FROM cf_routing_outbox WHERE id = ?`).bind(outboxId).first();
1896
- const bankAccountId = _nullishCoalesce(_optionalChain([row, 'optionalAccess', _41 => _41.bank_account_id]), () => ( null));
1906
+ const bankAccountId = _nullishCoalesce(_optionalChain([row, 'optionalAccess', _43 => _43.bank_account_id]), () => ( null));
1897
1907
  if (bankAccountId !== null) {
1898
1908
  const bankUpdate = db.prepare(
1899
1909
  `UPDATE bank_accounts SET cf_rule_id = ? WHERE id = ?`
@@ -1906,7 +1916,7 @@ async function markCompleted(db, outboxId, cfRuleIdForCreate) {
1906
1916
  }
1907
1917
  async function markFailureAttempt(db, outboxId, error) {
1908
1918
  const row = await db.prepare(`SELECT attempts FROM cf_routing_outbox WHERE id = ?`).bind(outboxId).first();
1909
- const currentAttempts = _nullishCoalesce(_optionalChain([row, 'optionalAccess', _42 => _42.attempts]), () => ( 0));
1919
+ const currentAttempts = _nullishCoalesce(_optionalChain([row, 'optionalAccess', _44 => _44.attempts]), () => ( 0));
1910
1920
  const newAttempts = currentAttempts + 1;
1911
1921
  const nextAt = nextAttemptAt(newAttempts);
1912
1922
  const newStatus = newAttempts >= MAX_ATTEMPTS ? "failed" : "pending";
@@ -2204,7 +2214,7 @@ async function checkAndIncrement(db, principal, cfg = DEFAULT_RATE_LIMIT) {
2204
2214
  ON CONFLICT(principal, window_start) DO UPDATE SET count = count + 1
2205
2215
  `).bind(principal, currentBucket).run();
2206
2216
  const sumRow = await db.prepare(`SELECT COALESCE(SUM(count), 0) as total FROM rate_limit_buckets WHERE principal = ? AND window_start >= ?`).bind(principal, windowStartBucket).first();
2207
- const currentCount = _nullishCoalesce(_optionalChain([sumRow, 'optionalAccess', _43 => _43.total]), () => ( 0));
2217
+ const currentCount = _nullishCoalesce(_optionalChain([sumRow, 'optionalAccess', _45 => _45.total]), () => ( 0));
2208
2218
  if (currentCount > cfg.limit) {
2209
2219
  log("rate_limit_denied", { principal, count: currentCount, limit: cfg.limit });
2210
2220
  return {
@@ -2257,7 +2267,7 @@ async function probeDbWrite(db) {
2257
2267
  let rowId = null;
2258
2268
  try {
2259
2269
  const inserted = await db.prepare(`INSERT INTO event_log (event_type) VALUES ('health_probe') RETURNING id`).first();
2260
- rowId = _nullishCoalesce(_optionalChain([inserted, 'optionalAccess', _44 => _44.id]), () => ( null));
2270
+ rowId = _nullishCoalesce(_optionalChain([inserted, 'optionalAccess', _46 => _46.id]), () => ( null));
2261
2271
  const ms = Date.now() - t0;
2262
2272
  const status = ms < 500 ? "green" : ms < 2e3 ? "yellow" : "red";
2263
2273
  return { status, latency_ms: ms };
@@ -2299,8 +2309,8 @@ async function probeOutboxDrift(db) {
2299
2309
  db.prepare(`SELECT COUNT(*) as cnt FROM cf_routing_outbox WHERE status = 'pending'`).first(),
2300
2310
  db.prepare(`SELECT COUNT(*) as cnt FROM cf_routing_outbox WHERE status = 'failed'`).first()
2301
2311
  ]);
2302
- const pending = _nullishCoalesce(_optionalChain([pendingRow, 'optionalAccess', _45 => _45.cnt]), () => ( 0));
2303
- const failed = _nullishCoalesce(_optionalChain([failedRow, 'optionalAccess', _46 => _46.cnt]), () => ( 0));
2312
+ const pending = _nullishCoalesce(_optionalChain([pendingRow, 'optionalAccess', _47 => _47.cnt]), () => ( 0));
2313
+ const failed = _nullishCoalesce(_optionalChain([failedRow, 'optionalAccess', _48 => _48.cnt]), () => ( 0));
2304
2314
  let status = "green";
2305
2315
  if (failed > 0) status = "red";
2306
2316
  else if (pending > 0) status = "yellow";
@@ -2354,7 +2364,7 @@ async function fetchAllCfRules(cfg) {
2354
2364
  const json = await res.json();
2355
2365
  if (!json.success || !json.result) throw new Error("CF rules list: success=false");
2356
2366
  rules.push(...json.result);
2357
- const cursor = _optionalChain([json, 'access', _47 => _47.result_info, 'optionalAccess', _48 => _48.cursor]);
2367
+ const cursor = _optionalChain([json, 'access', _49 => _49.result_info, 'optionalAccess', _50 => _50.cursor]);
2358
2368
  url2 = cursor ? `${CF_API2}/zones/${cfg.zoneId}/email/routing/rules?per_page=1000&cursor=${encodeURIComponent(cursor)}` : null;
2359
2369
  }
2360
2370
  return rules;
@@ -2383,7 +2393,7 @@ async function auditCfRoutingDrift(args) {
2383
2393
  );
2384
2394
  const banksyncPattern = new RegExp(`^[0-9a-f]{4,32}@${escapeRegExp(cf.domain)}$`);
2385
2395
  const cf_rules_orphaned = cfRules.filter((r) => {
2386
- const firstMatcher = _optionalChain([r, 'access', _49 => _49.matchers, 'optionalAccess', _50 => _50[0]]);
2396
+ const firstMatcher = _optionalChain([r, 'access', _51 => _51.matchers, 'optionalAccess', _52 => _52[0]]);
2387
2397
  if (!firstMatcher) return false;
2388
2398
  if (firstMatcher.type !== "literal") return false;
2389
2399
  const val = _nullishCoalesce(firstMatcher.value, () => ( ""));
@@ -2519,7 +2529,7 @@ async function runAlerterTick(db, cfg) {
2519
2529
  }
2520
2530
  const prior = await db.prepare(`SELECT value, updated_at FROM alert_state WHERE key = 'alerter_active'`).first();
2521
2531
  const repeatAfterMs = 30 * 60 * 1e3;
2522
- if (_optionalChain([prior, 'optionalAccess', _51 => _51.value]) === "active" && Date.now() - new Date(prior.updated_at).getTime() < repeatAfterMs) {
2532
+ if (_optionalChain([prior, 'optionalAccess', _53 => _53.value]) === "active" && Date.now() - new Date(prior.updated_at).getTime() < repeatAfterMs) {
2523
2533
  return { fired: false };
2524
2534
  }
2525
2535
  const posted = await postAlert(payload, cfg);
@@ -2605,7 +2615,7 @@ async function buildSqlDump(db) {
2605
2615
  const lines = [];
2606
2616
  lines.push(`-- banksync backup ${(/* @__PURE__ */ new Date()).toISOString()}`);
2607
2617
  const versionRow = await db.prepare(`SELECT value FROM schema_meta WHERE key = 'version'`).first();
2608
- lines.push(`-- schema_version=${_nullishCoalesce(_optionalChain([versionRow, 'optionalAccess', _52 => _52.value]), () => ( "unknown"))}`);
2618
+ lines.push(`-- schema_version=${_nullishCoalesce(_optionalChain([versionRow, 'optionalAccess', _54 => _54.value]), () => ( "unknown"))}`);
2609
2619
  lines.push(`-- restore: re-apply migrations then load this file`);
2610
2620
  lines.push("");
2611
2621
  const rowCounts = {};
@@ -2659,10 +2669,79 @@ 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 (_optionalChain([enabled, 'optionalAccess', _55 => _55.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(_nullishCoalesce(_optionalChain([checkpoint, 'optionalAccess', _56 => _56.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 = _nullishCoalesce(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 _chunkN7BVOSFNcjs.fetchFioStatement.call(void 0, 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(_nullishCoalesce(_optionalChain([statement, 'access', _57 => _57.info, 'optionalAccess', _58 => _58.iban]), () => ( ""))).replace(/\s/g, "").toUpperCase();
2716
+ const domestic = `${_nullishCoalesce(_optionalChain([statement, 'access', _59 => _59.info, 'optionalAccess', _60 => _60.accountId]), () => ( ""))}/${_nullishCoalesce(_optionalChain([statement, 'access', _61 => _61.info, 'optionalAccess', _62 => _62.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 = _chunkN7BVOSFNcjs.mapFioTransaction.call(void 0, raw);
2723
+ if (!mapped || mapped.identity_kind !== "movement") throw new Error("fio_unverified_movement");
2724
+ if (mapped.currency !== String(_nullishCoalesce(_optionalChain([statement, 'access', _63 => _63.info, 'optionalAccess', _64 => _64.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
- const url2 = _optionalChain([env, 'access', _53 => _53.FIO_PROXY_URL, 'optionalAccess', _54 => _54.trim, 'call', _55 => _55()]);
2665
- const secret = _optionalChain([env, 'access', _56 => _56.FIO_PROXY_SECRET, 'optionalAccess', _57 => _57.trim, 'call', _58 => _58()]);
2743
+ const url2 = _optionalChain([env, 'access', _65 => _65.FIO_PROXY_URL, 'optionalAccess', _66 => _66.trim, 'call', _67 => _67()]);
2744
+ const secret = _optionalChain([env, 'access', _68 => _68.FIO_PROXY_SECRET, 'optionalAccess', _69 => _69.trim, 'call', _70 => _70()]);
2666
2745
  return url2 && secret ? { url: url2, secret } : void 0;
2667
2746
  }
2668
2747
  function cfRoutingConfig(env) {
@@ -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 && _optionalChain([schema, 'optionalAccess', _71 => _71.value]) === "11") {
2817
+ spoolId = await sha256Hex2(`${account.id}:${await _asyncNullishCoalesce(extracted.messageId, async () => ( 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 (_optionalChain([stored, 'optionalAccess', _72 => _72.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 && _optionalChain([schema, 'optionalAccess', _73 => _73.value]) === "11") {
2830
+ const enabled = await env.DB.prepare("SELECT ingest_enabled FROM bank_accounts WHERE id=?").bind(account.id).first();
2831
+ if (_optionalChain([enabled, 'optionalAccess', _74 => _74.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: _nullishCoalesce(extracted.messageId, () => ( null)) });
2706
2836
  await writeEvent(env.DB, { event_type: "email_received", bank_account_id: account.id, detail: { message_id: _nullishCoalesce(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: _nullishCoalesce(outcome.bankAccountId, () => ( null)),
2711
2842
  error_message: outcome.reason,
2712
2843
  raw_data: sanitizeDiagnosticRaw(extracted.text),
2713
2844
  external_id: _nullishCoalesce(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: _nullishCoalesce(outcome.parsed.date, () => ( (/* @__PURE__ */ new Date()).toISOString())),
2857
+ date: outcome.parsed.date,
2725
2858
  external_id: _nullishCoalesce(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: _nullishCoalesce(extracted.messageId, () => ( null)) })),
2733
2866
  external_id: _nullishCoalesce(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 (e18) {
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,10 +2959,16 @@ 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 (_optionalChain([schema, 'optionalAccess', _75 => _75.value]) === "11") {
2964
+ const result = await recoverBankAccount(env.DB, account, env, fioProxyConfig(env));
2965
+ await ensureDeliveryJobs(env.DB);
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: 0, 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);
2829
- await _chunkPBN2BUNRcjs.setFioPointer.call(void 0, token, yyyyMmDdDaysAgo(90), fioProxyConfig(env));
2971
+ await _chunkN7BVOSFNcjs.setFioPointer.call(void 0, token, yyyyMmDdDaysAgo(90), fioProxyConfig(env));
2830
2972
  await markBankAccountApiPointerSet(env.DB, account.id);
2831
2973
  await writeEvent(env.DB, {
2832
2974
  event_type: "api_pointer_set",
@@ -2846,7 +2988,7 @@ async function runBankApiSync(env, account) {
2846
2988
  };
2847
2989
  }
2848
2990
  await markBankAccountApiFetchStarted(env.DB, account.id);
2849
- const raw = await _chunkPBN2BUNRcjs.fetchNewTransactions.call(void 0, token, fioProxyConfig(env));
2991
+ const raw = await _chunkN7BVOSFNcjs.fetchNewTransactions.call(void 0, token, fioProxyConfig(env));
2850
2992
  let inserted = 0;
2851
2993
  let skippedDuplicate = 0;
2852
2994
  let skippedOutgoing = 0;
@@ -2855,7 +2997,7 @@ async function runBankApiSync(env, account) {
2855
2997
  for (const item of raw) {
2856
2998
  let mapped;
2857
2999
  try {
2858
- mapped = _chunkPBN2BUNRcjs.mapFioTransaction.call(void 0, item);
3000
+ mapped = _chunkN7BVOSFNcjs.mapFioTransaction.call(void 0, item);
2859
3001
  } catch (err) {
2860
3002
  parseErrors++;
2861
3003
  await insertParseLog(env.DB, {
@@ -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,
@@ -2911,7 +3053,7 @@ async function runBankApiSync(env, account) {
2911
3053
  };
2912
3054
  } catch (err) {
2913
3055
  await markBankAccountApiFetchFailure(env.DB, account.id, String(err));
2914
- if (err instanceof _chunkPBN2BUNRcjs.FioRateLimited) {
3056
+ if (err instanceof _chunkN7BVOSFNcjs.FioRateLimited) {
2915
3057
  await writeEvent(env.DB, {
2916
3058
  event_type: "api_rate_limited",
2917
3059
  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 = _nullishCoalesce(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", {
@@ -2943,11 +3078,11 @@ async function runDueBankApiSyncs(env) {
2943
3078
  deferred: result.deferred
2944
3079
  });
2945
3080
  } catch (err) {
2946
- if (err instanceof _chunkPBN2BUNRcjs.FioRateLimited) {
3081
+ if (err instanceof _chunkN7BVOSFNcjs.FioRateLimited) {
2947
3082
  log("api_rate_limited", { bank_account_id: account.id, account_type: account.account_type, retry_after_s: err.retryAfterS });
2948
3083
  continue;
2949
3084
  }
2950
- if (err instanceof _chunkPBN2BUNRcjs.FioTransientFailure) {
3085
+ if (err instanceof _chunkN7BVOSFNcjs.FioTransientFailure) {
2951
3086
  logError("api_transient_failure", err, { bank_account_id: account.id, account_type: account.account_type });
2952
3087
  continue;
2953
3088
  }
@@ -2968,7 +3103,7 @@ async function handleApiSyncQueue(batch, env) {
2968
3103
  await assertSchemaVersion(env.DB);
2969
3104
  for (const rawMsg of batch.messages) {
2970
3105
  const msg = rawMsg;
2971
- if (_optionalChain([msg, 'access', _59 => _59.body, 'optionalAccess', _60 => _60.kind]) !== "api_sync_tick") {
3106
+ if (_optionalChain([msg, 'access', _76 => _76.body, 'optionalAccess', _77 => _77.kind]) !== "api_sync_tick") {
2972
3107
  logError("api_sync_queue_unknown_message", new Error("unknown api sync queue message"), { queue: batch.queue });
2973
3108
  msg.ack();
2974
3109
  continue;
@@ -3051,8 +3186,8 @@ async function adminFetch(req, env) {
3051
3186
  backupsPresent: Boolean(
3052
3187
  env.BACKUPS && Number.isInteger(Number(env.BACKUP_ENCRYPTION_KEY_VERSION)) && env[`BACKUP_ENCRYPTION_KEY_V${env.BACKUP_ENCRYPTION_KEY_VERSION}`]
3053
3188
  ),
3054
- emailAuthPresent: Boolean(_optionalChain([env, 'access', _61 => _61.EMAIL_AUTHSERV_ID, 'optionalAccess', _62 => _62.trim, 'call', _63 => _63()])),
3055
- callbackPolicyPresent: Boolean(_optionalChain([env, 'access', _64 => _64.CALLBACK_HOST_ALLOWLIST, 'optionalAccess', _65 => _65.trim, 'call', _66 => _66()])),
3189
+ emailAuthPresent: Boolean(_optionalChain([env, 'access', _78 => _78.EMAIL_AUTHSERV_ID, 'optionalAccess', _79 => _79.trim, 'call', _80 => _80()])),
3190
+ callbackPolicyPresent: Boolean(_optionalChain([env, 'access', _81 => _81.CALLBACK_HOST_ALLOWLIST, 'optionalAccess', _82 => _82.trim, 'call', _83 => _83()])),
3056
3191
  isProduction: env.ENV === "production"
3057
3192
  }
3058
3193
  });
@@ -3219,6 +3354,11 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3219
3354
  if (input.fio_api_token !== void 0 && (_nullishCoalesce(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 && _optionalChain([expanded, 'optionalAccess', _84 => _84.value]) !== "11") return jsonResponse({ error: "paused_ingest_requires_schema11" }, 409);
3359
+ const capability = _optionalChain([expanded, 'optionalAccess', _85 => _85.value]) === "11" ? await env.DB.prepare("SELECT event_version FROM webhook_consumers WHERE app_id=?").bind(input.owner_app_id).first() : null;
3360
+ if (_optionalChain([capability, 'optionalAccess', _86 => _86.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 (_optionalChain([capability, 'optionalAccess', _87 => _87.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 = _nullishCoalesce(input.ingest_mode, () => ( (input.fio_api_token ? "api" : "email")));
3224
3364
  const createEmailRoute = ingestMode === "email" || ingestMode === "both";
@@ -3234,14 +3374,19 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3234
3374
  ...input.label !== void 0 ? { label: input.label } : {},
3235
3375
  owner_app_id: input.owner_app_id,
3236
3376
  cf_rule_id: null,
3237
- api_token_cipher: _nullishCoalesce(_optionalChain([encryptedApiToken, 'optionalAccess', _67 => _67.cipher]), () => ( null)),
3238
- api_token_key_ver: _nullishCoalesce(_optionalChain([encryptedApiToken, 'optionalAccess', _68 => _68.keyVersion]), () => ( null)),
3377
+ api_token_cipher: _nullishCoalesce(_optionalChain([encryptedApiToken, 'optionalAccess', _88 => _88.cipher]), () => ( null)),
3378
+ api_token_key_ver: _nullishCoalesce(_optionalChain([encryptedApiToken, 'optionalAccess', _89 => _89.keyVersion]), () => ( null)),
3239
3379
  api_token_prefix: input.fio_api_token ? tokenPrefix(input.fio_api_token) : null,
3240
3380
  api_fetch_enabled: Boolean(input.fio_api_token && ingestMode !== "email")
3241
3381
  });
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 (_optionalChain([schemaVersion, 'optionalAccess', _90 => _90.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 (_optionalChain([schema, 'optionalAccess', _91 => _91.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 (e21) {
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]);
@@ -3306,7 +3471,7 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3306
3471
  if (existing.account_type !== "FIO") return jsonResponse({ error: "fio_api_token_supported_only_for_fio" }, 400);
3307
3472
  const input = parseOr400(UpdateFioTokenSchema, bodyText);
3308
3473
  if (input instanceof Response) return input;
3309
- const rawToken = _optionalChain([input, 'access', _69 => _69.fio_api_token, 'optionalAccess', _70 => _70.trim, 'call', _71 => _71()]);
3474
+ const rawToken = _optionalChain([input, 'access', _92 => _92.fio_api_token, 'optionalAccess', _93 => _93.trim, 'call', _94 => _94()]);
3310
3475
  if (rawToken !== void 0 && rawToken.length > 0 && rawToken.length < 8) {
3311
3476
  return jsonResponse({ error: "validation_failed: fio_api_token must be at least 8 characters" }, 400);
3312
3477
  }
@@ -3320,6 +3485,8 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3320
3485
  ingest_mode: _nullishCoalesce(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 (_optionalChain([schemaVersion, 'optionalAccess', _95 => _95.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
  }
@@ -3373,13 +3540,13 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
3373
3540
  queued_webhooks: result.queued_webhooks,
3374
3541
  backfill: result.backfill,
3375
3542
  deferred: result.deferred,
3376
- api_last_fetch_at: _nullishCoalesce(_optionalChain([updated, 'optionalAccess', _72 => _72.api_last_fetch_at]), () => ( null)),
3377
- api_last_success_at: _nullishCoalesce(_optionalChain([updated, 'optionalAccess', _73 => _73.api_last_success_at]), () => ( null)),
3378
- api_last_error: _nullishCoalesce(_optionalChain([updated, 'optionalAccess', _74 => _74.api_last_error]), () => ( null)),
3379
- api_backfill_done: _nullishCoalesce(_optionalChain([updated, 'optionalAccess', _75 => _75.api_backfill_done]), () => ( false))
3543
+ api_last_fetch_at: _nullishCoalesce(_optionalChain([updated, 'optionalAccess', _96 => _96.api_last_fetch_at]), () => ( null)),
3544
+ api_last_success_at: _nullishCoalesce(_optionalChain([updated, 'optionalAccess', _97 => _97.api_last_success_at]), () => ( null)),
3545
+ api_last_error: _nullishCoalesce(_optionalChain([updated, 'optionalAccess', _98 => _98.api_last_error]), () => ( null)),
3546
+ api_backfill_done: _nullishCoalesce(_optionalChain([updated, 'optionalAccess', _99 => _99.api_backfill_done]), () => ( false))
3380
3547
  });
3381
3548
  } catch (err) {
3382
- if (err instanceof _chunkPBN2BUNRcjs.FioRateLimited || err instanceof _chunkPBN2BUNRcjs.FioTransientFailure) {
3549
+ if (err instanceof _chunkN7BVOSFNcjs.FioRateLimited || err instanceof _chunkN7BVOSFNcjs.FioTransientFailure) {
3383
3550
  logError("fio_api_manual_sync_failed", err, { bank_account_id: id });
3384
3551
  return jsonResponse({ error: "fio_api_transient_failure" }, 503);
3385
3552
  }
@@ -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" && _optionalChain([schemaVersion, 'optionalAccess', _100 => _100.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: _nullishCoalesce(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(_nullishCoalesce(_nullishCoalesce(url2.searchParams.get("high_water"), () => ( _optionalChain([maximum, 'optionalAccess', _101 => _101.id]))), () => ( 0)));
3746
+ const cursor = Number(_nullishCoalesce(url2.searchParams.get("cursor"), () => ( 0)));
3747
+ const limit = Number(_nullishCoalesce(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: _nullishCoalesce(_optionalChain([rows, 'access', _102 => _102.results, 'access', _103 => _103.at, 'call', _104 => _104(-1), 'optionalAccess', _105 => _105.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 (e22) {
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 (_optionalChain([account, 'optionalAccess', _106 => _106.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 = _nullishCoalesce(url2.searchParams.get("since"), () => ( (/* @__PURE__ */ new Date(0)).toISOString()));
3571
3769
  const limitStr = url2.searchParams.get("limit");
@@ -3641,7 +3839,7 @@ var cloudflare_default = {
3641
3839
  log("email_rejected_oversized", { size: message.rawSize });
3642
3840
  return;
3643
3841
  }
3644
- const trustedAuthservId = _optionalChain([env, 'access', _76 => _76.EMAIL_AUTHSERV_ID, 'optionalAccess', _77 => _77.trim, 'call', _78 => _78()]);
3842
+ const trustedAuthservId = _optionalChain([env, 'access', _107 => _107.EMAIL_AUTHSERV_ID, 'optionalAccess', _108 => _108.trim, 'call', _109 => _109()]);
3645
3843
  if (!trustedAuthservId) {
3646
3844
  const inspection = inspectAuthenticationResults(message.headers.get("authentication-results"));
3647
3845
  await insertParseLog(env.DB, { error_message: "email_ingest_disabled_untrusted_authserv", raw_data: null });
@@ -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, ..._optionalChain([cursor, 'optionalAccess', _110 => _110.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 (e23) {
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 (_optionalChain([schema, 'optionalAccess', _111 => _111.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 (e24) {
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) {