BillTracker/services/userDbImportService.cts

886 lines
26 KiB
TypeScript
Raw Normal View History

// @ts-nocheck — INTENTIONAL (not debt): reads arbitrary user SQLite exports via dynamic table introspection (inherently `unknown` shapes). Keep.
2026-05-03 19:51:57 -05:00
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const { getDb } = require('../db/database.cts');
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService.cts');
const { toCents } = require('../utils/money.mts');
2026-05-03 19:51:57 -05:00
const MAX_SQLITE_BYTES = 50 * 1024 * 1024;
const SESSION_TTL_HOURS = 24;
const REQUIRED_TABLES = [
'export_metadata',
'categories',
'bills',
'payments',
'monthly_bill_state',
];
2026-05-03 19:51:57 -05:00
const VALID_BILLING_CYCLES = new Set(['monthly', 'quarterly', 'annually', 'irregular']);
const VALID_AUTODRAFT = new Set(['none', 'pending', 'assumed_paid', 'confirmed']);
const VALID_PAYMENT_SOURCES = new Set(['manual', 'file_import', 'provider_sync']);
2026-05-03 19:51:57 -05:00
function importError(status, message, code, details = []) {
const err = new Error(message);
err.status = status;
err.code = code;
err.details = details;
return err;
}
function sanitizeFilename(value) {
return value
? String(value)
.replace(/[^a-zA-Z0-9._\-\s]/g, '')
.trim()
.slice(0, 255) || null
2026-05-03 19:51:57 -05:00
: null;
}
function assertSqliteBuffer(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw importError(400, 'SQLite export file is required.', 'USER_DB_IMPORT_FILE_REQUIRED');
}
if (buffer.length > MAX_SQLITE_BYTES) {
throw importError(
400,
'SQLite export file is too large. Maximum size is 50 MB.',
'USER_DB_IMPORT_FILE_TOO_LARGE',
);
2026-05-03 19:51:57 -05:00
}
if (buffer.length < 16 || buffer.subarray(0, 16).toString('binary') !== 'SQLite format 3\u0000') {
throw importError(
400,
'File is not a valid SQLite database export.',
'USER_DB_IMPORT_INVALID_SQLITE',
);
2026-05-03 19:51:57 -05:00
}
}
function withTempSqlite(buffer, fn) {
const file = path.join(
os.tmpdir(),
`bill-tracker-user-import-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.sqlite`,
);
2026-05-03 19:51:57 -05:00
fs.writeFileSync(file, buffer, { mode: 0o600 });
let src = null;
try {
src = new Database(file, { readonly: true, fileMustExist: true });
src.prepare('SELECT name FROM sqlite_master LIMIT 1').get();
return fn(src);
} finally {
try {
src?.close();
} catch {}
try {
fs.unlinkSync(file);
} catch {}
2026-05-03 19:51:57 -05:00
}
}
function tableNames(db) {
return new Set(
db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all()
.map((r) => r.name),
);
2026-05-03 19:51:57 -05:00
}
const IMPORT_TABLES = new Set([
'categories',
'bills',
'payments',
'monthly_bill_state',
'monthly_starting_amounts',
'notes',
]);
2026-05-03 19:51:57 -05:00
function tableColumns(db, table) {
if (!IMPORT_TABLES.has(table)) throw new Error(`Import: unknown table '${table}'`);
return new Set(
db
.prepare(`PRAGMA table_info(${table})`)
.all()
.map((c) => c.name),
);
2026-05-03 19:51:57 -05:00
}
function selectKnown(db, table, columns) {
const cols = tableColumns(db, table);
const selected = columns.filter((c) => cols.has(c));
2026-05-03 19:51:57 -05:00
if (!selected.length) return [];
return db.prepare(`SELECT ${selected.join(', ')} FROM ${table}`).all();
}
function toInt(value, fallback = null) {
if (value === undefined || value === null || value === '') return fallback;
const n = Number(value);
return Number.isInteger(n) ? n : fallback;
}
function toNumber(value, fallback = null) {
if (value === undefined || value === null || value === '') return fallback;
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function cleanText(value, max = 1000) {
if (value === undefined || value === null) return null;
const s = String(value).trim();
return s ? s.slice(0, max) : null;
}
function cleanDate(value) {
const s = cleanText(value, 32);
return /^\d{4}-\d{2}-\d{2}$/.test(s || '') ? s : null;
}
function normalizeName(value) {
return String(value || '')
.trim()
.toLowerCase();
2026-05-03 19:51:57 -05:00
}
function parseMetadata(src) {
const row = src.prepare("SELECT value FROM export_metadata WHERE key = 'metadata_json'").get();
if (!row?.value) {
throw importError(
400,
'SQLite file is not a Bill Tracker user data export.',
'USER_DB_IMPORT_NOT_USER_EXPORT',
);
2026-05-03 19:51:57 -05:00
}
let metadata;
try {
metadata = JSON.parse(row.value);
} catch {
throw importError(
400,
'SQLite export metadata could not be read.',
'USER_DB_IMPORT_BAD_METADATA',
);
2026-05-03 19:51:57 -05:00
}
if (metadata?.export_type !== 'user_data') {
throw importError(
400,
'SQLite file is not a user data export created by this app.',
'USER_DB_IMPORT_NOT_USER_EXPORT',
);
2026-05-03 19:51:57 -05:00
}
return metadata;
}
function sanitizeCategory(row) {
const name = cleanText(row.name, 120);
if (!name) return null;
return {
old_id: toInt(row.id),
name,
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
};
}
/**
* Convert a money value from a source export to integer cents.
* Pre-v1.03 exports store dollars (REAL); post-v1.03 exports already store
* integer cents. Applying toCents() to a cents value would multiply it ×100,
* so the caller detects the source's unit via its schema_migrations table.
*/
function importMoney(value, sourceIsCents) {
if (value === null || value === undefined) return null;
return sourceIsCents ? Math.round(Number(value)) : toCents(value);
}
/** True when the source DB already stores money in integer cents (v1.03+). */
function sourceUsesCents(src) {
try {
if (!tableNames(src).has('schema_migrations')) return false;
return !!src.prepare("SELECT 1 FROM schema_migrations WHERE version = 'v1.03'").get();
} catch {
return false;
}
}
function sanitizeBill(row, sourceIsCents) {
2026-05-03 19:51:57 -05:00
const name = cleanText(row.name, 160);
const dueDay = toInt(row.due_day);
if (!name || dueDay < 1 || dueDay > 31) return null;
const interestRate = toNumber(row.interest_rate, null);
const cycleType = row.cycle_type
? String(row.cycle_type).trim().toLowerCase()
: cycleTypeFromBillingCycle(row.billing_cycle);
const normalizedCycleType = ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'].includes(
cycleType,
)
? cycleType
: 'monthly';
2026-05-03 19:51:57 -05:00
return {
old_id: toInt(row.id),
name,
category_id: toInt(row.category_id),
due_day: dueDay,
override_due_date: cleanText(row.override_due_date, 32),
bucket: dueDay <= 14 ? '1st' : '15th',
expected_amount: importMoney(Math.max(0, toNumber(row.expected_amount, 0) ?? 0), sourceIsCents),
interest_rate:
interestRate == null || interestRate < 0 || interestRate > 100 ? null : interestRate,
billing_cycle: VALID_BILLING_CYCLES.has(row.billing_cycle)
? row.billing_cycle
: billingCycleForCycleType(normalizedCycleType),
cycle_type: normalizedCycleType,
cycle_day: cleanText(row.cycle_day, 32),
2026-05-03 19:51:57 -05:00
autopay_enabled: toInt(row.autopay_enabled, 0) ? 1 : 0,
autodraft_status: VALID_AUTODRAFT.has(row.autodraft_status) ? row.autodraft_status : 'none',
website: cleanText(row.website, 500),
username: cleanText(row.username, 255),
account_info: cleanText(row.account_info, 500),
has_2fa: toInt(row.has_2fa, 0) ? 1 : 0,
active: row.active === undefined ? 1 : toInt(row.active, 1) ? 1 : 0,
2026-05-03 19:51:57 -05:00
notes: cleanText(row.notes, 2000),
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
};
}
function sanitizePayment(row, validBillIds, sourceIsCents) {
2026-05-03 19:51:57 -05:00
const billId = toInt(row.bill_id);
const amount = toNumber(row.amount);
const paidDate = cleanDate(row.paid_date);
2026-05-16 20:26:09 -05:00
const paymentSource = cleanText(row.payment_source, 64) || 'manual';
if (!billId || !validBillIds.has(billId) || amount == null || amount < 0 || !paidDate)
return null;
2026-05-03 19:51:57 -05:00
return {
old_id: toInt(row.id),
bill_id: billId,
amount: importMoney(amount, sourceIsCents),
2026-05-03 19:51:57 -05:00
paid_date: paidDate,
method: cleanText(row.method, 120),
notes: cleanText(row.notes, 2000),
2026-05-16 20:26:09 -05:00
payment_source: VALID_PAYMENT_SOURCES.has(paymentSource) ? paymentSource : 'manual',
transaction_id: null,
2026-05-03 19:51:57 -05:00
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
};
}
function sanitizeMonthlyState(row, validBillIds, sourceIsCents) {
2026-05-03 19:51:57 -05:00
const billId = toInt(row.bill_id);
const year = toInt(row.year);
const month = toInt(row.month);
if (!billId || !validBillIds.has(billId) || year < 2000 || year > 2100 || month < 1 || month > 12)
return null;
2026-05-03 19:51:57 -05:00
const actual = toNumber(row.actual_amount, null);
return {
old_id: toInt(row.id),
bill_id: billId,
year,
month,
actual_amount: actual == null || actual < 0 ? null : importMoney(actual, sourceIsCents),
2026-05-03 19:51:57 -05:00
notes: cleanText(row.notes, 2000),
is_skipped: toInt(row.is_skipped, 0) ? 1 : 0,
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
};
}
function sanitizeMonthlyStartingAmounts(row, sourceIsCents) {
2026-05-04 20:12:57 -05:00
const year = toInt(row.year);
const month = toInt(row.month);
if (year < 2000 || year > 2100 || month < 1 || month > 12) return null;
return {
old_id: toInt(row.id),
year,
month,
first_amount: importMoney(Math.max(0, toNumber(row.first_amount, 0) ?? 0), sourceIsCents),
fifteenth_amount: importMoney(
Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0),
sourceIsCents,
),
other_amount: importMoney(Math.max(0, toNumber(row.other_amount, 0) ?? 0), sourceIsCents),
2026-05-04 20:12:57 -05:00
notes: cleanText(row.notes, 2000),
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
};
}
2026-05-03 19:51:57 -05:00
function readExportData(src) {
const names = tableNames(src);
const missing = REQUIRED_TABLES.filter((t) => !names.has(t));
2026-05-03 19:51:57 -05:00
if (missing.length) {
throw importError(
400,
'SQLite file is not a supported Bill Tracker user export.',
'USER_DB_IMPORT_UNSUPPORTED_SCHEMA',
missing.map((table) => ({
table,
message: 'Required user export table is missing',
})),
);
2026-05-03 19:51:57 -05:00
}
const metadata = parseMetadata(src);
// Pre-v1.03 exports store money in dollars; v1.03+ exports store integer cents.
const sourceIsCents = sourceUsesCents(src);
2026-05-03 19:51:57 -05:00
const categories = selectKnown(src, 'categories', ['id', 'name', 'created_at', 'updated_at'])
.map(sanitizeCategory)
.filter(Boolean);
2026-05-03 19:51:57 -05:00
const bills = selectKnown(src, 'bills', [
'id',
'name',
'category_id',
'due_day',
'override_due_date',
'bucket',
'expected_amount',
'interest_rate',
'billing_cycle',
'autopay_enabled',
'autodraft_status',
'website',
'username',
'account_info',
'has_2fa',
'active',
'notes',
'created_at',
'updated_at',
])
.map((row) => sanitizeBill(row, sourceIsCents))
.filter(Boolean);
const validBillIds = new Set(bills.map((b) => b.old_id).filter(Boolean));
2026-05-16 20:26:09 -05:00
const payments = selectKnown(src, 'payments', [
'id',
'bill_id',
'amount',
'paid_date',
'method',
'notes',
'payment_source',
'transaction_id',
'created_at',
'updated_at',
])
.map((row) => sanitizePayment(row, validBillIds, sourceIsCents))
.filter(Boolean);
const monthlyState = selectKnown(src, 'monthly_bill_state', [
'id',
'bill_id',
'year',
'month',
'actual_amount',
'notes',
'is_skipped',
'created_at',
'updated_at',
2026-05-16 20:26:09 -05:00
])
.map((row) => sanitizeMonthlyState(row, validBillIds, sourceIsCents))
.filter(Boolean);
2026-05-04 20:12:57 -05:00
const monthlyStartingAmounts = names.has('monthly_starting_amounts')
? selectKnown(src, 'monthly_starting_amounts', [
'id',
'year',
'month',
'first_amount',
'fifteenth_amount',
'other_amount',
'notes',
'created_at',
'updated_at',
])
.map((row) => sanitizeMonthlyStartingAmounts(row, sourceIsCents))
.filter(Boolean)
2026-05-04 20:12:57 -05:00
: [];
2026-05-03 19:51:57 -05:00
const notes = names.has('notes')
? selectKnown(src, 'notes', [
'type',
'bill_id',
'payment_id',
'monthly_state_id',
'year',
'month',
'notes',
])
.map((n) => ({ ...n, notes: cleanText(n.notes, 2000) }))
.filter((n) => n.notes)
2026-05-03 19:51:57 -05:00
: [];
return {
metadata,
categories,
bills,
payments,
monthly_bill_state: monthlyState,
monthly_starting_amounts: monthlyStartingAmounts,
notes,
};
2026-05-03 19:51:57 -05:00
}
function existingLookups(db, userId) {
const categories = db.prepare('SELECT id, name FROM categories WHERE user_id = ?').all(userId);
const bills = db.prepare('SELECT id, name, due_day FROM bills WHERE user_id = ?').all(userId);
return {
categoryByName: new Map(categories.map((c) => [normalizeName(c.name), c])),
billByKey: new Map(bills.map((b) => [`${normalizeName(b.name)}|${b.due_day}`, b])),
2026-05-03 19:51:57 -05:00
};
}
function buildPreview(userId, data, originalFilename) {
const db = getDb();
const lookups = existingLookups(db, userId);
const categoryPlan = data.categories.map((c) => {
2026-05-03 19:51:57 -05:00
const existing = lookups.categoryByName.get(normalizeName(c.name));
return {
old_id: c.old_id,
name: c.name,
action: existing ? 'skip' : 'create',
existing_id: existing?.id ?? null,
reason: existing
? 'Category already exists for this user'
: 'Category is missing for this user',
2026-05-03 19:51:57 -05:00
};
});
const categoryMap = new Map(categoryPlan.map((c) => [c.old_id, c]));
const billPlan = data.bills.map((b) => {
2026-05-03 19:51:57 -05:00
const existing = lookups.billByKey.get(`${normalizeName(b.name)}|${b.due_day}`);
const category = categoryMap.get(b.category_id);
return {
old_id: b.old_id,
name: b.name,
due_day: b.due_day,
category_name: category?.name ?? null,
action: existing ? 'skip' : 'create',
existing_id: existing?.id ?? null,
reason: existing
? 'Bill with same name and due day already exists for this user'
: 'Bill is missing for this user',
2026-05-03 19:51:57 -05:00
};
});
const billPlanByOldId = new Map(billPlan.map((b) => [b.old_id, b]));
const paymentPlan = data.payments.map((p) => ({
2026-05-03 19:51:57 -05:00
old_id: p.old_id,
bill_old_id: p.bill_id,
action: billPlanByOldId.has(p.bill_id) ? 'create_or_skip_duplicate' : 'conflict',
reason: billPlanByOldId.has(p.bill_id)
? 'Will import if no duplicate payment exists'
: 'Referenced bill is not present in export',
2026-05-03 19:51:57 -05:00
}));
const monthlyPlan = data.monthly_bill_state.map((m) => ({
2026-05-03 19:51:57 -05:00
old_id: m.old_id,
bill_old_id: m.bill_id,
year: m.year,
month: m.month,
action: billPlanByOldId.has(m.bill_id) ? 'create_or_skip_duplicate' : 'conflict',
reason: billPlanByOldId.has(m.bill_id)
? 'Will import if monthly state is missing'
: 'Referenced bill is not present in export',
2026-05-03 19:51:57 -05:00
}));
const monthlyStartingAmountsPlan = data.monthly_starting_amounts.map((m) => ({
2026-05-04 20:12:57 -05:00
old_id: m.old_id,
year: m.year,
month: m.month,
action: 'create_or_skip_duplicate',
reason: 'Will import if monthly starting amounts are missing',
}));
2026-05-03 19:51:57 -05:00
const summary = {
categories: {
total: data.categories.length,
create: categoryPlan.filter((x) => x.action === 'create').length,
skip: categoryPlan.filter((x) => x.action === 'skip').length,
2026-05-03 19:51:57 -05:00
conflict: 0,
},
bills: {
total: data.bills.length,
create: billPlan.filter((x) => x.action === 'create').length,
skip: billPlan.filter((x) => x.action === 'skip').length,
2026-05-03 19:51:57 -05:00
conflict: 0,
},
payments: {
total: data.payments.length,
create: data.payments.length,
skip: 0,
conflict: paymentPlan.filter((x) => x.action === 'conflict').length,
2026-05-03 19:51:57 -05:00
},
monthly_bill_state: {
total: data.monthly_bill_state.length,
create: data.monthly_bill_state.length,
skip: 0,
conflict: monthlyPlan.filter((x) => x.action === 'conflict').length,
2026-05-03 19:51:57 -05:00
},
2026-05-04 20:12:57 -05:00
monthly_starting_amounts: {
total: data.monthly_starting_amounts.length,
create: data.monthly_starting_amounts.length,
skip: 0,
conflict: 0,
},
2026-05-03 19:51:57 -05:00
notes: {
total: data.notes.length,
create: 0,
skip: data.notes.length,
conflict: 0,
},
};
const warnings = [
'Conflicts and duplicates are skipped by default.',
'This import does not restore users, passwords, sessions, admin settings, SMTP credentials, backups, or server paths.',
];
return {
import_type: 'user_db',
source_filename: originalFilename,
metadata: data.metadata,
counts: {
bills: data.bills.length,
categories: data.categories.length,
payments: data.payments.length,
monthly_bill_state: data.monthly_bill_state.length,
2026-05-04 20:12:57 -05:00
monthly_starting_amounts: data.monthly_starting_amounts.length,
2026-05-03 19:51:57 -05:00
notes: data.notes.length,
},
summary,
proposed_actions: {
categories: categoryPlan.slice(0, 100),
bills: billPlan.slice(0, 100),
payments: paymentPlan.slice(0, 100),
monthly_bill_state: monthlyPlan.slice(0, 100),
2026-05-04 20:12:57 -05:00
monthly_starting_amounts: monthlyStartingAmountsPlan.slice(0, 100),
2026-05-03 19:51:57 -05:00
},
warnings,
};
}
function saveSession(db, userId, sessionData) {
const id = crypto.randomUUID();
const now = new Date();
const expires = new Date(now.getTime() + SESSION_TTL_HOURS * 60 * 60 * 1000);
db.prepare(
`
2026-05-03 19:51:57 -05:00
INSERT INTO import_sessions (id, user_id, created_at, expires_at, preview_json)
VALUES (?, ?, ?, ?, ?)
`,
).run(id, userId, now.toISOString(), expires.toISOString(), JSON.stringify(sessionData));
2026-05-03 19:51:57 -05:00
return id;
}
function loadSession(db, userId, importSessionId) {
const row = db
.prepare(
`
2026-05-03 19:51:57 -05:00
SELECT preview_json FROM import_sessions
WHERE id = ? AND user_id = ? AND expires_at > ?
`,
)
.get(importSessionId, userId, new Date().toISOString());
2026-05-03 19:51:57 -05:00
if (!row) {
throw importError(
400,
'Import session is invalid or expired. Preview the SQLite export again.',
'USER_DB_IMPORT_SESSION_INVALID',
);
2026-05-03 19:51:57 -05:00
}
const data = JSON.parse(row.preview_json);
if (data.import_type !== 'user_db') {
throw importError(
400,
'Import session is not a SQLite user export preview.',
'USER_DB_IMPORT_SESSION_TYPE',
);
2026-05-03 19:51:57 -05:00
}
return data;
}
async function previewUserDbImport(userId, buffer, options = {}) {
assertSqliteBuffer(buffer);
const originalFilename = sanitizeFilename(options.original_filename);
return withTempSqlite(buffer, (src) => {
const data = readExportData(src);
const preview = buildPreview(userId, data, originalFilename);
const db = getDb();
const importSessionId = saveSession(db, userId, {
import_type: 'user_db',
source_filename: originalFilename,
metadata: data.metadata,
data,
preview,
});
return {
import_session_id: importSessionId,
...preview,
};
});
}
function ensureCategory(db, userId, category, summary, details) {
const existing = db
.prepare('SELECT id FROM categories WHERE user_id = ? AND LOWER(name) = LOWER(?)')
.get(userId, category.name);
2026-05-03 19:51:57 -05:00
if (existing) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.categories.skipped++;
return existing.id;
}
const result = db
.prepare('INSERT INTO categories (user_id, name) VALUES (?, ?)')
.run(userId, category.name);
2026-05-03 19:51:57 -05:00
summary.rows_created++;
details.categories.created++;
return result.lastInsertRowid;
}
function ensureBill(db, userId, bill, categoryId, summary, details) {
const existing = db
.prepare('SELECT id FROM bills WHERE user_id = ? AND LOWER(name) = LOWER(?) AND due_day = ?')
2026-05-03 19:51:57 -05:00
.get(userId, bill.name, bill.due_day);
if (existing) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.bills.skipped++;
return existing.id;
}
const result = db
.prepare(
`
2026-05-03 19:51:57 -05:00
INSERT INTO bills
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate,
billing_cycle, cycle_type, cycle_day, autopay_enabled, autodraft_status, website, username, account_info, has_2fa,
2026-05-03 19:51:57 -05:00
active, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
userId,
bill.name,
categoryId || null,
bill.due_day,
bill.override_due_date || null,
bill.bucket,
bill.expected_amount,
bill.interest_rate,
billingCycleForCycleType(bill.cycle_type),
bill.cycle_type,
bill.cycle_day || null,
bill.autopay_enabled,
bill.autodraft_status,
bill.website,
bill.username,
bill.account_info,
bill.has_2fa,
bill.active,
bill.notes,
);
2026-05-03 19:51:57 -05:00
summary.rows_created++;
details.bills.created++;
return result.lastInsertRowid;
}
function importPayment(db, targetBillId, payment, summary, details) {
const duplicate = db
.prepare(
`
2026-05-03 19:51:57 -05:00
SELECT p.id FROM payments p
WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND COALESCE(p.method, '') = COALESCE(?, '')
AND p.deleted_at IS NULL
`,
)
.get(targetBillId, payment.paid_date, payment.amount, payment.method);
2026-05-03 19:51:57 -05:00
if (duplicate) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.payments.skipped++;
return;
}
db.prepare(
`
2026-05-16 20:26:09 -05:00
INSERT INTO payments (bill_id, amount, paid_date, method, notes, payment_source, transaction_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
).run(
2026-05-16 20:26:09 -05:00
targetBillId,
payment.amount,
payment.paid_date,
payment.method,
payment.notes,
payment.payment_source || 'manual',
payment.transaction_id,
);
2026-05-03 19:51:57 -05:00
summary.rows_created++;
details.payments.created++;
}
function importMonthlyState(db, targetBillId, row, summary, details) {
const duplicate = db
.prepare(
`
2026-05-03 19:51:57 -05:00
SELECT id FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?
`,
)
.get(targetBillId, row.year, row.month);
2026-05-03 19:51:57 -05:00
if (duplicate) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.monthly_bill_state.skipped++;
return;
}
db.prepare(
`
2026-05-03 19:51:57 -05:00
INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount, notes, is_skipped)
VALUES (?, ?, ?, ?, ?, ?)
`,
).run(targetBillId, row.year, row.month, row.actual_amount, row.notes, row.is_skipped);
2026-05-03 19:51:57 -05:00
summary.rows_created++;
details.monthly_bill_state.created++;
}
2026-05-04 20:12:57 -05:00
function importMonthlyStartingAmounts(db, userId, row, summary, details) {
const duplicate = db
.prepare(
`
2026-05-04 20:12:57 -05:00
SELECT id FROM monthly_starting_amounts WHERE user_id = ? AND year = ? AND month = ?
`,
)
.get(userId, row.year, row.month);
2026-05-04 20:12:57 -05:00
if (duplicate) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.monthly_starting_amounts.skipped++;
return;
}
db.prepare(
`
2026-05-04 20:12:57 -05:00
INSERT INTO monthly_starting_amounts (user_id, year, month, first_amount, fifteenth_amount, other_amount, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
).run(
userId,
row.year,
row.month,
row.first_amount,
row.fifteenth_amount,
row.other_amount,
row.notes,
);
2026-05-04 20:12:57 -05:00
summary.rows_created++;
details.monthly_starting_amounts.created++;
}
2026-05-03 19:51:57 -05:00
async function applyUserDbImport(userId, importSessionId, options = {}) {
if (options.overwrite) {
throw importError(
400,
'Overwrite is not supported for user SQLite imports. Existing data is skipped.',
'USER_DB_IMPORT_OVERWRITE_UNSUPPORTED',
);
2026-05-03 19:51:57 -05:00
}
const db = getDb();
const session = loadSession(db, userId, importSessionId);
const { data } = session;
const summary = {
import_type: 'user_db',
rows_created: 0,
rows_updated: 0,
rows_skipped: 0,
rows_conflicted: 0,
rows_errored: 0,
};
const details = {
categories: { created: 0, skipped: 0, errored: 0 },
bills: { created: 0, skipped: 0, errored: 0 },
payments: { created: 0, skipped: 0, errored: 0 },
monthly_bill_state: { created: 0, skipped: 0, errored: 0 },
2026-05-04 20:12:57 -05:00
monthly_starting_amounts: { created: 0, skipped: 0, errored: 0 },
2026-05-03 19:51:57 -05:00
notes: { created: 0, skipped: data.notes?.length || 0, errored: 0 },
};
summary.rows_skipped += details.notes.skipped;
const tx = db.transaction(() => {
const categoryIdMap = new Map();
for (const category of data.categories) {
categoryIdMap.set(category.old_id, ensureCategory(db, userId, category, summary, details));
}
const billIdMap = new Map();
for (const bill of data.bills) {
const targetCategoryId = bill.category_id ? categoryIdMap.get(bill.category_id) : null;
billIdMap.set(bill.old_id, ensureBill(db, userId, bill, targetCategoryId, summary, details));
}
for (const payment of data.payments) {
const targetBillId = billIdMap.get(payment.bill_id);
if (!targetBillId) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.payments.skipped++;
continue;
}
importPayment(db, targetBillId, payment, summary, details);
}
for (const row of data.monthly_bill_state) {
const targetBillId = billIdMap.get(row.bill_id);
if (!targetBillId) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.monthly_bill_state.skipped++;
continue;
}
importMonthlyState(db, targetBillId, row, summary, details);
}
2026-05-04 20:12:57 -05:00
for (const row of data.monthly_starting_amounts) {
importMonthlyStartingAmounts(db, userId, row, summary, details);
}
db.prepare(
`
2026-05-03 19:51:57 -05:00
INSERT INTO import_history
(user_id, imported_at, source_filename, file_type, sheet_name, rows_parsed,
rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored,
options_json, summary_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
2026-05-03 19:51:57 -05:00
userId,
new Date().toISOString(),
session.source_filename || null,
'sqlite',
'User SQLite export',
data.categories.length +
data.bills.length +
data.payments.length +
data.monthly_bill_state.length +
data.monthly_starting_amounts.length +
(data.notes?.length || 0),
2026-05-03 19:51:57 -05:00
summary.rows_created,
summary.rows_updated,
summary.rows_skipped,
summary.rows_conflicted,
summary.rows_errored,
JSON.stringify({ overwrite: false }),
JSON.stringify({ ...summary, details }),
);
db.prepare('DELETE FROM import_sessions WHERE id = ? AND user_id = ?').run(
importSessionId,
userId,
);
2026-05-03 19:51:57 -05:00
});
tx();
return { ...summary, details };
}
module.exports = {
previewUserDbImport,
applyUserDbImport,
};