2026-07-06 11:26:50 -05:00
|
|
|
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
|
|
|
|
|
import type { Req, Res, Next } from '../types/http';
|
2026-05-04 20:12:57 -05:00
|
|
|
const express = require('express');
|
|
|
|
|
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 { getCycleRange } = require('../services/statusService.cts');
|
|
|
|
|
const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
|
2026-07-05 13:44:04 -05:00
|
|
|
const { toCents, fromCents } = require('../utils/money.mts');
|
2026-05-04 20:12:57 -05:00
|
|
|
|
|
|
|
|
function parseYearMonth(source) {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const year = parseInt(source.year || now.getFullYear(), 10);
|
|
|
|
|
const month = parseInt(source.month || now.getMonth() + 1, 10);
|
|
|
|
|
|
|
|
|
|
if (Number.isNaN(year) || year < 2000 || year > 2100) {
|
|
|
|
|
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
|
|
|
|
|
}
|
|
|
|
|
if (Number.isNaN(month) || month < 1 || month > 12) {
|
|
|
|
|
return { error: 'month must be an integer between 1 and 12' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { year, month };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function money(value) {
|
|
|
|
|
const n = Number(value);
|
2026-05-31 15:06:10 -05:00
|
|
|
return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
|
2026-05-04 20:12:57 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getStartingAmounts(db, userId, year, month) {
|
2026-07-06 14:23:53 -05:00
|
|
|
const row = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-05-04 20:12:57 -05:00
|
|
|
SELECT first_amount, fifteenth_amount, other_amount
|
|
|
|
|
FROM monthly_starting_amounts
|
|
|
|
|
WHERE user_id = ? AND year = ? AND month = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.get(userId, year, month);
|
2026-05-04 20:12:57 -05:00
|
|
|
|
|
|
|
|
return {
|
2026-06-11 20:12:31 -05:00
|
|
|
first_amount: fromCents(row?.first_amount || 0),
|
|
|
|
|
fifteenth_amount: fromCents(row?.fifteenth_amount || 0),
|
|
|
|
|
other_amount: fromCents(row?.other_amount || 0),
|
2026-05-04 20:12:57 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function calculatePaidDeductions(db, userId, year, month) {
|
|
|
|
|
const { start, end } = getCycleRange(year, month);
|
|
|
|
|
|
|
|
|
|
// Paid from first bucket: bills with due_day 1-14
|
2026-07-06 14:23:53 -05:00
|
|
|
const firstPaid = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-05-04 20:12:57 -05:00
|
|
|
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
|
|
|
|
FROM payments p
|
|
|
|
|
JOIN bills b ON b.id = p.bill_id
|
|
|
|
|
WHERE b.user_id = ?
|
|
|
|
|
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 20:12:57 -05:00
|
|
|
AND b.due_day BETWEEN 1 AND 14
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.get(userId, start, end);
|
2026-05-04 20:12:57 -05:00
|
|
|
|
|
|
|
|
// Paid from fifteenth bucket: bills with due_day 15-31
|
2026-07-06 14:23:53 -05:00
|
|
|
const fifteenthPaid = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-05-04 20:12:57 -05:00
|
|
|
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
|
|
|
|
FROM payments p
|
|
|
|
|
JOIN bills b ON b.id = p.bill_id
|
|
|
|
|
WHERE b.user_id = ?
|
|
|
|
|
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 20:12:57 -05:00
|
|
|
AND b.due_day BETWEEN 15 AND 31
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.get(userId, start, end);
|
2026-05-04 20:12:57 -05:00
|
|
|
|
2026-05-11 15:00:35 -05:00
|
|
|
// Paid from other bucket: bills with due_day outside 1-14 and 15-31 (shouldn't happen with current schema)
|
2026-07-06 14:23:53 -05:00
|
|
|
const otherPaid = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-05-11 15:00:35 -05:00
|
|
|
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
|
|
|
|
FROM payments p
|
|
|
|
|
JOIN bills b ON b.id = p.bill_id
|
|
|
|
|
WHERE b.user_id = ?
|
|
|
|
|
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-11 15:00:35 -05:00
|
|
|
AND (b.due_day < 1 OR b.due_day > 31)
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.get(userId, start, end);
|
2026-05-11 15:00:35 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const totalPaid = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-05-04 20:12:57 -05:00
|
|
|
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
|
|
|
|
FROM payments p
|
|
|
|
|
JOIN bills b ON b.id = p.bill_id
|
|
|
|
|
WHERE b.user_id = ?
|
|
|
|
|
AND p.paid_date BETWEEN ? AND ?
|
|
|
|
|
AND p.deleted_at IS NULL
|
2026-06-07 01:05:48 -05:00
|
|
|
AND ${accountingActiveSql('p')}
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.get(userId, start, end);
|
2026-05-04 20:12:57 -05:00
|
|
|
|
|
|
|
|
return {
|
2026-06-11 20:12:31 -05:00
|
|
|
paid_from_first: fromCents(firstPaid.paid),
|
|
|
|
|
paid_from_fifteenth: fromCents(fifteenthPaid.paid),
|
|
|
|
|
paid_from_other: fromCents(otherPaid.paid),
|
|
|
|
|
paid_total: fromCents(totalPaid.paid),
|
2026-05-04 20:12:57 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildStartingAmountsResponse(db, userId, year, month) {
|
|
|
|
|
const amounts = getStartingAmounts(db, userId, year, month);
|
|
|
|
|
const paid = calculatePaidDeductions(db, userId, year, month);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const combined_amount = money(
|
|
|
|
|
amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount,
|
|
|
|
|
);
|
2026-05-04 20:12:57 -05:00
|
|
|
const paid_total = paid.paid_total;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
year,
|
|
|
|
|
month,
|
|
|
|
|
first_amount: amounts.first_amount,
|
|
|
|
|
fifteenth_amount: amounts.fifteenth_amount,
|
|
|
|
|
other_amount: amounts.other_amount,
|
|
|
|
|
combined_amount,
|
|
|
|
|
paid_from_first: paid.paid_from_first,
|
|
|
|
|
paid_from_fifteenth: paid.paid_from_fifteenth,
|
2026-05-11 15:00:35 -05:00
|
|
|
paid_from_other: paid.paid_from_other,
|
2026-05-04 20:12:57 -05:00
|
|
|
paid_total,
|
2026-05-31 15:06:10 -05:00
|
|
|
first_remaining: money(amounts.first_amount - paid.paid_from_first),
|
|
|
|
|
fifteenth_remaining: money(amounts.fifteenth_amount - paid.paid_from_fifteenth),
|
|
|
|
|
other_remaining: money(amounts.other_amount - paid.paid_from_other),
|
|
|
|
|
combined_remaining: money(combined_amount - paid_total),
|
2026-05-04 20:12:57 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/', (req: Req, res: Res) => {
|
2026-05-04 20:12:57 -05:00
|
|
|
const parsed = parseYearMonth(req.query);
|
|
|
|
|
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
|
|
|
|
|
|
|
|
|
const db = getDb();
|
|
|
|
|
res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 11:26:50 -05:00
|
|
|
router.put('/', (req: Req, res: Res) => {
|
2026-05-04 20:12:57 -05:00
|
|
|
const parsed = parseYearMonth(req.body || {});
|
|
|
|
|
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
|
|
|
|
|
|
|
|
|
const firstAmount = Number(req.body?.first_amount);
|
|
|
|
|
if (!Number.isFinite(firstAmount) || firstAmount < 0 || firstAmount > 1000000000) {
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: 'first_amount must be a number between 0 and 1000000000' });
|
2026-05-04 20:12:57 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fifteenthAmount = Number(req.body?.fifteenth_amount);
|
|
|
|
|
if (!Number.isFinite(fifteenthAmount) || fifteenthAmount < 0 || fifteenthAmount > 1000000000) {
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: 'fifteenth_amount must be a number between 0 and 1000000000' });
|
2026-05-04 20:12:57 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const otherAmount = Number(req.body?.other_amount);
|
|
|
|
|
if (!Number.isFinite(otherAmount) || otherAmount < 0 || otherAmount > 1000000000) {
|
2026-07-06 14:23:53 -05:00
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: 'other_amount must be a number between 0 and 1000000000' });
|
2026-05-04 20:12:57 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
db.prepare(
|
|
|
|
|
`
|
2026-05-04 20:12:57 -05:00
|
|
|
INSERT INTO monthly_starting_amounts (user_id, year, month, first_amount, fifteenth_amount, other_amount, updated_at)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
|
|
|
ON CONFLICT(user_id, year, month) DO UPDATE SET
|
|
|
|
|
first_amount = excluded.first_amount,
|
|
|
|
|
fifteenth_amount = excluded.fifteenth_amount,
|
|
|
|
|
other_amount = excluded.other_amount,
|
|
|
|
|
updated_at = datetime('now')
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
).run(
|
|
|
|
|
req.user.id,
|
|
|
|
|
parsed.year,
|
|
|
|
|
parsed.month,
|
|
|
|
|
toCents(firstAmount),
|
|
|
|
|
toCents(fifteenthAmount),
|
|
|
|
|
toCents(otherAmount),
|
|
|
|
|
);
|
2026-05-04 20:12:57 -05:00
|
|
|
|
|
|
|
|
res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
module.exports = router;
|