2026-07-06 11:26:50 -05:00
|
|
|
import type { Req, Res, Next } from '../types/http';
|
2026-05-04 13:14:32 -05:00
|
|
|
const express = require('express');
|
2026-07-10 17:52:47 -05:00
|
|
|
const { ValidationError } = require('../utils/apiError.cts');
|
2026-05-04 13:14:32 -05:00
|
|
|
const router = express.Router();
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
|
|
|
const { getDb } = require('../db/database.cts');
|
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
|
|
|
const { buildTrackerRow, getCycleRange } = require('../services/statusService.cts');
|
2026-07-06 11:00:01 -05:00
|
|
|
const { getUserSettings } = require('../services/userSettings.cts');
|
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
|
|
|
const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
|
2026-06-07 15:53:46 -05:00
|
|
|
const {
|
|
|
|
|
feedUrlForToken,
|
|
|
|
|
getActiveToken,
|
|
|
|
|
getOrCreateToken,
|
|
|
|
|
previewFeed,
|
|
|
|
|
regenerateToken,
|
|
|
|
|
revokeToken,
|
2026-07-06 10:54:10 -05:00
|
|
|
} = require('../services/calendarFeedService.cts');
|
2026-07-05 13:48:44 -05:00
|
|
|
const { localDateString } = require('../utils/dates.mts');
|
2026-07-05 13:44:04 -05:00
|
|
|
const { roundMoney, sumMoney, fromCents } = require('../utils/money.mts');
|
2026-05-04 13:14:32 -05:00
|
|
|
|
2026-07-11 06:57:41 -05:00
|
|
|
function toDateString(year: number, month: number, day: number) {
|
2026-05-04 13:14:32 -05:00
|
|
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 06:57:41 -05:00
|
|
|
function emptyDay(year: number, month: number, day: number) {
|
2026-05-04 13:14:32 -05:00
|
|
|
return {
|
|
|
|
|
date: toDateString(year, month, day),
|
|
|
|
|
day,
|
2026-07-11 06:57:41 -05:00
|
|
|
bills_due: [] as any[],
|
|
|
|
|
payments: [] as any[],
|
2026-05-04 13:14:32 -05:00
|
|
|
status_summary: {
|
|
|
|
|
due_count: 0,
|
|
|
|
|
paid_count: 0,
|
|
|
|
|
skipped_count: 0,
|
|
|
|
|
missed_count: 0,
|
|
|
|
|
total_due: 0,
|
|
|
|
|
total_paid: 0,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 06:57:41 -05:00
|
|
|
function tokenPayload(req: Req, tokenRow: any) {
|
2026-06-07 15:53:46 -05:00
|
|
|
if (!tokenRow) {
|
|
|
|
|
return {
|
|
|
|
|
active: false,
|
|
|
|
|
token: null,
|
|
|
|
|
feed_url: null,
|
|
|
|
|
created_at: null,
|
|
|
|
|
last_used_at: null,
|
|
|
|
|
revoked_at: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
active: !!tokenRow.active,
|
|
|
|
|
token: tokenRow.token,
|
|
|
|
|
feed_url: feedUrlForToken(req, tokenRow.token),
|
|
|
|
|
created_at: tokenRow.created_at,
|
|
|
|
|
last_used_at: tokenRow.last_used_at,
|
|
|
|
|
revoked_at: tokenRow.revoked_at,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GET /api/calendar/feed — current user's calendar feed token and URL
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/feed', (req: Req, res: Res) => {
|
2026-06-07 15:53:46 -05:00
|
|
|
res.json(tokenPayload(req, getActiveToken(req.user.id)));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/calendar/feed — create the feed token if one does not exist
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/feed', (req: Req, res: Res) => {
|
2026-06-07 15:53:46 -05:00
|
|
|
const token = getOrCreateToken(req.user.id);
|
|
|
|
|
res.status(201).json(tokenPayload(req, token));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/calendar/feed/regenerate — revoke old token and issue a new URL
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/feed/regenerate', (req: Req, res: Res) => {
|
2026-06-07 15:53:46 -05:00
|
|
|
const token = regenerateToken(req.user.id);
|
|
|
|
|
res.json(tokenPayload(req, token));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// DELETE /api/calendar/feed — revoke the active feed URL
|
2026-07-06 11:26:50 -05:00
|
|
|
router.delete('/feed', (req: Req, res: Res) => {
|
2026-06-07 15:53:46 -05:00
|
|
|
revokeToken(req.user.id);
|
|
|
|
|
res.json(tokenPayload(req, null));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/calendar/feed/preview — next generated events shown before subscribing
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/feed/preview', (req: Req, res: Res) => {
|
2026-06-07 15:53:46 -05:00
|
|
|
const limit = Math.min(Math.max(parseInt(req.query.limit || '10', 10) || 10, 1), 50);
|
|
|
|
|
res.json({
|
|
|
|
|
events: previewFeed(req.user.id, { limit }),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
// GET /api/calendar?year=2026&month=5
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/', (req: Req, res: Res) => {
|
2026-05-04 13:14:32 -05:00
|
|
|
const db = getDb();
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const year = parseInt(req.query.year || now.getFullYear(), 10);
|
|
|
|
|
const month = parseInt(req.query.month || now.getMonth() + 1, 10);
|
|
|
|
|
|
|
|
|
|
if (isNaN(year) || year < 2000 || year > 2100) {
|
2026-07-10 17:52:47 -05:00
|
|
|
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
|
2026-05-04 13:14:32 -05:00
|
|
|
}
|
|
|
|
|
if (isNaN(month) || month < 1 || month > 12) {
|
2026-07-10 17:52:47 -05:00
|
|
|
throw ValidationError('month must be an integer between 1 and 12', 'month');
|
2026-05-04 13:14:32 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-10 19:42:51 -05:00
|
|
|
const today = localDateString(now);
|
2026-05-15 22:45:38 -05:00
|
|
|
const userSettings = getUserSettings(req.user.id);
|
2026-07-06 14:23:53 -05:00
|
|
|
const rowOptions = {
|
|
|
|
|
gracePeriodDays: userSettings.grace_period_days,
|
|
|
|
|
dueSoonDays: userSettings.due_soon_days,
|
|
|
|
|
};
|
2026-05-04 13:14:32 -05:00
|
|
|
const daysInMonth = new Date(year, month, 0).getDate();
|
|
|
|
|
const { start, end } = getCycleRange(year, month);
|
|
|
|
|
const days = Array.from({ length: daysInMonth }, (_, index) => emptyDay(year, month, index + 1));
|
2026-07-06 14:23:53 -05:00
|
|
|
const dayByDate = new Map(days.map((day) => [day.date, day]));
|
2026-05-04 13:14:32 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const bills = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-05-04 13:14:32 -05:00
|
|
|
SELECT b.*, c.name AS category_name
|
|
|
|
|
FROM bills b
|
2026-05-16 10:34:32 -05:00
|
|
|
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
|
|
|
|
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
|
2026-05-04 13:14:32 -05:00
|
|
|
ORDER BY b.due_day ASC, b.name ASC
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.all(req.user.id);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
const paymentsByBillStmt = db.prepare(`
|
|
|
|
|
SELECT *
|
|
|
|
|
FROM payments
|
|
|
|
|
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
|
|
|
|
|
AND deleted_at IS NULL
|
2026-06-07 01:05:48 -05:00
|
|
|
AND ${accountingActiveSql()}
|
2026-05-04 13:14:32 -05:00
|
|
|
ORDER BY paid_date DESC
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const monthlyStateStmt = db.prepare(`
|
|
|
|
|
SELECT actual_amount, notes, is_skipped
|
|
|
|
|
FROM monthly_bill_state
|
|
|
|
|
WHERE bill_id = ? AND year = ? AND month = ?
|
|
|
|
|
`);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const payments = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-05-04 13:14:32 -05:00
|
|
|
SELECT
|
|
|
|
|
p.id AS payment_id,
|
|
|
|
|
p.bill_id,
|
|
|
|
|
b.name AS bill_name,
|
|
|
|
|
p.amount,
|
|
|
|
|
p.paid_date,
|
|
|
|
|
p.method,
|
|
|
|
|
p.notes
|
|
|
|
|
FROM payments p
|
|
|
|
|
JOIN bills b ON p.bill_id = b.id
|
|
|
|
|
WHERE b.user_id = ?
|
2026-05-16 10:34:32 -05:00
|
|
|
AND b.deleted_at IS NULL
|
2026-05-04 13:14:32 -05:00
|
|
|
AND p.paid_date BETWEEN ? AND ?
|
|
|
|
|
AND p.deleted_at IS NULL
|
2026-06-07 01:05:48 -05:00
|
|
|
AND ${accountingActiveSql('p')}
|
2026-05-04 13:14:32 -05:00
|
|
|
ORDER BY p.paid_date ASC, b.name ASC
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.all(req.user.id, start, end);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
for (const payment of payments) {
|
|
|
|
|
const day = dayByDate.get(payment.paid_date);
|
|
|
|
|
if (day) {
|
2026-06-11 20:12:31 -05:00
|
|
|
const amount = fromCents(payment.amount);
|
2026-05-04 13:14:32 -05:00
|
|
|
day.payments.push({
|
|
|
|
|
payment_id: payment.payment_id,
|
|
|
|
|
bill_id: payment.bill_id,
|
|
|
|
|
bill_name: payment.bill_name,
|
2026-06-11 20:12:31 -05:00
|
|
|
amount,
|
2026-05-04 13:14:32 -05:00
|
|
|
paid_date: payment.paid_date,
|
|
|
|
|
method: payment.method || null,
|
|
|
|
|
notes: payment.notes || null,
|
|
|
|
|
});
|
2026-06-11 20:12:31 -05:00
|
|
|
day.status_summary.total_paid += amount || 0;
|
2026-05-04 13:14:32 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const calendarBills = bills
|
2026-07-11 06:57:41 -05:00
|
|
|
.map((bill: any) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const billRange = getCycleRange(year, month, bill);
|
|
|
|
|
if (!billRange) return null;
|
2026-05-16 20:26:09 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const billPayments = paymentsByBillStmt.all(bill.id, billRange.start, billRange.end);
|
|
|
|
|
const row = buildTrackerRow(bill, billPayments, year, month, today, rowOptions);
|
|
|
|
|
if (!row) return null;
|
2026-05-16 20:26:09 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const monthlyState = monthlyStateStmt.get(bill.id, year, month);
|
|
|
|
|
const actualAmount = fromCents(monthlyState?.actual_amount);
|
|
|
|
|
const isSkipped = !!monthlyState?.is_skipped;
|
|
|
|
|
const effectiveAmount = actualAmount ?? row.expected_amount;
|
|
|
|
|
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= effectiveAmount;
|
|
|
|
|
const isAutodraft = row.status === 'autodraft';
|
|
|
|
|
const status = isSkipped ? 'skipped' : isPaidByThreshold ? 'paid' : row.status;
|
|
|
|
|
const isPaid = status === 'paid' || isAutodraft;
|
2026-05-04 13:14:32 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
return {
|
|
|
|
|
bill_id: bill.id,
|
|
|
|
|
name: bill.name,
|
|
|
|
|
due_date: row.due_date,
|
|
|
|
|
due_day: Number(row.due_date.slice(8, 10)),
|
|
|
|
|
expected_amount: row.expected_amount,
|
|
|
|
|
actual_amount: actualAmount,
|
|
|
|
|
effective_amount: effectiveAmount,
|
|
|
|
|
category_name: bill.category_name || null,
|
|
|
|
|
is_paid: isPaid,
|
|
|
|
|
is_skipped: isSkipped,
|
|
|
|
|
paid_amount: row.total_paid || 0,
|
|
|
|
|
status,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter(Boolean);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
for (const bill of calendarBills) {
|
|
|
|
|
const day = dayByDate.get(bill.due_date);
|
|
|
|
|
if (!day) continue;
|
|
|
|
|
|
|
|
|
|
day.bills_due.push(bill);
|
|
|
|
|
day.status_summary.due_count += 1;
|
|
|
|
|
if (bill.is_paid) day.status_summary.paid_count += 1;
|
|
|
|
|
if (bill.is_skipped) day.status_summary.skipped_count += 1;
|
|
|
|
|
if (!bill.is_paid && !bill.is_skipped && (bill.status === 'late' || bill.status === 'missed')) {
|
|
|
|
|
day.status_summary.missed_count += 1;
|
|
|
|
|
}
|
|
|
|
|
if (!bill.is_skipped) day.status_summary.total_due += bill.effective_amount || 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 06:57:41 -05:00
|
|
|
const activeBills = calendarBills.filter((bill: any) => !bill.is_skipped);
|
|
|
|
|
const expectedTotal = sumMoney(activeBills, (bill: any) => bill.effective_amount || 0);
|
|
|
|
|
const paidTotal = sumMoney(activeBills, (bill: any) => bill.paid_amount || 0);
|
2026-06-10 20:14:13 -05:00
|
|
|
const remainingTotal = Math.max(0, roundMoney(expectedTotal - paidTotal));
|
2026-07-06 14:23:53 -05:00
|
|
|
const paidPercent =
|
|
|
|
|
expectedTotal > 0 ? Math.min(100, Math.round((paidTotal / expectedTotal) * 100)) : 0;
|
2026-05-04 13:14:32 -05:00
|
|
|
|
2026-06-10 20:14:13 -05:00
|
|
|
// Cent-exact: the per-day loops above accumulate floats; settle them here.
|
|
|
|
|
for (const day of days) {
|
|
|
|
|
day.status_summary.total_paid = roundMoney(day.status_summary.total_paid);
|
2026-07-06 14:23:53 -05:00
|
|
|
day.status_summary.total_due = roundMoney(day.status_summary.total_due);
|
2026-06-10 20:14:13 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
res.json({
|
|
|
|
|
year,
|
|
|
|
|
month,
|
|
|
|
|
today,
|
|
|
|
|
days,
|
|
|
|
|
summary: {
|
|
|
|
|
expected_total: expectedTotal,
|
|
|
|
|
paid_total: paidTotal,
|
|
|
|
|
remaining_total: remainingTotal,
|
|
|
|
|
paid_percent: paidPercent,
|
|
|
|
|
bill_count: activeBills.length,
|
2026-07-11 06:57:41 -05:00
|
|
|
paid_count: activeBills.filter((bill: any) => bill.is_paid).length,
|
|
|
|
|
skipped_count: calendarBills.filter((bill: any) => bill.is_skipped).length,
|
|
|
|
|
missed_count: activeBills.filter(
|
|
|
|
|
(bill: any) => bill.status === 'late' || bill.status === 'missed',
|
|
|
|
|
).length,
|
2026-05-04 13:14:32 -05:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
module.exports = router;
|