@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.
- package/README.md +64 -8
- package/dist/{chunk-5QXHZGAW.js → chunk-3WM2GGFL.js} +66 -27
- package/dist/{chunk-PBN2BUNR.cjs → chunk-N7BVOSFN.cjs} +66 -27
- package/dist/cloudflare.cjs +344 -115
- package/dist/cloudflare.d.cts +3 -1
- package/dist/cloudflare.d.ts +3 -1
- package/dist/cloudflare.js +289 -60
- package/dist/index.cjs +4 -2
- package/dist/index.d.cts +11 -18
- package/dist/index.d.ts +11 -18
- package/dist/index.js +3 -1
- package/dist/{types-Cb5l2lXq.d.cts → types-DZX_B1qb.d.cts} +8 -2
- package/dist/{types-Cb5l2lXq.d.ts → types-DZX_B1qb.d.ts} +8 -2
- package/migrations/0011_complete_bank_facts.sql +52 -0
- package/package.json +3 -3
package/dist/cloudflare.cjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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:
|
|
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)
|
|
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 (
|
|
640
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
834
|
-
const insertedCount = _nullishCoalesce(_optionalChain([txInserted24h, 'optionalAccess',
|
|
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',
|
|
848
|
-
outgoing_filtered_24h: _nullishCoalesce(_optionalChain([outgoing, 'optionalAccess',
|
|
849
|
-
unknown_provider_24h: _nullishCoalesce(_optionalChain([unknownProv, 'optionalAccess',
|
|
850
|
-
unmatched_24h: _nullishCoalesce(_optionalChain([unmatched, 'optionalAccess',
|
|
851
|
-
not_transaction_24h: _nullishCoalesce(_optionalChain([notTransaction, 'optionalAccess',
|
|
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',
|
|
872
|
-
delivery_stalled: _nullishCoalesce(_optionalChain([deliveryStalled, 'optionalAccess',
|
|
873
|
-
delivery_terminal: _nullishCoalesce(_optionalChain([deliveryTerminal, 'optionalAccess',
|
|
874
|
-
oldest_undelivered_at: _nullishCoalesce(_optionalChain([oldestUndelivered, 'optionalAccess',
|
|
875
|
-
pending_delivery_alerts: _nullishCoalesce(_optionalChain([pendingAlerts, 'optionalAccess',
|
|
876
|
-
api_rate_limited_24h: _nullishCoalesce(_optionalChain([apiRateLimited, 'optionalAccess',
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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 =
|
|
1230
|
+
const envelope = _chunkN7BVOSFNcjs.buildWebhookEnvelope.call(void 0, {
|
|
1222
1231
|
delivery_id: deliveryId,
|
|
1223
1232
|
pairing_code: row.pairing_code,
|
|
1224
|
-
transaction
|
|
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',
|
|
1352
|
-
_nullishCoalesce(_optionalChain([args, 'access',
|
|
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
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
2303
|
-
const failed = _nullishCoalesce(_optionalChain([failedRow, 'optionalAccess',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
2665
|
-
const secret = _optionalChain([env, 'access',
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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',
|
|
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',
|
|
3055
|
-
callbackPolicyPresent: Boolean(_optionalChain([env, 'access',
|
|
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',
|
|
3238
|
-
api_token_key_ver: _nullishCoalesce(_optionalChain([encryptedApiToken, 'optionalAccess',
|
|
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',
|
|
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',
|
|
3377
|
-
api_last_success_at: _nullishCoalesce(_optionalChain([updated, 'optionalAccess',
|
|
3378
|
-
api_last_error: _nullishCoalesce(_optionalChain([updated, 'optionalAccess',
|
|
3379
|
-
api_backfill_done: _nullishCoalesce(_optionalChain([updated, 'optionalAccess',
|
|
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
|
|
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',
|
|
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) {
|