feat: SimpleFIN payment backfill button on subscription bills (v0.33.7.2)
This commit is contained in:
parent
da6a93804b
commit
32f1568515
17
HISTORY.md
17
HISTORY.md
|
|
@ -1,5 +1,22 @@
|
||||||
# Bill Tracker — Changelog
|
# Bill Tracker — Changelog
|
||||||
|
|
||||||
|
## v0.33.7.2
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- **SimpleFIN payment backfill** — The bill edit/subscription modal now shows a "Sync payments" button for bills that have a merchant rule stored (`has_merchant_rule === 1`). This covers both "Track from recommendation" and "Link to bill" flows. Clicking it calls `POST /api/bills/:id/sync-simplefin-payments` which scans unmatched negative transactions matching the bill's merchant rules and auto-creates `payment_source = 'auto_match'` records with the transaction's date and amount.
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- **Subscription -> bill flow now creates payments** — Both `POST /api/subscriptions/recommendations/match-bill` and the "Create subscription from recommendation" path now insert auto-match payment records alongside the transaction match update.
|
||||||
|
|
||||||
|
### 🛠 Internal
|
||||||
|
|
||||||
|
- `GET /api/bills/:id` now returns `has_merchant_rule` boolean for conditional UI rendering.
|
||||||
|
- New helper `syncBillPaymentsFromSimplefin()` in `billMerchantRuleService.js` handles merchant extraction, rule fallback from notes, transaction scanning, and payment creation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.28.01
|
## v0.28.01
|
||||||
|
|
||||||
### 🏆 Major Features
|
### 🏆 Major Features
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ export const api = {
|
||||||
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
||||||
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
||||||
billTransactions: (id) => get(`/bills/${id}/transactions`),
|
billTransactions: (id) => get(`/bills/${id}/transactions`),
|
||||||
|
syncBillSimplefinPayments: (id) => post(`/bills/${id}/sync-simplefin-payments`),
|
||||||
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
|
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
|
||||||
saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data),
|
saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data),
|
||||||
billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`),
|
billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ChevronDown, Copy, Link2, Link2Off, Pencil, Plus, Trash2 } from 'lucide-react';
|
import { ChevronDown, Copy, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
@ -85,6 +85,7 @@ function paymentSourceLabel(source) {
|
||||||
file_import: 'File import',
|
file_import: 'File import',
|
||||||
provider_sync: 'Sync',
|
provider_sync: 'Sync',
|
||||||
transaction_match: 'Transaction',
|
transaction_match: 'Transaction',
|
||||||
|
auto_match: 'SimpleFIN',
|
||||||
};
|
};
|
||||||
return labels[source] || source || 'Manual';
|
return labels[source] || source || 'Manual';
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +96,7 @@ function paymentSourceTone(source) {
|
||||||
file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
||||||
provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||||
transaction_match: 'border-primary/25 bg-primary/10 text-primary',
|
transaction_match: 'border-primary/25 bg-primary/10 text-primary',
|
||||||
|
auto_match: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||||
};
|
};
|
||||||
return tones[source] || tones.manual;
|
return tones[source] || tones.manual;
|
||||||
}
|
}
|
||||||
|
|
@ -137,6 +139,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
const [minimumPayment, setMinimumPayment] = useState(sourceBill?.minimum_payment == null ? '' : String(sourceBill.minimum_payment));
|
const [minimumPayment, setMinimumPayment] = useState(sourceBill?.minimum_payment == null ? '' : String(sourceBill.minimum_payment));
|
||||||
const [snowballInclude, setSnowballInclude] = useState(!!sourceBill?.snowball_include);
|
const [snowballInclude, setSnowballInclude] = useState(!!sourceBill?.snowball_include);
|
||||||
const [snowballExempt, setSnowballExempt] = useState(!!sourceBill?.snowball_exempt);
|
const [snowballExempt, setSnowballExempt] = useState(!!sourceBill?.snowball_exempt);
|
||||||
|
const [syncingPayments, setSyncingPayments] = useState(false);
|
||||||
const [showDebtSection, setShowDebtSection] = useState(
|
const [showDebtSection, setShowDebtSection] = useState(
|
||||||
() => isDebtCat(categories, sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE)
|
() => isDebtCat(categories, sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE)
|
||||||
|| !!sourceBill?.snowball_include
|
|| !!sourceBill?.snowball_include
|
||||||
|
|
@ -705,6 +708,44 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground/70">0-30 days before renewal.</p>
|
<p className="text-[10px] text-muted-foreground/70">0-30 days before renewal.</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!isNew && (sourceBill?.has_merchant_rule || ['simplefin_recommendation', 'catalog_match'].includes(sourceBill?.subscription_source)) ? (
|
||||||
|
<div className="col-span-2 border-t border-border/40 pt-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">SimpleFIN payment history</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
|
||||||
|
Scan unmatched bank transactions and backfill any missing payments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 gap-1.5"
|
||||||
|
disabled={syncingPayments}
|
||||||
|
onClick={async () => {
|
||||||
|
setSyncingPayments(true);
|
||||||
|
try {
|
||||||
|
const result = await api.syncBillSimplefinPayments(sourceBill.id);
|
||||||
|
if (result.added > 0) {
|
||||||
|
toast.success(`Synced ${result.added} payment${result.added !== 1 ? 's' : ''} from SimpleFIN.`);
|
||||||
|
} else {
|
||||||
|
toast.info('No new payments found in SimpleFIN history.');
|
||||||
|
}
|
||||||
|
} 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 payments</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,13 @@ export const APP_NAME = 'BillTracker';
|
||||||
|
|
||||||
export const RELEASE_NOTES = {
|
export const RELEASE_NOTES = {
|
||||||
version: APP_VERSION,
|
version: APP_VERSION,
|
||||||
date: '2026-05-15',
|
date: '2026-05-29',
|
||||||
highlights: [
|
highlights: [
|
||||||
|
{
|
||||||
|
icon: '🔄',
|
||||||
|
title: 'SimpleFIN payment backfill',
|
||||||
|
desc: 'Subscription bills with merchant rules now have a Sync Payments button in the edit modal. It scans unmatched SimpleFIN transactions and auto-creates payment records for missing history.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: '🛡️',
|
icon: '🛡️',
|
||||||
title: 'Safer financial history',
|
title: 'Safer financial history',
|
||||||
|
|
@ -33,11 +38,6 @@ export const RELEASE_NOTES = {
|
||||||
title: 'Ramsey Snowball mode',
|
title: 'Ramsey Snowball mode',
|
||||||
desc: 'Debt Snowball now defaults to smallest-balance-first, keeps custom drag ordering behind a toggle, skips mortgages by default, and adds an inline Ramsey readiness checklist.',
|
desc: 'Debt Snowball now defaults to smallest-balance-first, keeps custom drag ordering behind a toggle, skips mortgages by default, and adds an inline Ramsey readiness checklist.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: '🎛️',
|
|
||||||
title: 'Cleaner tracker and interface polish',
|
|
||||||
desc: 'The Tracker remaining card now shows the active 1st or 15th balance, Roadmap columns breathe on desktop and mobile, and app surfaces have a calmer darker treatment.',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
image: {
|
image: {
|
||||||
src: '/img/doingmypart.jpg',
|
src: '/img/doingmypart.jpg',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.33.7.1",
|
"version": "0.33.7.2",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const {
|
||||||
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
|
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
const { validatePaymentInput } = require('../services/paymentValidation');
|
const { validatePaymentInput } = require('../services/paymentValidation');
|
||||||
|
const { syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService');
|
||||||
const { decorateTransaction } = require('../services/transactionService');
|
const { decorateTransaction } = require('../services/transactionService');
|
||||||
|
|
||||||
// ── GET /api/bills ────────────────────────────────────────────────────────────
|
// ── GET /api/bills ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -229,7 +230,10 @@ router.get('/:id', (req, res) => {
|
||||||
SELECT b.*, c.name AS category_name,
|
SELECT b.*, c.name AS category_name,
|
||||||
CASE WHEN EXISTS(
|
CASE WHEN EXISTS(
|
||||||
SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id
|
SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id
|
||||||
) THEN 1 ELSE 0 END AS has_history_ranges
|
) THEN 1 ELSE 0 END AS has_history_ranges,
|
||||||
|
CASE WHEN EXISTS(
|
||||||
|
SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id
|
||||||
|
) THEN 1 ELSE 0 END AS has_merchant_rule
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
||||||
WHERE b.id = ? AND b.user_id = ? AND b.deleted_at IS NULL
|
WHERE b.id = ? AND b.user_id = ? AND b.deleted_at IS NULL
|
||||||
|
|
@ -373,6 +377,23 @@ router.post('/:id/restore', (req, res) => {
|
||||||
res.json(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id));
|
res.json(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/bills/:id/sync-simplefin-payments
|
||||||
|
// Scan unmatched SimpleFIN transactions for this bill's merchant rules and
|
||||||
|
// backfill any missing payments.
|
||||||
|
router.post('/:id/sync-simplefin-payments', (req, res) => {
|
||||||
|
const billId = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isInteger(billId)) return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
|
||||||
|
const db = getDb();
|
||||||
|
const bill = db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
||||||
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||||
|
try {
|
||||||
|
const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json(standardizeError(err.message || 'Sync failed', 'SYNC_ERROR'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
|
// ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
|
||||||
router.get('/:id/payments', (req, res) => {
|
router.get('/:id/payments', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
|
||||||
|
|
@ -60,15 +60,29 @@ router.post('/recommendations/match-bill', (req, res) => {
|
||||||
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
||||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const update = db.prepare(`
|
const placeholders = txIds.map(() => '?').join(',');
|
||||||
|
const txRows = db.prepare(`
|
||||||
|
SELECT id, amount, posted_date, transacted_at
|
||||||
|
FROM transactions
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders}) AND ignored = 0 AND match_status != 'matched'
|
||||||
|
`).all(req.user.id, ...txIds);
|
||||||
|
|
||||||
|
const updateTx = db.prepare(`
|
||||||
UPDATE transactions
|
UPDATE transactions
|
||||||
SET matched_bill_id = ?, match_status = 'matched', updated_at = CURRENT_TIMESTAMP
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND user_id = ? AND ignored = 0 AND match_status != 'matched'
|
WHERE id = ? AND user_id = ? AND ignored = 0 AND match_status != 'matched'
|
||||||
`);
|
`);
|
||||||
|
const insertPayment = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
|
||||||
|
VALUES (?, ?, ?, 'auto_match', ?)
|
||||||
|
`);
|
||||||
let matchedCount = 0;
|
let matchedCount = 0;
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const id of txIds) {
|
for (const tx of txRows) {
|
||||||
matchedCount += update.run(billId, id, req.user.id).changes;
|
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
||||||
|
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
||||||
|
matchedCount += updateTx.run(billId, tx.id, req.user.id).changes;
|
||||||
|
if (paidDate) insertPayment.run(billId, amount, paidDate, tx.id);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,4 +81,74 @@ function applyMerchantRules(db, userId) {
|
||||||
return { matched };
|
return { matched };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { addMerchantRule, applyMerchantRules };
|
// Sync all unmatched SimpleFIN transactions for a single bill using its stored
|
||||||
|
// merchant rules. If no rule exists yet but the bill has a detected merchant in
|
||||||
|
// its notes, the rule is created on the fly.
|
||||||
|
// Returns { added: number }.
|
||||||
|
function syncBillPaymentsFromSimplefin(db, userId, billId) {
|
||||||
|
// Load rules for this specific bill
|
||||||
|
let rules;
|
||||||
|
try {
|
||||||
|
rules = db.prepare(`
|
||||||
|
SELECT merchant FROM bill_merchant_rules
|
||||||
|
WHERE user_id = ? AND bill_id = ?
|
||||||
|
`).all(userId, billId).map(r => r.merchant);
|
||||||
|
} catch {
|
||||||
|
return { added: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: extract merchant from notes "Detected from recurring merchant: X"
|
||||||
|
if (rules.length === 0) {
|
||||||
|
const bill = db.prepare('SELECT notes FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, userId);
|
||||||
|
const match = bill?.notes?.match(/Detected from recurring merchant:\s*(.+)/i);
|
||||||
|
if (match) {
|
||||||
|
const extracted = normalizeMerchant(match[1].trim());
|
||||||
|
if (extracted && extracted.length >= 3) {
|
||||||
|
addMerchantRule(db, userId, billId, extracted);
|
||||||
|
rules = [extracted];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rules.length === 0) return { added: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const txRows = db.prepare(`
|
||||||
|
SELECT id, amount, payee, description, memo, posted_date, transacted_at
|
||||||
|
FROM transactions
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND match_status = 'unmatched'
|
||||||
|
AND ignored = 0
|
||||||
|
AND amount < 0
|
||||||
|
`).all(userId);
|
||||||
|
|
||||||
|
if (txRows.length === 0) return { added: 0 };
|
||||||
|
|
||||||
|
const insertPayment = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
|
||||||
|
VALUES (?, ?, ?, 'auto_match', ?)
|
||||||
|
`);
|
||||||
|
const updateTx = db.prepare(`
|
||||||
|
UPDATE transactions
|
||||||
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND user_id = ? AND match_status = 'unmatched'
|
||||||
|
`);
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const tx of txRows) {
|
||||||
|
const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
|
||||||
|
if (!txMerchant) continue;
|
||||||
|
const matches = rules.some(r => txMerchant.includes(r) || r.includes(txMerchant));
|
||||||
|
if (!matches) continue;
|
||||||
|
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
||||||
|
if (!paidDate) continue;
|
||||||
|
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
||||||
|
insertPayment.run(billId, amount, paidDate, tx.id);
|
||||||
|
updateTx.run(billId, tx.id, userId);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { added };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { addMerchantRule, applyMerchantRules, syncBillPaymentsFromSimplefin };
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,10 @@ function decorateSubscription(bill) {
|
||||||
|
|
||||||
function getSubscriptions(db, userId) {
|
function getSubscriptions(db, userId) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT b.*, c.name AS category_name
|
SELECT b.*, c.name AS category_name,
|
||||||
|
CASE WHEN EXISTS(
|
||||||
|
SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id
|
||||||
|
) THEN 1 ELSE 0 END AS has_merchant_rule
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
|
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
|
@ -405,12 +408,31 @@ function createSubscriptionFromRecommendation(db, userId, payload = {}) {
|
||||||
? payload.transaction_ids.map(id => Number(id)).filter(Number.isInteger).slice(0, 50)
|
? payload.transaction_ids.map(id => Number(id)).filter(Number.isInteger).slice(0, 50)
|
||||||
: [];
|
: [];
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
const update = db.prepare(`
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
const txRows = db.prepare(`
|
||||||
|
SELECT id, amount, posted_date, transacted_at
|
||||||
|
FROM transactions
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders}) AND ignored = 0
|
||||||
|
`).all(userId, ...ids);
|
||||||
|
|
||||||
|
const updateTx = db.prepare(`
|
||||||
UPDATE transactions
|
UPDATE transactions
|
||||||
SET matched_bill_id = ?, match_status = 'matched', updated_at = CURRENT_TIMESTAMP
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE user_id = ? AND id = ? AND ignored = 0
|
WHERE id = ? AND user_id = ? AND ignored = 0 AND match_status != 'matched'
|
||||||
`);
|
`);
|
||||||
for (const id of ids) update.run(created.id, userId, id);
|
const insertPayment = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
|
||||||
|
VALUES (?, ?, ?, 'auto_match', ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const tx of txRows) {
|
||||||
|
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
||||||
|
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
||||||
|
updateTx.run(created.id, tx.id, userId);
|
||||||
|
if (paidDate) insertPayment.run(created.id, amount, paidDate, tx.id);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
return decorateSubscription(created);
|
return decorateSubscription(created);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue