@festapp/banksync 0.1.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -8
- package/dist/{chunk-5QXHZGAW.js → chunk-3WM2GGFL.js} +66 -27
- package/dist/{chunk-PBN2BUNR.cjs → chunk-N7BVOSFN.cjs} +66 -27
- package/dist/cloudflare.cjs +358 -117
- package/dist/cloudflare.d.cts +3 -1
- package/dist/cloudflare.d.ts +3 -1
- package/dist/cloudflare.js +303 -62
- package/dist/index.cjs +4 -2
- package/dist/index.d.cts +11 -18
- package/dist/index.d.ts +11 -18
- package/dist/index.js +3 -1
- package/dist/{types-Cb5l2lXq.d.cts → types-DZX_B1qb.d.cts} +8 -2
- package/dist/{types-Cb5l2lXq.d.ts → types-DZX_B1qb.d.ts} +8 -2
- package/migrations/0011_complete_bank_facts.sql +52 -0
- package/package.json +7 -7
package/dist/cloudflare.js
CHANGED
|
@@ -3,13 +3,14 @@ import {
|
|
|
3
3
|
FioTransientFailure,
|
|
4
4
|
buildWebhookEnvelope,
|
|
5
5
|
detectProvider,
|
|
6
|
+
fetchFioStatement,
|
|
6
7
|
fetchNewTransactions,
|
|
7
8
|
mapFioTransaction,
|
|
8
9
|
parseEmail,
|
|
9
10
|
resolveVariableSymbol,
|
|
10
11
|
setFioPointer,
|
|
11
12
|
signWebhook
|
|
12
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-3WM2GGFL.js";
|
|
13
14
|
|
|
14
15
|
// src/mime.ts
|
|
15
16
|
import PostalMime from "postal-mime";
|
|
@@ -313,7 +314,7 @@ function logError(event, err, data) {
|
|
|
313
314
|
}
|
|
314
315
|
|
|
315
316
|
// src/db.ts
|
|
316
|
-
var SUPPORTED_SCHEMA_VERSIONS = ["10"];
|
|
317
|
+
var SUPPORTED_SCHEMA_VERSIONS = ["10", "11"];
|
|
317
318
|
var _checkedDbs = /* @__PURE__ */ new Set();
|
|
318
319
|
async function assertSchemaVersion(db) {
|
|
319
320
|
if (_checkedDbs.has(db)) return;
|
|
@@ -542,6 +543,7 @@ var SQL_CREATE_CONSUMER = `INSERT INTO webhook_consumers (app_id, callback_url,
|
|
|
542
543
|
async function createConsumer(db, args) {
|
|
543
544
|
const row = await db.prepare(SQL_CREATE_CONSUMER).bind(args.app_id, args.callback_url, args.secret_cipher, args.secret_hash, args.secret_prefix).first();
|
|
544
545
|
if (!row) throw new Error("createConsumer: no row returned");
|
|
546
|
+
if (args.event_version === "2") await db.prepare("UPDATE webhook_consumers SET event_version='2' WHERE id=?").bind(row.id).run();
|
|
545
547
|
return row;
|
|
546
548
|
}
|
|
547
549
|
var SQL_FIND_CONSUMER_BY_APP_ID = `SELECT id, app_id, callback_url, secret_cipher, secret_hash, secret_prefix, prev_secret_cipher, prev_expires_at, admin_key_hash, admin_key_prefix, created_at FROM webhook_consumers WHERE app_id = ?`;
|
|
@@ -601,45 +603,30 @@ INSERT OR IGNORE INTO transactions (
|
|
|
601
603
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
602
604
|
`;
|
|
603
605
|
var SQL_SELECT_TRANSACTION_BY_ID = `SELECT id, bank_account_id, amount_cents, currency, counter_account, bank_code, bank_name, vs, ks, ss, message, sender_name, user_identification, transaction_type, performed_by, comment, command_id, source, date, date_offset_min, transaction_id, external_id FROM transactions WHERE id = ?`;
|
|
604
|
-
var SQL_FIND_DUPLICATE_EXT = `SELECT id FROM transactions WHERE external_id = ? AND external_id IS NOT NULL LIMIT 1`;
|
|
605
|
-
var SQL_FIND_DUPLICATE_FIO = `SELECT
|
|
606
|
-
var SQL_FIND_DUPLICATE_FUZZY = `
|
|
607
|
-
SELECT id
|
|
608
|
-
FROM transactions
|
|
609
|
-
WHERE bank_account_id = ?
|
|
610
|
-
AND vs = ?
|
|
611
|
-
AND amount_cents = ?
|
|
612
|
-
AND currency = ?
|
|
613
|
-
AND substr(date, 1, 10) BETWEEN date(?, '-3 days') AND date(?, '+3 days')
|
|
614
|
-
LIMIT 1
|
|
615
|
-
`;
|
|
616
|
-
function fuzzyDateDay(date) {
|
|
617
|
-
return /^\d{4}-\d{2}-\d{2}/.test(date) ? date.slice(0, 10) : null;
|
|
618
|
-
}
|
|
619
|
-
async function findFuzzyDuplicate(db, bankAccountId, p) {
|
|
620
|
-
if (!p.vs) return false;
|
|
621
|
-
const dateDay = fuzzyDateDay(p.date);
|
|
622
|
-
if (!dateDay) return false;
|
|
623
|
-
const hit = await db.prepare(SQL_FIND_DUPLICATE_FUZZY).bind(bankAccountId, p.vs, p.amount_cents, p.currency, dateDay, dateDay).first();
|
|
624
|
-
return hit !== null;
|
|
625
|
-
}
|
|
606
|
+
var SQL_FIND_DUPLICATE_EXT = `SELECT id FROM transactions WHERE bank_account_id = ? AND external_id = ? AND external_id IS NOT NULL LIMIT 1`;
|
|
607
|
+
var SQL_FIND_DUPLICATE_FIO = `SELECT * FROM transactions WHERE bank_account_id = ? AND transaction_id = ? AND transaction_id IS NOT NULL AND source='fio_api' LIMIT 1`;
|
|
626
608
|
async function insertTransaction(db, args) {
|
|
609
|
+
const schema = await db.prepare(`SELECT value FROM schema_meta WHERE key='version'`).first();
|
|
610
|
+
const v2 = schema?.value === "11";
|
|
627
611
|
const p = {
|
|
628
612
|
...args.payload,
|
|
629
|
-
vs: resolveVariableSymbol(args.payload)
|
|
613
|
+
vs: v2 ? args.payload.raw_vs ?? args.payload.vs ?? null : resolveVariableSymbol(args.payload)
|
|
630
614
|
};
|
|
631
|
-
if (p.transaction_id != null) {
|
|
615
|
+
if (p.transaction_id != null && p.source === "fio_api") {
|
|
632
616
|
const hit = await db.prepare(SQL_FIND_DUPLICATE_FIO).bind(args.bank_account_id, p.transaction_id).first();
|
|
633
|
-
if (hit)
|
|
617
|
+
if (hit) {
|
|
618
|
+
const facts = hit;
|
|
619
|
+
if (facts.amount_cents !== p.amount_cents || facts.currency !== p.currency || facts.date.slice(0, 10) !== p.date.slice(0, 10) || facts.identity_kind === "movement" && (facts.raw_vs !== (p.raw_vs ?? args.payload.vs ?? null) || facts.payer_reference !== (p.payer_reference ?? null))) throw new Error("bank_movement_fact_conflict");
|
|
620
|
+
return { status: "skipped", reason: "duplicate_transaction_id" };
|
|
621
|
+
}
|
|
634
622
|
}
|
|
635
623
|
if (p.external_id != null) {
|
|
636
|
-
const hit = await db.prepare(SQL_FIND_DUPLICATE_EXT).bind(p.external_id).first();
|
|
624
|
+
const hit = await db.prepare(SQL_FIND_DUPLICATE_EXT).bind(args.bank_account_id, p.external_id).first();
|
|
637
625
|
if (hit) return { status: "skipped", reason: "duplicate_external_id" };
|
|
638
626
|
}
|
|
639
|
-
if (
|
|
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
|
p.date_offset_min ?? null,
|
|
662
649
|
p.transaction_id ?? null,
|
|
663
650
|
p.external_id ?? null
|
|
664
|
-
|
|
651
|
+
];
|
|
652
|
+
if (v2) values.push(
|
|
653
|
+
p.payer_reference ?? null,
|
|
654
|
+
args.payload.raw_vs ?? args.payload.vs ?? null,
|
|
655
|
+
p.amount_cents > 0 ? "incoming" : p.amount_cents < 0 ? "outgoing" : "zero",
|
|
656
|
+
p.identity_kind ?? (p.source === "fio_api" && p.transaction_id ? "movement" : "observation"),
|
|
657
|
+
p.identity_provenance ?? (p.source === "fio_api" ? "fio_api_column22" : "authenticated_email_unverified_movement")
|
|
658
|
+
);
|
|
659
|
+
const result = await db.prepare(sql).bind(...values).run();
|
|
665
660
|
if (result.meta.changes === 0) {
|
|
666
|
-
if (p.transaction_id != null) {
|
|
661
|
+
if (p.transaction_id != null && p.source === "fio_api") {
|
|
667
662
|
const hit = await db.prepare(SQL_FIND_DUPLICATE_FIO).bind(args.bank_account_id, p.transaction_id).first();
|
|
668
|
-
if (hit)
|
|
663
|
+
if (hit) {
|
|
664
|
+
const facts = hit;
|
|
665
|
+
if (facts.amount_cents !== p.amount_cents || facts.currency !== p.currency || facts.date.slice(0, 10) !== p.date.slice(0, 10) || facts.identity_kind === "movement" && (facts.raw_vs !== (p.raw_vs ?? args.payload.vs ?? null) || facts.payer_reference !== (p.payer_reference ?? null))) throw new Error("bank_movement_fact_conflict");
|
|
666
|
+
return { status: "skipped", reason: "duplicate_transaction_id" };
|
|
667
|
+
}
|
|
669
668
|
}
|
|
670
669
|
if (p.external_id != null) {
|
|
671
|
-
const hit = await db.prepare(SQL_FIND_DUPLICATE_EXT).bind(p.external_id).first();
|
|
670
|
+
const hit = await db.prepare(SQL_FIND_DUPLICATE_EXT).bind(args.bank_account_id, p.external_id).first();
|
|
672
671
|
if (hit) return { status: "skipped", reason: "duplicate_external_id" };
|
|
673
672
|
}
|
|
674
|
-
|
|
675
|
-
return { status: "skipped", reason: "fuzzy_duplicate" };
|
|
676
|
-
}
|
|
677
|
-
return { status: "skipped", reason: "fuzzy_duplicate" };
|
|
673
|
+
throw new Error("transaction_insert_constraint_conflict");
|
|
678
674
|
}
|
|
679
675
|
const rowId = result.meta.last_row_id;
|
|
680
|
-
const row = await db.prepare(SQL_SELECT_TRANSACTION_BY_ID).bind(rowId).first();
|
|
676
|
+
const row = await db.prepare(v2 ? "SELECT * FROM transactions WHERE id = ?" : SQL_SELECT_TRANSACTION_BY_ID).bind(rowId).first();
|
|
681
677
|
if (!row) throw new Error("insertTransaction: row vanished after insert");
|
|
682
678
|
return { status: "inserted", transaction: row };
|
|
683
679
|
}
|
|
@@ -880,8 +876,14 @@ async function getStatusData(db) {
|
|
|
880
876
|
var SQL_PRUNE_PARSE_LOG = `DELETE FROM parse_log WHERE created_at < datetime('now', '-30 days')`;
|
|
881
877
|
var SQL_PRUNE_WEBHOOK_LOG = `DELETE FROM webhook_log WHERE created_at < datetime('now', '-90 days')`;
|
|
882
878
|
var SQL_PRUNE_EVENT_LOG = `DELETE FROM event_log WHERE created_at < datetime('now', '-30 days')`;
|
|
883
|
-
var SQL_PRUNE_TRANSACTIONS = `DELETE FROM transactions WHERE created_at < datetime('now', '-90 days')
|
|
884
|
-
|
|
879
|
+
var SQL_PRUNE_TRANSACTIONS = `DELETE FROM transactions WHERE created_at < datetime('now', '-90 days')
|
|
880
|
+
AND NOT EXISTS (SELECT 1 FROM webhook_delivery_jobs j WHERE j.transaction_id=transactions.id
|
|
881
|
+
AND (j.status<>'delivered' OR j.business_outcome LIKE 'quarantined_%'))
|
|
882
|
+
AND NOT EXISTS (SELECT 1 FROM webhook_subscriptions s WHERE s.bank_account_id=transactions.bank_account_id
|
|
883
|
+
AND datetime(s.created_at)<=datetime(transactions.created_at)
|
|
884
|
+
AND (s.deleted_at IS NULL OR datetime(transactions.created_at)<datetime(s.deleted_at))
|
|
885
|
+
AND NOT EXISTS (SELECT 1 FROM webhook_delivery_jobs j WHERE j.transaction_id=transactions.id AND j.consumer_app_id=s.consumer_app_id))`;
|
|
886
|
+
var SQL_PRUNE_IDEMPOTENCY_KEYS = `DELETE FROM idempotency_keys WHERE request_path <> '/bank-accounts' AND created_at < datetime('now', '-24 hours')`;
|
|
885
887
|
var SQL_PRUNE_ADMIN_AUDIT_LOG = `DELETE FROM admin_audit_log WHERE created_at < datetime('now', '-90 days')`;
|
|
886
888
|
var SQL_CLEAR_EXPIRED_PREV_SECRETS = `UPDATE webhook_consumers SET prev_secret_cipher = NULL, prev_expires_at = NULL WHERE prev_expires_at < datetime('now')`;
|
|
887
889
|
async function pruneRetention(db) {
|
|
@@ -925,6 +927,7 @@ function parseBody(schema, body) {
|
|
|
925
927
|
}
|
|
926
928
|
}
|
|
927
929
|
var CreateBankAccountSchema = v.object({
|
|
930
|
+
ingest_enabled: v.optional(v.boolean()),
|
|
928
931
|
account_number: v.pipe(v.string(), v.minLength(3), v.maxLength(64)),
|
|
929
932
|
account_type: v.optional(v.picklist(["FIO", "AIRBANK"])),
|
|
930
933
|
ingest_mode: v.optional(v.picklist(["email", "api", "both"])),
|
|
@@ -960,6 +963,7 @@ var UpdateFioTokenSchema = v.pipe(
|
|
|
960
963
|
)
|
|
961
964
|
);
|
|
962
965
|
var CreateConsumerSchema = v.object({
|
|
966
|
+
event_version: v.optional(v.picklist(["1", "2"])),
|
|
963
967
|
app_id: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-_]{1,63}$/)),
|
|
964
968
|
callback_url: v.pipe(v.string(), v.url(), v.regex(/^https:\/\//))
|
|
965
969
|
});
|
|
@@ -1197,13 +1201,18 @@ async function sha256Hex(input) {
|
|
|
1197
1201
|
function newDispatchToken() {
|
|
1198
1202
|
return crypto.randomUUID();
|
|
1199
1203
|
}
|
|
1200
|
-
async function ensureDeliveryJobs(db, transactionId) {
|
|
1204
|
+
async function ensureDeliveryJobs(db, transactionId, cutover) {
|
|
1205
|
+
const schema = await db.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
|
|
1206
|
+
const expanded = schema?.value === "11";
|
|
1207
|
+
if (cutover && !expanded) throw new Error("cutover_requires_schema11");
|
|
1201
1208
|
const candidates = await db.prepare(`
|
|
1202
|
-
SELECT t.*, b.pairing_code, s.consumer_app_id
|
|
1209
|
+
SELECT t.*, b.pairing_code, s.consumer_app_id${expanded ? ", c.event_version" : ""}
|
|
1203
1210
|
FROM transactions t
|
|
1204
1211
|
JOIN bank_accounts b ON b.id = t.bank_account_id
|
|
1205
1212
|
JOIN webhook_subscriptions s ON s.bank_account_id = t.bank_account_id
|
|
1213
|
+
${expanded ? "JOIN webhook_consumers c ON c.app_id = s.consumer_app_id" : ""}
|
|
1206
1214
|
WHERE (? IS NULL OR t.id = ?)
|
|
1215
|
+
AND ${expanded ? "(c.event_version='2' OR t.amount_cents > 0)" : "t.amount_cents > 0"}
|
|
1207
1216
|
AND NOT EXISTS (
|
|
1208
1217
|
SELECT 1 FROM webhook_delivery_jobs j
|
|
1209
1218
|
WHERE j.transaction_id = t.id AND j.consumer_app_id = s.consumer_app_id
|
|
@@ -1212,16 +1221,17 @@ async function ensureDeliveryJobs(db, transactionId) {
|
|
|
1212
1221
|
-- Intent is decided by the subscription interval at ingest, not by the
|
|
1213
1222
|
-- current active-subscription view. Exact UTC datetime (not julianday) so
|
|
1214
1223
|
-- a same-second create/delete/transaction is deterministic.
|
|
1215
|
-
AND datetime(s.created_at) <= datetime(t.created_at)
|
|
1216
|
-
|
|
1217
|
-
`).bind(transactionId ?? null, transactionId ?? null).all();
|
|
1224
|
+
${cutover ? "AND t.bank_account_id=? AND s.consumer_app_id=? AND t.id>=? AND t.id<=? AND s.deleted_at IS NULL AND c.event_version='2'" : "AND datetime(s.created_at) <= datetime(t.created_at) AND (s.deleted_at IS NULL OR datetime(t.created_at) < datetime(s.deleted_at))"}
|
|
1225
|
+
`).bind(transactionId ?? null, transactionId ?? null, ...cutover ? [cutover.accountId, cutover.consumerAppId, cutover.fromId, cutover.throughId] : []).all();
|
|
1218
1226
|
let created = 0;
|
|
1219
1227
|
for (const row of candidates.results) {
|
|
1228
|
+
const { pairing_code, consumer_app_id, event_version, created_at: _createdAt, ...transaction } = row;
|
|
1220
1229
|
const deliveryId = ulid();
|
|
1221
1230
|
const envelope = buildWebhookEnvelope({
|
|
1222
1231
|
delivery_id: deliveryId,
|
|
1223
1232
|
pairing_code: row.pairing_code,
|
|
1224
|
-
transaction
|
|
1233
|
+
transaction,
|
|
1234
|
+
event_version: row.event_version ?? "1"
|
|
1225
1235
|
});
|
|
1226
1236
|
const payload = JSON.stringify(envelope);
|
|
1227
1237
|
const result = await db.prepare(`
|
|
@@ -2659,6 +2669,75 @@ async function runBackupTick(db, cfg) {
|
|
|
2659
2669
|
};
|
|
2660
2670
|
}
|
|
2661
2671
|
|
|
2672
|
+
// src/bankRecovery.ts
|
|
2673
|
+
async function recoverBankAccount(db, account, env, proxy) {
|
|
2674
|
+
const enabled = await db.prepare("SELECT ingest_enabled FROM bank_accounts WHERE id=?").bind(account.id).first();
|
|
2675
|
+
if (enabled?.ingest_enabled === 0) throw new Error("bank_connection_paused");
|
|
2676
|
+
const credential = await decryptSecret(account.api_token_cipher, account.api_token_key_ver, env);
|
|
2677
|
+
const keys = [await sha256Hex2(`fio-account:${account.account_number.replace(/\s/g, "").toUpperCase()}`), await sha256Hex2(`fio-token:${credential}`)].sort();
|
|
2678
|
+
const fence = crypto.randomUUID();
|
|
2679
|
+
const held = [];
|
|
2680
|
+
try {
|
|
2681
|
+
for (const key of keys) {
|
|
2682
|
+
const claim = await db.prepare(`INSERT INTO bank_poll_leases (credential_hash,token,lease_until,next_allowed_at)
|
|
2683
|
+
VALUES (?,?,datetime('now','+120 seconds'),datetime('now','+30 seconds'))
|
|
2684
|
+
ON CONFLICT(credential_hash) DO UPDATE SET token=excluded.token, lease_until=excluded.lease_until,
|
|
2685
|
+
next_allowed_at=excluded.next_allowed_at
|
|
2686
|
+
WHERE datetime(bank_poll_leases.lease_until)<=datetime('now') AND datetime(bank_poll_leases.next_allowed_at)<=datetime('now')`).bind(key, fence).run();
|
|
2687
|
+
if (claim.meta.changes !== 1) throw new Error("bank_poll_busy");
|
|
2688
|
+
held.push(key);
|
|
2689
|
+
}
|
|
2690
|
+
async function renew() {
|
|
2691
|
+
for (const key of held) {
|
|
2692
|
+
const result = await db.prepare(`UPDATE bank_poll_leases SET lease_until=datetime('now','+120 seconds')
|
|
2693
|
+
WHERE credential_hash=? AND token=? AND datetime(lease_until)>datetime('now')`).bind(key, fence).run();
|
|
2694
|
+
if (result.meta.changes !== 1) throw new Error("bank_poll_lease_lost");
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
const open = await db.prepare(`SELECT id,from_date,to_date,cipher,key_version FROM bank_recovery_batches
|
|
2698
|
+
WHERE bank_account_id=? AND state<>'completed' ORDER BY created_at,id LIMIT 1`).bind(account.id).first();
|
|
2699
|
+
const checkpoint = await db.prepare("SELECT api_reconciled_through FROM bank_accounts WHERE id=?").bind(account.id).first();
|
|
2700
|
+
const end = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2701
|
+
const start = new Date(checkpoint?.api_reconciled_through ?? Date.parse(end) - 86 * 864e5);
|
|
2702
|
+
start.setUTCDate(start.getUTCDate() - 3);
|
|
2703
|
+
if (!open && Date.parse(end) - start.getTime() > 89 * 864e5) throw new Error("recovery_window_requires_bank_unlock");
|
|
2704
|
+
const batch = open ?? { id: crypto.randomUUID(), from_date: start.toISOString().slice(0, 10), to_date: end, cipher: null, key_version: null };
|
|
2705
|
+
if (!open) await db.prepare(`INSERT INTO bank_recovery_batches(id,bank_account_id,from_date,to_date,state) VALUES(?,?,?,?,'fetching')`).bind(batch.id, account.id, batch.from_date, batch.to_date).run();
|
|
2706
|
+
await db.prepare(`UPDATE bank_accounts SET api_last_fetch_at=datetime('now') WHERE id=?`).bind(account.id).run();
|
|
2707
|
+
let statement;
|
|
2708
|
+
if (batch.cipher !== null && batch.key_version !== null) statement = JSON.parse(await decryptSecret(batch.cipher, batch.key_version, env));
|
|
2709
|
+
else {
|
|
2710
|
+
statement = await fetchFioStatement(credential, batch.from_date, batch.to_date, proxy);
|
|
2711
|
+
await renew();
|
|
2712
|
+
const encrypted = await encryptSecret(JSON.stringify(statement), env);
|
|
2713
|
+
await db.prepare(`UPDATE bank_recovery_batches SET cipher=?,key_version=?,state='spooled' WHERE id=?`).bind(encrypted.cipher, encrypted.keyVersion, batch.id).run();
|
|
2714
|
+
}
|
|
2715
|
+
const identity = String(statement.info?.iban ?? "").replace(/\s/g, "").toUpperCase();
|
|
2716
|
+
const domestic = `${statement.info?.accountId ?? ""}/${statement.info?.bankId ?? ""}`;
|
|
2717
|
+
const expected = account.account_number.replace(/\s/g, "").toUpperCase();
|
|
2718
|
+
if (expected !== identity && expected !== domestic) throw new Error("fio_receiving_account_mismatch");
|
|
2719
|
+
let inserted = 0, skipped = 0;
|
|
2720
|
+
for (const raw of statement.transactions) {
|
|
2721
|
+
await renew();
|
|
2722
|
+
const mapped = mapFioTransaction(raw);
|
|
2723
|
+
if (!mapped || mapped.identity_kind !== "movement") throw new Error("fio_unverified_movement");
|
|
2724
|
+
if (mapped.currency !== String(statement.info?.currency ?? "").toUpperCase()) throw new Error("fio_statement_currency_mismatch");
|
|
2725
|
+
const result = await insertTransaction(db, { bank_account_id: account.id, payload: mapped });
|
|
2726
|
+
if (result.status === "inserted") inserted++;
|
|
2727
|
+
else skipped++;
|
|
2728
|
+
}
|
|
2729
|
+
await renew();
|
|
2730
|
+
await db.batch([
|
|
2731
|
+
db.prepare(`UPDATE bank_recovery_batches SET state='completed',completed_at=datetime('now') WHERE id=?`).bind(batch.id),
|
|
2732
|
+
db.prepare(`UPDATE bank_accounts SET api_last_success_at=datetime('now'),api_last_error=NULL,api_backfill_done=1,
|
|
2733
|
+
api_reconciled_through=?,api_pointer_initialized=1 WHERE id=?`).bind(batch.to_date, account.id)
|
|
2734
|
+
]);
|
|
2735
|
+
return { inserted, skipped };
|
|
2736
|
+
} finally {
|
|
2737
|
+
for (const key of held) await db.prepare(`UPDATE bank_poll_leases SET lease_until=datetime('now'),next_allowed_at=datetime('now','+30 seconds') WHERE credential_hash=? AND token=?`).bind(key, fence).run();
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2662
2741
|
// src/cloudflare.ts
|
|
2663
2742
|
function fioProxyConfig(env) {
|
|
2664
2743
|
const url2 = env.FIO_PROXY_URL?.trim();
|
|
@@ -2674,10 +2753,36 @@ function cfRoutingConfig(env) {
|
|
|
2674
2753
|
}
|
|
2675
2754
|
async function processEmail(rawStream, env, envelope) {
|
|
2676
2755
|
let bodyText;
|
|
2756
|
+
let spoolId;
|
|
2757
|
+
const reader = rawStream.getReader();
|
|
2758
|
+
const parts = [];
|
|
2759
|
+
let byteCount = 0;
|
|
2760
|
+
while (true) {
|
|
2761
|
+
const item = await reader.read();
|
|
2762
|
+
if (item.done) break;
|
|
2763
|
+
byteCount += item.value.byteLength;
|
|
2764
|
+
if (byteCount > 512 * 1024) {
|
|
2765
|
+
await reader.cancel();
|
|
2766
|
+
throw new Error("email_body_limit");
|
|
2767
|
+
}
|
|
2768
|
+
parts.push(item.value);
|
|
2769
|
+
}
|
|
2770
|
+
const rawArray = new Uint8Array(byteCount);
|
|
2771
|
+
let offset = 0;
|
|
2772
|
+
for (const part of parts) {
|
|
2773
|
+
rawArray.set(part, offset);
|
|
2774
|
+
offset += part.length;
|
|
2775
|
+
}
|
|
2776
|
+
const rawBytes = rawArray.buffer;
|
|
2777
|
+
let binary = "";
|
|
2778
|
+
for (let i = 0; i < rawArray.length; i += 8192) binary += String.fromCharCode(...rawArray.subarray(i, i + 8192));
|
|
2779
|
+
const bytesBase64 = btoa(binary);
|
|
2780
|
+
const rawMime = new TextDecoder().decode(rawBytes);
|
|
2781
|
+
let r2SpoolKey;
|
|
2677
2782
|
try {
|
|
2678
2783
|
let extracted;
|
|
2679
2784
|
try {
|
|
2680
|
-
extracted = await extractEmailBody(
|
|
2785
|
+
extracted = await extractEmailBody(new Blob([rawBytes]).stream());
|
|
2681
2786
|
bodyText = extracted.text;
|
|
2682
2787
|
} catch (err) {
|
|
2683
2788
|
const rawText = await new Response(rawStream).text().catch(() => "(failed to read raw)");
|
|
@@ -2697,31 +2802,59 @@ async function processEmail(rawStream, env, envelope) {
|
|
|
2697
2802
|
log("email_rejected_authentication", { reason });
|
|
2698
2803
|
return;
|
|
2699
2804
|
}
|
|
2805
|
+
if (env.AUTHENTICATED_EMAIL_SPOOL === "on") {
|
|
2806
|
+
if (!env.BACKUPS) throw new Error("authenticated_spool_binding_required");
|
|
2807
|
+
const encrypted = await encryptSecret(JSON.stringify({ bytesBase64, envelope }), env);
|
|
2808
|
+
r2SpoolKey = `authenticated-email-pending/${await sha256Hex2(`${identity.recipient}:${bytesBase64}`)}`;
|
|
2809
|
+
const existing = await env.BACKUPS.head(r2SpoolKey);
|
|
2810
|
+
if (!existing) await env.BACKUPS.put(r2SpoolKey, JSON.stringify(encrypted));
|
|
2811
|
+
}
|
|
2700
2812
|
const allowlist = parseAllowlist(env.SENDER_ALLOWLIST);
|
|
2701
2813
|
const pairingCode = extractPairingCode(identity.recipient);
|
|
2702
2814
|
const account = pairingCode ? await findBankAccountByPairingCode(env.DB, pairingCode) : null;
|
|
2815
|
+
const schema = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
|
|
2816
|
+
if (account && schema?.value === "11") {
|
|
2817
|
+
spoolId = await sha256Hex2(`${account.id}:${extracted.messageId ?? await sha256Hex2(rawMime)}`);
|
|
2818
|
+
const digest = await sha256Hex2(rawMime);
|
|
2819
|
+
const encrypted = await encryptSecret(JSON.stringify({ bytesBase64, envelope }), env);
|
|
2820
|
+
await env.DB.prepare(`INSERT INTO authenticated_email_spool(id,bank_account_id,body_sha256,cipher,key_version)
|
|
2821
|
+
VALUES(?,?,?,?,?) ON CONFLICT(id) DO NOTHING`).bind(spoolId, account.id, digest, encrypted.cipher, encrypted.keyVersion).run();
|
|
2822
|
+
const stored = await env.DB.prepare("SELECT body_sha256 FROM authenticated_email_spool WHERE id=?").bind(spoolId).first();
|
|
2823
|
+
if (stored?.body_sha256 !== digest) throw new Error("email_transport_body_conflict");
|
|
2824
|
+
}
|
|
2825
|
+
async function completeSpool() {
|
|
2826
|
+
if (spoolId) await env.DB.prepare("UPDATE authenticated_email_spool SET state='completed',completed_at=datetime('now') WHERE id=?").bind(spoolId).run();
|
|
2827
|
+
if (r2SpoolKey) await env.BACKUPS.delete(r2SpoolKey);
|
|
2828
|
+
}
|
|
2829
|
+
if (account && schema?.value === "11") {
|
|
2830
|
+
const enabled = await env.DB.prepare("SELECT ingest_enabled FROM bank_accounts WHERE id=?").bind(account.id).first();
|
|
2831
|
+
if (enabled?.ingest_enabled === 0) throw new Error("email_connection_paused");
|
|
2832
|
+
}
|
|
2703
2833
|
const outcome = classifyEmail(extracted, identity, account, allowlist);
|
|
2704
2834
|
if (outcome.received) {
|
|
2705
2835
|
log("email_received", { pairing_code: pairingCode, message_id: extracted.messageId ?? null });
|
|
2706
2836
|
await writeEvent(env.DB, { event_type: "email_received", bank_account_id: account.id, detail: { message_id: extracted.messageId ?? null } });
|
|
2707
2837
|
}
|
|
2708
2838
|
if (outcome.kind === "reject" || outcome.kind === "skip") {
|
|
2839
|
+
if (r2SpoolKey && (outcome.reason.startsWith("parse_failed") || outcome.reason.startsWith("unknown_") || outcome.reason.startsWith("no_pairing"))) throw new Error("authenticated_email_quarantined");
|
|
2709
2840
|
await insertParseLog(env.DB, {
|
|
2710
2841
|
bank_account_id: outcome.bankAccountId ?? null,
|
|
2711
2842
|
error_message: outcome.reason,
|
|
2712
2843
|
raw_data: sanitizeDiagnosticRaw(extracted.text),
|
|
2713
2844
|
external_id: extracted.messageId ?? null
|
|
2714
2845
|
});
|
|
2846
|
+
await completeSpool();
|
|
2715
2847
|
return;
|
|
2716
2848
|
}
|
|
2717
2849
|
const acct = outcome.account;
|
|
2850
|
+
if (!outcome.parsed.date) throw new Error("email_missing_bank_date");
|
|
2718
2851
|
let result;
|
|
2719
2852
|
try {
|
|
2720
2853
|
result = await insertTransaction(env.DB, {
|
|
2721
2854
|
bank_account_id: acct.id,
|
|
2722
2855
|
payload: {
|
|
2723
2856
|
...outcome.parsed,
|
|
2724
|
-
date: outcome.parsed.date
|
|
2857
|
+
date: outcome.parsed.date,
|
|
2725
2858
|
external_id: extracted.messageId ?? null
|
|
2726
2859
|
}
|
|
2727
2860
|
});
|
|
@@ -2732,17 +2865,19 @@ async function processEmail(rawStream, env, envelope) {
|
|
|
2732
2865
|
raw_data: sanitizeDiagnosticRaw(JSON.stringify({ ...outcome.parsed, external_id: extracted.messageId ?? null })),
|
|
2733
2866
|
external_id: extracted.messageId ?? null
|
|
2734
2867
|
});
|
|
2735
|
-
|
|
2868
|
+
throw new Error("email_db_insert_failed");
|
|
2736
2869
|
}
|
|
2737
2870
|
if (result.status === "skipped") {
|
|
2738
2871
|
log("tx_skipped", { reason: result.reason, bank_account_id: acct.id });
|
|
2739
2872
|
await writeEvent(env.DB, { event_type: "tx_skipped", bank_account_id: acct.id, detail: { reason: result.reason } });
|
|
2873
|
+
await completeSpool();
|
|
2740
2874
|
return;
|
|
2741
2875
|
}
|
|
2742
2876
|
const tx = result.transaction;
|
|
2743
2877
|
log("tx_inserted", { bank_account_id: acct.id, tx_id: tx.id, vs: tx.vs, amount_cents: tx.amount_cents, currency: tx.currency });
|
|
2744
2878
|
await writeEvent(env.DB, { event_type: "tx_inserted", bank_account_id: acct.id, detail: { tx_id: tx.id } });
|
|
2745
2879
|
await createWebhookDeliveryCoordinator(env).observeTransaction(tx.id);
|
|
2880
|
+
await completeSpool();
|
|
2746
2881
|
} catch (err) {
|
|
2747
2882
|
try {
|
|
2748
2883
|
await insertParseLog(env.DB, {
|
|
@@ -2752,6 +2887,7 @@ async function processEmail(rawStream, env, envelope) {
|
|
|
2752
2887
|
} catch {
|
|
2753
2888
|
}
|
|
2754
2889
|
logError("email_unhandled_exception", err, {});
|
|
2890
|
+
throw new Error("email_ingest_incomplete");
|
|
2755
2891
|
}
|
|
2756
2892
|
}
|
|
2757
2893
|
function sanitizeDiagnosticRaw(value) {
|
|
@@ -2793,6 +2929,7 @@ function yyyyMmDdDaysAgo(days) {
|
|
|
2793
2929
|
var API_POLL_CRON = "* * * * *";
|
|
2794
2930
|
var MAINTENANCE_CRON = "15 3 * * *";
|
|
2795
2931
|
var API_SYNC_LEASE_S = 30;
|
|
2932
|
+
var RECONCILIATION_INTERVAL_MINUTES = 5;
|
|
2796
2933
|
function scheduledCron(event) {
|
|
2797
2934
|
const cron = event.cron;
|
|
2798
2935
|
return typeof cron === "string" ? cron : null;
|
|
@@ -2805,6 +2942,14 @@ function shouldRunMaintenance(event) {
|
|
|
2805
2942
|
const cron = scheduledCron(event);
|
|
2806
2943
|
return cron === null || cron === MAINTENANCE_CRON;
|
|
2807
2944
|
}
|
|
2945
|
+
function shouldRunReconciliation(event) {
|
|
2946
|
+
const cron = scheduledCron(event);
|
|
2947
|
+
if (cron === null) return true;
|
|
2948
|
+
if (cron !== API_POLL_CRON) return false;
|
|
2949
|
+
const scheduledTime = event.scheduledTime;
|
|
2950
|
+
if (typeof scheduledTime !== "number" || !Number.isFinite(scheduledTime)) return true;
|
|
2951
|
+
return Math.floor(scheduledTime / 6e4) % RECONCILIATION_INTERVAL_MINUTES === 0;
|
|
2952
|
+
}
|
|
2808
2953
|
function apiSyncDelayS(env) {
|
|
2809
2954
|
return Math.max(30, apiMinIntervalS(env));
|
|
2810
2955
|
}
|
|
@@ -2814,6 +2959,12 @@ async function runBankApiSync(env, account) {
|
|
|
2814
2959
|
}
|
|
2815
2960
|
const isBackfill = !account.api_backfill_done;
|
|
2816
2961
|
try {
|
|
2962
|
+
const schema = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
|
|
2963
|
+
if (schema?.value === "11") {
|
|
2964
|
+
const result = await recoverBankAccount(env.DB, account, env, fioProxyConfig(env));
|
|
2965
|
+
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
|
+
}
|
|
2817
2968
|
const token = await decryptSecret(account.api_token_cipher, account.api_token_key_ver, env);
|
|
2818
2969
|
if (isBackfill && account.api_last_success_at === null) {
|
|
2819
2970
|
await markBankAccountApiFetchStarted(env.DB, account.id);
|
|
@@ -2856,7 +3007,7 @@ async function runBankApiSync(env, account) {
|
|
|
2856
3007
|
});
|
|
2857
3008
|
continue;
|
|
2858
3009
|
}
|
|
2859
|
-
if (mapped === null) {
|
|
3010
|
+
if (mapped === null || mapped.amount_cents <= 0) {
|
|
2860
3011
|
skippedOutgoing++;
|
|
2861
3012
|
await insertParseLog(env.DB, {
|
|
2862
3013
|
bank_account_id: account.id,
|
|
@@ -2914,14 +3065,7 @@ async function runBankApiSync(env, account) {
|
|
|
2914
3065
|
}
|
|
2915
3066
|
async function runDueBankApiSyncs(env) {
|
|
2916
3067
|
const accounts = await listDueApiFetchAccounts(env.DB, apiMinIntervalS(env));
|
|
2917
|
-
const processedTokenPrefixes = /* @__PURE__ */ new Set();
|
|
2918
3068
|
for (const account of accounts) {
|
|
2919
|
-
const tokenKey = account.api_token_prefix ?? `account:${account.id}`;
|
|
2920
|
-
if (processedTokenPrefixes.has(tokenKey)) {
|
|
2921
|
-
log("api_sync_skipped_duplicate_token_prefix", { bank_account_id: account.id, account_type: account.account_type });
|
|
2922
|
-
continue;
|
|
2923
|
-
}
|
|
2924
|
-
processedTokenPrefixes.add(tokenKey);
|
|
2925
3069
|
try {
|
|
2926
3070
|
const result = await runBankApiSync(env, account);
|
|
2927
3071
|
log("api_sync_completed", {
|
|
@@ -3210,6 +3354,11 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
|
|
|
3210
3354
|
if (input.fio_api_token !== void 0 && (input.account_type ?? "FIO") !== "FIO") {
|
|
3211
3355
|
return jsonResponse({ error: "fio_api_token_supported_only_for_fio" }, 400);
|
|
3212
3356
|
}
|
|
3357
|
+
const expanded = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
|
|
3358
|
+
if (input.ingest_enabled === false && expanded?.value !== "11") return jsonResponse({ error: "paused_ingest_requires_schema11" }, 409);
|
|
3359
|
+
const capability = expanded?.value === "11" ? await env.DB.prepare("SELECT event_version FROM webhook_consumers WHERE app_id=?").bind(input.owner_app_id).first() : null;
|
|
3360
|
+
if (capability?.event_version === "2" && (input.ingest_mode === "email" || input.ingest_mode === "both") && (env.AUTHENTICATED_EMAIL_SPOOL !== "on" || !env.BACKUPS)) return jsonResponse({ error: "durable_email_capability_not_configured" }, 503);
|
|
3361
|
+
if (capability?.event_version === "2" && input.ingest_mode === "both") return jsonResponse({ error: "both_requires_verified_correlation" }, 409);
|
|
3213
3362
|
const pairingCode = await generateUniquePairingCode(env.DB);
|
|
3214
3363
|
const ingestMode = input.ingest_mode ?? (input.fio_api_token ? "api" : "email");
|
|
3215
3364
|
const createEmailRoute = ingestMode === "email" || ingestMode === "both";
|
|
@@ -3233,6 +3382,11 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
|
|
|
3233
3382
|
} catch (err) {
|
|
3234
3383
|
throw err;
|
|
3235
3384
|
}
|
|
3385
|
+
const schemaVersion = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
|
|
3386
|
+
if (input.ingest_enabled === false) {
|
|
3387
|
+
if (schemaVersion?.value !== "11") throw new Error("paused_ingest_requires_schema11");
|
|
3388
|
+
await env.DB.prepare("UPDATE bank_accounts SET ingest_enabled=0,api_fetch_enabled=0 WHERE id=?").bind(account.id).run();
|
|
3389
|
+
}
|
|
3236
3390
|
if (outboxId !== null) {
|
|
3237
3391
|
const cfRuleId = await provisionRoute(env.DB, cfRoutingConfig(env), {
|
|
3238
3392
|
outboxId,
|
|
@@ -3260,6 +3414,7 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
|
|
|
3260
3414
|
if (existing instanceof Response) return existing;
|
|
3261
3415
|
const input = parseOr400(UpdateBankAccountSchema, bodyText);
|
|
3262
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);
|
|
3263
3418
|
const updated = await updateBankAccount(env.DB, id, input);
|
|
3264
3419
|
if (!updated) return jsonResponse({ error: "not_found" }, 404);
|
|
3265
3420
|
return jsonResponse(updated);
|
|
@@ -3289,6 +3444,25 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
|
|
|
3289
3444
|
if (!deleted) return jsonResponse({ error: "not_found" }, 404);
|
|
3290
3445
|
return new Response(null, { status: 204 });
|
|
3291
3446
|
}
|
|
3447
|
+
const stateMatch = url2.pathname.match(/^\/bank-accounts\/(\d+)\/ingest-state$/);
|
|
3448
|
+
if (stateMatch && (req.method === "GET" || req.method === "PUT")) {
|
|
3449
|
+
const id = Number(stateMatch[1]);
|
|
3450
|
+
const existing = await requireOwnedAccount(env, id, authCtx);
|
|
3451
|
+
if (existing instanceof Response) return existing;
|
|
3452
|
+
const schema = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
|
|
3453
|
+
if (schema?.value !== "11") return jsonResponse({ error: "schema11_required" }, 409);
|
|
3454
|
+
if (req.method === "PUT") {
|
|
3455
|
+
let input;
|
|
3456
|
+
try {
|
|
3457
|
+
input = JSON.parse(bodyText);
|
|
3458
|
+
} catch {
|
|
3459
|
+
return jsonResponse({ error: "invalid_json" }, 400);
|
|
3460
|
+
}
|
|
3461
|
+
if (typeof input.enabled !== "boolean") return jsonResponse({ error: "enabled_required" }, 400);
|
|
3462
|
+
await env.DB.prepare("UPDATE bank_accounts SET ingest_enabled=? WHERE id=?").bind(input.enabled ? 1 : 0, id).run();
|
|
3463
|
+
}
|
|
3464
|
+
return jsonResponse(await env.DB.prepare("SELECT ingest_enabled,api_token_hash FROM bank_accounts WHERE id=?").bind(id).first());
|
|
3465
|
+
}
|
|
3292
3466
|
const fioTokenMatch = url2.pathname.match(/^\/bank-accounts\/(\d+)\/fio-token$/);
|
|
3293
3467
|
if (fioTokenMatch && req.method === "PUT") {
|
|
3294
3468
|
const id = Number(fioTokenMatch[1]);
|
|
@@ -3311,6 +3485,8 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
|
|
|
3311
3485
|
ingest_mode: input.ingest_mode ?? (existing.ingest_mode === "email" ? "api" : existing.ingest_mode)
|
|
3312
3486
|
});
|
|
3313
3487
|
if (!updated2) return jsonResponse({ error: "not_found" }, 404);
|
|
3488
|
+
const schemaVersion = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
|
|
3489
|
+
if (schemaVersion?.value === "11") await env.DB.prepare("UPDATE bank_accounts SET api_token_hash=? WHERE id=?").bind(await sha256Hex2(rawToken), id).run();
|
|
3314
3490
|
log("bank_account_api_token_updated", { bank_account_id: id, account_type: existing.account_type, api_token_prefix: updated2.api_token_prefix });
|
|
3315
3491
|
return jsonResponse(updated2);
|
|
3316
3492
|
}
|
|
@@ -3395,6 +3571,8 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
|
|
|
3395
3571
|
if (authCtx.type === "tenant") return forbidden();
|
|
3396
3572
|
const input = parseOr400(CreateConsumerSchema, bodyText);
|
|
3397
3573
|
if (input instanceof Response) return input;
|
|
3574
|
+
const schemaVersion = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
|
|
3575
|
+
if (input.event_version === "2" && schemaVersion?.value !== "11") return jsonResponse({ error: "v2_requires_schema11" }, 409);
|
|
3398
3576
|
let callbackUrl;
|
|
3399
3577
|
try {
|
|
3400
3578
|
callbackUrl = validateCallbackUrl(input.callback_url, { environment: env.ENV, allowlist: env.CALLBACK_HOST_ALLOWLIST });
|
|
@@ -3408,6 +3586,7 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
|
|
|
3408
3586
|
const secretCipher = await webhookEncrypt(plain, env.WEBHOOK_KEK);
|
|
3409
3587
|
const consumer = await createConsumer(env.DB, {
|
|
3410
3588
|
app_id: input.app_id,
|
|
3589
|
+
event_version: input.event_version ?? "1",
|
|
3411
3590
|
callback_url: callbackUrl,
|
|
3412
3591
|
secret_cipher: secretCipher,
|
|
3413
3592
|
secret_hash: secretHash,
|
|
@@ -3557,6 +3736,34 @@ async function dispatch(url2, req, env, authCtx, bodyText) {
|
|
|
3557
3736
|
const next_cursor = entries.length === limit ? entries[entries.length - 1].id : null;
|
|
3558
3737
|
return jsonResponse({ jobs: entries, next_cursor });
|
|
3559
3738
|
}
|
|
3739
|
+
const exportMatch = url2.pathname.match(/^\/bank-accounts\/(\d+)\/transaction-export$/);
|
|
3740
|
+
if (exportMatch && req.method === "GET") {
|
|
3741
|
+
const id = Number(exportMatch[1]);
|
|
3742
|
+
const account = await requireOwnedAccount(env, id, authCtx);
|
|
3743
|
+
if (account instanceof Response) return account;
|
|
3744
|
+
const maximum = await env.DB.prepare("SELECT COALESCE(MAX(id),0) AS id FROM transactions WHERE bank_account_id=?").bind(id).first();
|
|
3745
|
+
const high = Number(url2.searchParams.get("high_water") ?? maximum?.id ?? 0);
|
|
3746
|
+
const cursor = Number(url2.searchParams.get("cursor") ?? 0);
|
|
3747
|
+
const limit = Number(url2.searchParams.get("limit") ?? 200);
|
|
3748
|
+
if (!Number.isSafeInteger(high) || high < 0 || !Number.isSafeInteger(cursor) || cursor < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 200) return jsonResponse({ error: "invalid_cursor" }, 400);
|
|
3749
|
+
const rows = await env.DB.prepare("SELECT * FROM transactions WHERE bank_account_id=? AND id>? AND id<=? ORDER BY id LIMIT ?").bind(id, cursor, high, limit).all();
|
|
3750
|
+
return jsonResponse({ bank_account_id: id, high_water: high, cursor: rows.results.at(-1)?.id ?? cursor, complete: rows.results.length < limit, transactions: rows.results });
|
|
3751
|
+
}
|
|
3752
|
+
if (url2.pathname === "/admin/cutover-reconcile" && req.method === "POST") {
|
|
3753
|
+
if (authCtx.type !== "admin") return forbidden();
|
|
3754
|
+
let input;
|
|
3755
|
+
try {
|
|
3756
|
+
input = JSON.parse(bodyText);
|
|
3757
|
+
} catch {
|
|
3758
|
+
return jsonResponse({ error: "invalid_json" }, 400);
|
|
3759
|
+
}
|
|
3760
|
+
if (typeof input.manifest_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(input.manifest_sha256) || typeof input.consumer_app_id !== "string" || ![input.account_id, input.from_id, input.through_id].every((n) => Number.isSafeInteger(n) && n > 0) || input.through_id < input.from_id) return jsonResponse({ error: "invalid_manifest_window" }, 400);
|
|
3761
|
+
const account = await findBankAccountById(env.DB, input.account_id);
|
|
3762
|
+
if (account?.owner_app_id !== input.consumer_app_id) return forbidden();
|
|
3763
|
+
const created = await ensureDeliveryJobs(env.DB, void 0, { accountId: input.account_id, consumerAppId: input.consumer_app_id, fromId: input.from_id, throughId: input.through_id });
|
|
3764
|
+
await writeEvent(env.DB, { event_type: "cutover_reconcile", bank_account_id: input.account_id, detail: { consumer: input.consumer_app_id, manifest_sha256: input.manifest_sha256, from_id: input.from_id, through_id: input.through_id, created } });
|
|
3765
|
+
return jsonResponse({ created });
|
|
3766
|
+
}
|
|
3560
3767
|
if (url2.pathname === "/transactions" && req.method === "GET") {
|
|
3561
3768
|
const since = url2.searchParams.get("since") ?? (/* @__PURE__ */ new Date(0)).toISOString();
|
|
3562
3769
|
const limitStr = url2.searchParams.get("limit");
|
|
@@ -3672,6 +3879,7 @@ var cloudflare_default = {
|
|
|
3672
3879
|
await assertSchemaVersion(env.DB);
|
|
3673
3880
|
const runPolling = shouldRunApiPolling(event);
|
|
3674
3881
|
const runMaintenance = shouldRunMaintenance(event);
|
|
3882
|
+
const runReconciliation = runPolling && shouldRunReconciliation(event);
|
|
3675
3883
|
if (runMaintenance) {
|
|
3676
3884
|
try {
|
|
3677
3885
|
const result = await pruneRetention(env.DB);
|
|
@@ -3696,6 +3904,39 @@ var cloudflare_default = {
|
|
|
3696
3904
|
} catch (err) {
|
|
3697
3905
|
logError("outbox_process_failed", err, {});
|
|
3698
3906
|
}
|
|
3907
|
+
}
|
|
3908
|
+
if (runReconciliation) {
|
|
3909
|
+
if (env.AUTHENTICATED_EMAIL_SPOOL === "on" && env.BACKUPS) {
|
|
3910
|
+
const cursor = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='email_spool_cursor'").first();
|
|
3911
|
+
const page = await env.BACKUPS.list({ prefix: "authenticated-email-pending/", limit: 10, ...cursor?.value ? { cursor: cursor.value } : {} });
|
|
3912
|
+
for (const object2 of page.objects) {
|
|
3913
|
+
try {
|
|
3914
|
+
const stored = await env.BACKUPS.get(object2.key);
|
|
3915
|
+
if (!stored) continue;
|
|
3916
|
+
const encrypted = JSON.parse(await stored.text());
|
|
3917
|
+
const message = JSON.parse(await decryptSecret(encrypted.cipher, encrypted.keyVersion, env));
|
|
3918
|
+
const bytes = Uint8Array.from(atob(message.bytesBase64), (c) => c.charCodeAt(0));
|
|
3919
|
+
await processEmail(new Blob([bytes]).stream(), env, message.envelope);
|
|
3920
|
+
} catch {
|
|
3921
|
+
log("authenticated_email_recovery_pending", { spool_key: object2.key });
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
await env.DB.prepare("INSERT INTO schema_meta(key,value) VALUES('email_spool_cursor',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(page.truncated ? page.cursor : "").run();
|
|
3925
|
+
}
|
|
3926
|
+
const schema = await env.DB.prepare("SELECT value FROM schema_meta WHERE key='version'").first();
|
|
3927
|
+
if (schema?.value === "11") {
|
|
3928
|
+
const pending = await env.DB.prepare("SELECT id,cipher,key_version FROM authenticated_email_spool WHERE state='pending' ORDER BY attempts,created_at LIMIT 10").all();
|
|
3929
|
+
for (const row of pending.results) {
|
|
3930
|
+
try {
|
|
3931
|
+
await env.DB.prepare("UPDATE authenticated_email_spool SET attempts=attempts+1 WHERE id=?").bind(row.id).run();
|
|
3932
|
+
const message = JSON.parse(await decryptSecret(row.cipher, row.key_version, env));
|
|
3933
|
+
const restored = Uint8Array.from(atob(message.bytesBase64), (c) => c.charCodeAt(0));
|
|
3934
|
+
await processEmail(new Blob([restored]).stream(), env, message.envelope);
|
|
3935
|
+
} catch {
|
|
3936
|
+
log("email_recovery_pending", { spool_id: row.id });
|
|
3937
|
+
}
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3699
3940
|
try {
|
|
3700
3941
|
const swept = await createWebhookDeliveryCoordinator(env).sweep();
|
|
3701
3942
|
if (swept.created > 0 || swept.considered > 0) {
|
|
@@ -3723,7 +3964,7 @@ var cloudflare_default = {
|
|
|
3723
3964
|
logError("api_sync_tick_failed", err, {});
|
|
3724
3965
|
}
|
|
3725
3966
|
}
|
|
3726
|
-
if (
|
|
3967
|
+
if (runReconciliation && env.ALERT_WEBHOOK_URL) {
|
|
3727
3968
|
try {
|
|
3728
3969
|
const alert = await runAlerterTick(env.DB, {
|
|
3729
3970
|
webhookUrl: env.ALERT_WEBHOOK_URL,
|
|
@@ -3736,7 +3977,7 @@ var cloudflare_default = {
|
|
|
3736
3977
|
logError("alerter_tick_failed", err, {});
|
|
3737
3978
|
}
|
|
3738
3979
|
}
|
|
3739
|
-
if (
|
|
3980
|
+
if (runReconciliation && env.ALERT_WEBHOOK_URL) {
|
|
3740
3981
|
try {
|
|
3741
3982
|
const service = env.BANKSYNC_DOMAIN ?? "banksync";
|
|
3742
3983
|
await detectStalledIncidents(env.DB, service);
|