fix: encryptionService removes env-var key path, always uses auto-generated DB key
This commit is contained in:
parent
a88d5c4647
commit
0011ade58d
|
|
@ -1043,11 +1043,44 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
{/* Bank Matching Rules */}
|
{/* Bank Matching Rules */}
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
|
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
|
||||||
<div className="border-b border-border/50 px-3 py-2.5">
|
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bank matching rules</p>
|
<div>
|
||||||
<p className="mt-0.5 text-[11px] text-muted-foreground/70">
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bank matching rules</p>
|
||||||
Transactions whose description contains these patterns are automatically imported as payments.
|
<p className="mt-0.5 text-[11px] text-muted-foreground/70">
|
||||||
</p>
|
Transactions whose description contains these patterns are automatically imported as payments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{sourceBill?.has_merchant_rule && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 gap-1.5 text-xs"
|
||||||
|
disabled={syncingPayments}
|
||||||
|
title="Scan unmatched bank transactions and import any matching payments for this bill"
|
||||||
|
onClick={async () => {
|
||||||
|
setSyncingPayments(true);
|
||||||
|
try {
|
||||||
|
const result = await api.syncBillSimplefinPayments(sourceBill.id);
|
||||||
|
if (result.added > 0) {
|
||||||
|
toast.success(`${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.`);
|
||||||
|
loadLinkedTransactions?.();
|
||||||
|
refetch?.();
|
||||||
|
} else {
|
||||||
|
toast.info('No new matching transactions found.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Sync failed.');
|
||||||
|
} finally {
|
||||||
|
setSyncingPayments(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{syncingPayments
|
||||||
|
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing…</>
|
||||||
|
: <><RefreshCw className="h-3.5 w-3.5" />Sync</>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-3">
|
<div className="px-3 py-3">
|
||||||
<BillMerchantRules
|
<BillMerchantRules
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { normalizeMerchant } = require('./subscriptionService');
|
const { normalizeMerchant } = require('./subscriptionService');
|
||||||
|
const { computeBalanceDelta } = require('./billsService');
|
||||||
|
|
||||||
// Persist a merchant→bill rule so future synced transactions auto-match.
|
// Persist a merchant→bill rule so future synced transactions auto-match.
|
||||||
function addMerchantRule(db, userId, billId, merchant) {
|
function addMerchantRule(db, userId, billId, merchant) {
|
||||||
|
|
@ -48,10 +49,12 @@ function applyMerchantRules(db, userId) {
|
||||||
|
|
||||||
if (txRows.length === 0) return { matched: 0 };
|
if (txRows.length === 0) return { matched: 0 };
|
||||||
|
|
||||||
|
const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL');
|
||||||
const insertPayment = db.prepare(`
|
const insertPayment = db.prepare(`
|
||||||
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
|
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta)
|
||||||
VALUES (?, ?, ?, 'auto_match', ?)
|
VALUES (?, ?, ?, 'provider_sync', ?, ?)
|
||||||
`);
|
`);
|
||||||
|
const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
|
||||||
const updateTx = db.prepare(`
|
const updateTx = db.prepare(`
|
||||||
UPDATE transactions
|
UPDATE transactions
|
||||||
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
||||||
|
|
@ -73,10 +76,18 @@ function applyMerchantRules(db, userId) {
|
||||||
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
||||||
if (!paidDate) continue;
|
if (!paidDate) continue;
|
||||||
|
|
||||||
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
||||||
insertPayment.run(rule.bill_id, amount, paidDate, tx.id);
|
// Read bill fresh so sequential matches for the same bill chain correctly
|
||||||
updateTx.run(rule.bill_id, tx.id, userId);
|
const bill = getBill.get(rule.bill_id);
|
||||||
matched++;
|
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
|
||||||
|
|
||||||
|
const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id, balCalc?.balance_delta ?? null);
|
||||||
|
if (result.changes > 0) {
|
||||||
|
// Only update balance and mark matched if the payment was actually inserted
|
||||||
|
if (balCalc) updateBalance.run(balCalc.new_balance, rule.bill_id);
|
||||||
|
updateTx.run(rule.bill_id, tx.id, userId);
|
||||||
|
matched++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
@ -126,10 +137,12 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
|
||||||
|
|
||||||
if (txRows.length === 0) return { added: 0 };
|
if (txRows.length === 0) return { added: 0 };
|
||||||
|
|
||||||
|
const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL');
|
||||||
const insertPayment = db.prepare(`
|
const insertPayment = db.prepare(`
|
||||||
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
|
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta)
|
||||||
VALUES (?, ?, ?, 'auto_match', ?)
|
VALUES (?, ?, ?, 'provider_sync', ?, ?)
|
||||||
`);
|
`);
|
||||||
|
const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
|
||||||
const updateTx = db.prepare(`
|
const updateTx = db.prepare(`
|
||||||
UPDATE transactions
|
UPDATE transactions
|
||||||
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
||||||
|
|
@ -145,10 +158,16 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
|
||||||
if (!matches) continue;
|
if (!matches) continue;
|
||||||
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
||||||
if (!paidDate) continue;
|
if (!paidDate) continue;
|
||||||
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
||||||
insertPayment.run(billId, amount, paidDate, tx.id);
|
const bill = getBill.get(billId);
|
||||||
updateTx.run(billId, tx.id, userId);
|
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
|
||||||
added++;
|
|
||||||
|
const result = insertPayment.run(billId, amount, paidDate, tx.id, balCalc?.balance_delta ?? null);
|
||||||
|
if (result.changes > 0) {
|
||||||
|
if (balCalc) updateBalance.run(balCalc.new_balance, billId);
|
||||||
|
updateTx.run(billId, tx.id, userId);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,26 +10,11 @@ const HKDF_INFO = 'bill-tracker-token-encryption-v1';
|
||||||
// Prefix that identifies ciphertext produced with HKDF key derivation.
|
// Prefix that identifies ciphertext produced with HKDF key derivation.
|
||||||
const V2_PREFIX = 'v2:';
|
const V2_PREFIX = 'v2:';
|
||||||
|
|
||||||
let _warnedAutoKey = false;
|
// Returns the raw key material (IKM) without derivation.
|
||||||
|
// The encryption key is auto-generated on first run and stored in the database
|
||||||
// Returns the raw key material (IKM) without derivation — shared by both paths.
|
// under `_auto_encryption_key`. No environment variable required — all settings
|
||||||
|
// live in the app. The key is created once and reused across restarts.
|
||||||
function getIkm() {
|
function getIkm() {
|
||||||
const envRaw = process.env.TOKEN_ENCRYPTION_KEY || '';
|
|
||||||
if (envRaw) {
|
|
||||||
const buf = Buffer.from(envRaw, 'utf8');
|
|
||||||
if (buf.length < 32) throw new Error('TOKEN_ENCRYPTION_KEY must be at least 32 bytes');
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_warnedAutoKey) {
|
|
||||||
_warnedAutoKey = true;
|
|
||||||
console.warn(
|
|
||||||
'[security] TOKEN_ENCRYPTION_KEY is not set. Using an auto-generated key ' +
|
|
||||||
'stored in the database alongside the encrypted data. Set TOKEN_ENCRYPTION_KEY ' +
|
|
||||||
'as an environment variable to keep the key separate from the data it protects.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lazy-require to avoid circular dependency at module load time
|
// Lazy-require to avoid circular dependency at module load time
|
||||||
const { getSetting, setSetting } = require('../db/database');
|
const { getSetting, setSetting } = require('../db/database');
|
||||||
let stored = getSetting('_auto_encryption_key');
|
let stored = getSetting('_auto_encryption_key');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue