679 lines
25 KiB
JavaScript
679 lines
25 KiB
JavaScript
'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');
|
||
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService');
|
||
const { toCents } = require('../utils/money');
|
||
|
||
const MAX_SQLITE_BYTES = 50 * 1024 * 1024;
|
||
const SESSION_TTL_HOURS = 24;
|
||
const REQUIRED_TABLES = ['export_metadata', 'categories', 'bills', 'payments', 'monthly_bill_state'];
|
||
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']);
|
||
|
||
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
|
||
: 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');
|
||
}
|
||
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');
|
||
}
|
||
}
|
||
|
||
function withTempSqlite(buffer, fn) {
|
||
const file = path.join(os.tmpdir(), `bill-tracker-user-import-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.sqlite`);
|
||
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 {}
|
||
}
|
||
}
|
||
|
||
function tableNames(db) {
|
||
return new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name));
|
||
}
|
||
|
||
const IMPORT_TABLES = new Set([
|
||
'categories', 'bills', 'payments',
|
||
'monthly_bill_state', 'monthly_starting_amounts', 'notes',
|
||
]);
|
||
|
||
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));
|
||
}
|
||
|
||
function selectKnown(db, table, columns) {
|
||
const cols = tableColumns(db, table);
|
||
const selected = columns.filter(c => cols.has(c));
|
||
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();
|
||
}
|
||
|
||
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 BillTracker user data export.', 'USER_DB_IMPORT_NOT_USER_EXPORT');
|
||
}
|
||
let metadata;
|
||
try {
|
||
metadata = JSON.parse(row.value);
|
||
} catch {
|
||
throw importError(400, 'SQLite export metadata could not be read.', 'USER_DB_IMPORT_BAD_METADATA');
|
||
}
|
||
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');
|
||
}
|
||
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) {
|
||
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';
|
||
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),
|
||
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),
|
||
notes: cleanText(row.notes, 2000),
|
||
created_at: cleanText(row.created_at, 32),
|
||
updated_at: cleanText(row.updated_at, 32),
|
||
};
|
||
}
|
||
|
||
function sanitizePayment(row, validBillIds, sourceIsCents) {
|
||
const billId = toInt(row.bill_id);
|
||
const amount = toNumber(row.amount);
|
||
const paidDate = cleanDate(row.paid_date);
|
||
const paymentSource = cleanText(row.payment_source, 64) || 'manual';
|
||
if (!billId || !validBillIds.has(billId) || amount == null || amount < 0 || !paidDate) return null;
|
||
return {
|
||
old_id: toInt(row.id),
|
||
bill_id: billId,
|
||
amount: importMoney(amount, sourceIsCents),
|
||
paid_date: paidDate,
|
||
method: cleanText(row.method, 120),
|
||
notes: cleanText(row.notes, 2000),
|
||
payment_source: VALID_PAYMENT_SOURCES.has(paymentSource) ? paymentSource : 'manual',
|
||
transaction_id: null,
|
||
created_at: cleanText(row.created_at, 32),
|
||
updated_at: cleanText(row.updated_at, 32),
|
||
};
|
||
}
|
||
|
||
function sanitizeMonthlyState(row, validBillIds, sourceIsCents) {
|
||
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;
|
||
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),
|
||
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) {
|
||
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),
|
||
notes: cleanText(row.notes, 2000),
|
||
created_at: cleanText(row.created_at, 32),
|
||
updated_at: cleanText(row.updated_at, 32),
|
||
};
|
||
}
|
||
|
||
function readExportData(src) {
|
||
const names = tableNames(src);
|
||
const missing = REQUIRED_TABLES.filter(t => !names.has(t));
|
||
if (missing.length) {
|
||
throw importError(400, 'SQLite file is not a supported BillTracker user export.', 'USER_DB_IMPORT_UNSUPPORTED_SCHEMA', missing.map(table => ({
|
||
table,
|
||
message: 'Required user export table is missing',
|
||
})));
|
||
}
|
||
|
||
const metadata = parseMetadata(src);
|
||
// Pre-v1.03 exports store money in dollars; v1.03+ exports store integer cents.
|
||
const sourceIsCents = sourceUsesCents(src);
|
||
const categories = selectKnown(src, 'categories', ['id', 'name', 'created_at', 'updated_at'])
|
||
.map(sanitizeCategory).filter(Boolean);
|
||
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));
|
||
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'])
|
||
.map(row => sanitizeMonthlyState(row, validBillIds, sourceIsCents)).filter(Boolean);
|
||
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)
|
||
: [];
|
||
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)
|
||
: [];
|
||
|
||
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, notes };
|
||
}
|
||
|
||
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])),
|
||
};
|
||
}
|
||
|
||
function buildPreview(userId, data, originalFilename) {
|
||
const db = getDb();
|
||
const lookups = existingLookups(db, userId);
|
||
const categoryPlan = data.categories.map(c => {
|
||
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',
|
||
};
|
||
});
|
||
|
||
const categoryMap = new Map(categoryPlan.map(c => [c.old_id, c]));
|
||
const billPlan = data.bills.map(b => {
|
||
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',
|
||
};
|
||
});
|
||
|
||
const billPlanByOldId = new Map(billPlan.map(b => [b.old_id, b]));
|
||
const paymentPlan = data.payments.map(p => ({
|
||
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',
|
||
}));
|
||
const monthlyPlan = data.monthly_bill_state.map(m => ({
|
||
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',
|
||
}));
|
||
const monthlyStartingAmountsPlan = data.monthly_starting_amounts.map(m => ({
|
||
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',
|
||
}));
|
||
|
||
const summary = {
|
||
categories: {
|
||
total: data.categories.length,
|
||
create: categoryPlan.filter(x => x.action === 'create').length,
|
||
skip: categoryPlan.filter(x => x.action === 'skip').length,
|
||
conflict: 0,
|
||
},
|
||
bills: {
|
||
total: data.bills.length,
|
||
create: billPlan.filter(x => x.action === 'create').length,
|
||
skip: billPlan.filter(x => x.action === 'skip').length,
|
||
conflict: 0,
|
||
},
|
||
payments: {
|
||
total: data.payments.length,
|
||
create: data.payments.length,
|
||
skip: 0,
|
||
conflict: paymentPlan.filter(x => x.action === 'conflict').length,
|
||
},
|
||
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,
|
||
},
|
||
monthly_starting_amounts: {
|
||
total: data.monthly_starting_amounts.length,
|
||
create: data.monthly_starting_amounts.length,
|
||
skip: 0,
|
||
conflict: 0,
|
||
},
|
||
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,
|
||
monthly_starting_amounts: data.monthly_starting_amounts.length,
|
||
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),
|
||
monthly_starting_amounts: monthlyStartingAmountsPlan.slice(0, 100),
|
||
},
|
||
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(`
|
||
INSERT INTO import_sessions (id, user_id, created_at, expires_at, preview_json)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
`).run(id, userId, now.toISOString(), expires.toISOString(), JSON.stringify(sessionData));
|
||
return id;
|
||
}
|
||
|
||
function loadSession(db, userId, importSessionId) {
|
||
const row = db.prepare(`
|
||
SELECT preview_json FROM import_sessions
|
||
WHERE id = ? AND user_id = ? AND expires_at > ?
|
||
`).get(importSessionId, userId, new Date().toISOString());
|
||
if (!row) {
|
||
throw importError(400, 'Import session is invalid or expired. Preview the SQLite export again.', 'USER_DB_IMPORT_SESSION_INVALID');
|
||
}
|
||
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');
|
||
}
|
||
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);
|
||
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);
|
||
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 = ?')
|
||
.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(`
|
||
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,
|
||
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,
|
||
);
|
||
summary.rows_created++;
|
||
details.bills.created++;
|
||
return result.lastInsertRowid;
|
||
}
|
||
|
||
function importPayment(db, targetBillId, payment, summary, details) {
|
||
const duplicate = db.prepare(`
|
||
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);
|
||
if (duplicate) {
|
||
summary.rows_skipped++;
|
||
summary.rows_conflicted++;
|
||
details.payments.skipped++;
|
||
return;
|
||
}
|
||
db.prepare(`
|
||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, payment_source, transaction_id)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`).run(
|
||
targetBillId,
|
||
payment.amount,
|
||
payment.paid_date,
|
||
payment.method,
|
||
payment.notes,
|
||
payment.payment_source || 'manual',
|
||
payment.transaction_id,
|
||
);
|
||
summary.rows_created++;
|
||
details.payments.created++;
|
||
}
|
||
|
||
function importMonthlyState(db, targetBillId, row, summary, details) {
|
||
const duplicate = db.prepare(`
|
||
SELECT id FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?
|
||
`).get(targetBillId, row.year, row.month);
|
||
if (duplicate) {
|
||
summary.rows_skipped++;
|
||
summary.rows_conflicted++;
|
||
details.monthly_bill_state.skipped++;
|
||
return;
|
||
}
|
||
db.prepare(`
|
||
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);
|
||
summary.rows_created++;
|
||
details.monthly_bill_state.created++;
|
||
}
|
||
|
||
function importMonthlyStartingAmounts(db, userId, row, summary, details) {
|
||
const duplicate = db.prepare(`
|
||
SELECT id FROM monthly_starting_amounts WHERE user_id = ? AND year = ? AND month = ?
|
||
`).get(userId, row.year, row.month);
|
||
if (duplicate) {
|
||
summary.rows_skipped++;
|
||
summary.rows_conflicted++;
|
||
details.monthly_starting_amounts.skipped++;
|
||
return;
|
||
}
|
||
db.prepare(`
|
||
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);
|
||
summary.rows_created++;
|
||
details.monthly_starting_amounts.created++;
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
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 },
|
||
monthly_starting_amounts: { created: 0, skipped: 0, errored: 0 },
|
||
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);
|
||
}
|
||
|
||
for (const row of data.monthly_starting_amounts) {
|
||
importMonthlyStartingAmounts(db, userId, row, summary, details);
|
||
}
|
||
|
||
db.prepare(`
|
||
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(
|
||
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),
|
||
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);
|
||
});
|
||
|
||
tx();
|
||
return { ...summary, details };
|
||
}
|
||
|
||
module.exports = {
|
||
previewUserDbImport,
|
||
applyUserDbImport,
|
||
};
|