Add special financing feature
Data model: - Migration 003: financing_plans and financing_payments tables - Plans track total amount, due date, paycheck assignment (1, 2, or split) - Payments linked to paycheck records, one per period Payment calculation: - Auto-calculated: (total - paid_so_far) / remaining_periods - Split plans halve the per-period amount across both paychecks - Recalculates each period as payments are made Financing page (/financing): - Create/edit/delete plans with name, amount, due date, paycheck assignment - Progress bar showing paid vs total - Overdue badge when past due with remaining balance - Paid-off plans moved to a separate section Paycheck view: - New Financing section per column with payment checkbox - Overdue badge on individual payments - Running paid/total shown in bill meta - Financing payments included in remaining balance calculation - Auto-closes plan and reloads when fully paid off - Lazy generation works: first interaction generates paycheck and payment records Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../db');
|
||||
const { calcPaymentAmount } = require('./financing');
|
||||
|
||||
const CONFIG_KEYS = [
|
||||
'paycheck1_day',
|
||||
@@ -84,9 +85,51 @@ async function buildVirtualPaychecks(year, month) {
|
||||
paid_at: null,
|
||||
})),
|
||||
one_time_expenses: [],
|
||||
financing: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Attach virtual financing payment previews
|
||||
const activePlans = await pool.query(
|
||||
`SELECT * FROM financing_plans WHERE active = TRUE ORDER BY due_date ASC`
|
||||
);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
for (const plan of activePlans.rows) {
|
||||
const amount = await calcPaymentAmount(client, plan, year, month);
|
||||
if (amount <= 0) continue;
|
||||
|
||||
const due = new Date(plan.due_date);
|
||||
const dueYear = due.getFullYear();
|
||||
const dueMonth = due.getMonth() + 1;
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const overdue = due < today && parseFloat(plan.total_amount) > 0;
|
||||
|
||||
const entry = {
|
||||
financing_payment_id: null,
|
||||
plan_id: plan.id,
|
||||
name: plan.name,
|
||||
amount,
|
||||
paid: false,
|
||||
paid_at: null,
|
||||
due_date: plan.due_date,
|
||||
overdue,
|
||||
plan_closed: false,
|
||||
};
|
||||
|
||||
if (plan.assigned_paycheck == null) {
|
||||
// split across both — give half to each
|
||||
const half = parseFloat((amount / 2).toFixed(2));
|
||||
paychecks.find(p => p.paycheck_number === 1)?.financing.push({ ...entry, amount: half });
|
||||
paychecks.find(p => p.paycheck_number === 2)?.financing.push({ ...entry, amount: half });
|
||||
} else {
|
||||
paychecks.find(p => p.paycheck_number === plan.assigned_paycheck)?.financing.push(entry);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return paychecks;
|
||||
}
|
||||
|
||||
@@ -135,6 +178,31 @@ async function generatePaychecks(year, month) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate financing_payment records for active plans
|
||||
const activePlans = await client.query(
|
||||
`SELECT * FROM financing_plans WHERE active = TRUE ORDER BY due_date ASC`
|
||||
);
|
||||
for (const plan of activePlans.rows) {
|
||||
// Determine which paycheck(s) this plan applies to
|
||||
const targets = plan.assigned_paycheck == null ? [1, 2] : [plan.assigned_paycheck];
|
||||
for (const pcNum of targets) {
|
||||
const paycheckId = paycheckIds[pcNum - 1];
|
||||
// Calculate per-period amount (for split plans, this is the full per-period amount; we halve below)
|
||||
const fullAmount = await calcPaymentAmount(client, plan, year, month);
|
||||
if (fullAmount <= 0) continue;
|
||||
const amount = plan.assigned_paycheck == null
|
||||
? parseFloat((fullAmount / 2).toFixed(2))
|
||||
: fullAmount;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO financing_payments (plan_id, paycheck_id, amount)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (plan_id, paycheck_id) DO NOTHING`,
|
||||
[plan.id, paycheckId, amount]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return paycheckIds;
|
||||
} catch (err) {
|
||||
@@ -185,6 +253,27 @@ async function fetchPaychecksForMonth(year, month) {
|
||||
[pc.id]
|
||||
);
|
||||
|
||||
const financingResult = await pool.query(
|
||||
`SELECT fp.id AS financing_payment_id,
|
||||
fp.plan_id,
|
||||
fp.amount,
|
||||
fp.paid,
|
||||
fp.paid_at,
|
||||
pl.name,
|
||||
pl.due_date,
|
||||
pl.total_amount,
|
||||
COALESCE(SUM(fp2.amount) FILTER (WHERE fp2.paid), 0) AS paid_total
|
||||
FROM financing_payments fp
|
||||
JOIN financing_plans pl ON pl.id = fp.plan_id
|
||||
LEFT JOIN financing_payments fp2 ON fp2.plan_id = fp.plan_id AND fp2.paid = TRUE
|
||||
WHERE fp.paycheck_id = $1
|
||||
GROUP BY fp.id, fp.plan_id, fp.amount, fp.paid, fp.paid_at, pl.name, pl.due_date, pl.total_amount
|
||||
ORDER BY pl.due_date`,
|
||||
[pc.id]
|
||||
);
|
||||
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
|
||||
paychecks.push({
|
||||
id: pc.id,
|
||||
period_year: pc.period_year,
|
||||
@@ -207,6 +296,18 @@ async function fetchPaychecksForMonth(year, month) {
|
||||
paid_at: b.paid_at,
|
||||
})),
|
||||
one_time_expenses: oteResult.rows,
|
||||
financing: financingResult.rows.map(f => ({
|
||||
financing_payment_id: f.financing_payment_id,
|
||||
plan_id: f.plan_id,
|
||||
name: f.name,
|
||||
amount: f.amount,
|
||||
paid: f.paid,
|
||||
paid_at: f.paid_at,
|
||||
due_date: f.due_date,
|
||||
total_amount: f.total_amount,
|
||||
paid_total: parseFloat(f.paid_total) || 0,
|
||||
overdue: new Date(f.due_date) < today && (parseFloat(f.total_amount) - (parseFloat(f.paid_total) || 0)) > 0,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user