feat: add Auto Check MH Payment toggle with weekly schedule

Adds a toggle behind the Go button on the Payments page to automatically run the MH batch payment check on a user-selected day of week and hour. Default is off.

- Schema: added autoMhCheckEnabled, autoMhCheckDayOfWeek, autoMhCheckHour to User model
- Backend: new mhBatchPaymentService (shared logic), GET/PUT /auto-mh-check-setting routes, hourly cron job that fires on matching day+hour
- Frontend: toggle + day select (Mon–Sun) + time select (hourly) that persist immediately to DB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:58:36 -04:00
parent 27d9132820
commit fc3e8c0e25
310 changed files with 2821 additions and 2015 deletions

View File

@@ -9,6 +9,7 @@ import { runNetworkSync, runNetworkFilesSync } from "../services/networkSyncServ
import { readRcloneConfig, writeRcloneConfig } from "../services/rcloneConfigService";
import { runRclonePull } from "../services/rcloneService";
import { importLatestBackup } from "../services/autoImportService";
import { runMhBatchPaymentCheck } from "../services/mhBatchPaymentService";
// Local backup folder in the app root (apps/Backend/backups)
const LOCAL_BACKUP_DIR = path.resolve(process.cwd(), "backups");
@@ -267,4 +268,47 @@ export const startBackupCron = () => {
}
}
});
// ============================================================
// Every hour — Auto MH payment check (runs when day-of-week and hour match setting)
// ============================================================
cron.schedule("0 * * * *", async () => {
const admin = await getAdminUser();
if (!admin) return;
if (!admin.autoMhCheckEnabled) return;
const now = new Date();
const currentDayOfWeek = now.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
const currentHour = now.getHours();
const targetDay = admin.autoMhCheckDayOfWeek ?? 1;
const targetHour = admin.autoMhCheckHour ?? 13;
if (currentDayOfWeek !== targetDay || currentHour !== targetHour) return;
console.log(`🔄 [Auto MH Check] Running scheduled MH batch payment check...`);
const startedAt = new Date();
const log = await cronJobLogStorage.createJobLog("auto-mh-check", startedAt);
try {
// Check the past 7 days
const toDate = now.toISOString().split("T")[0];
const fromDateObj = new Date(now);
fromDateObj.setDate(fromDateObj.getDate() - 7);
const fromDate = fromDateObj.toISOString().split("T")[0];
const result = await runMhBatchPaymentCheck(admin.id, fromDate, toDate);
await cronJobLogStorage.completeJobLog(log.id, "success", new Date());
console.log(`✅ Auto MH Check done: ${result.importSummary ?? result.message ?? "complete"}`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error("Auto MH Check failed:", err);
await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage);
await storage.createNotification(
admin.id,
"BACKUP",
`❌ Auto MH payment check failed: ${errorMessage}`
);
}
});
};

View File

@@ -333,6 +333,42 @@ router.put("/auto-backup-setting", async (req, res) => {
res.json({ autoBackupEnabled: updated.autoBackupEnabled, autoBackupHour: updated.autoBackupHour ?? 20 });
});
// GET auto MH check setting
router.get("/auto-mh-check-setting", async (req, res) => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
const user = await storage.getUser(userId);
if (!user) return res.status(404).json({ error: "User not found" });
res.json({
autoMhCheckEnabled: user.autoMhCheckEnabled,
autoMhCheckDayOfWeek: user.autoMhCheckDayOfWeek ?? 1,
autoMhCheckHour: user.autoMhCheckHour ?? 13,
});
});
// PUT auto MH check setting
router.put("/auto-mh-check-setting", async (req, res) => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: "Unauthorized" });
const { autoMhCheckEnabled, autoMhCheckDayOfWeek, autoMhCheckHour } = req.body;
const patch: any = {};
if (typeof autoMhCheckEnabled === "boolean") patch.autoMhCheckEnabled = autoMhCheckEnabled;
if (typeof autoMhCheckDayOfWeek === "number") patch.autoMhCheckDayOfWeek = autoMhCheckDayOfWeek;
if (typeof autoMhCheckHour === "number") patch.autoMhCheckHour = autoMhCheckHour;
const updated = await storage.updateUser(userId, patch);
if (!updated) return res.status(404).json({ error: "User not found" });
res.json({
autoMhCheckEnabled: updated.autoMhCheckEnabled,
autoMhCheckDayOfWeek: updated.autoMhCheckDayOfWeek ?? 1,
autoMhCheckHour: updated.autoMhCheckHour ?? 13,
});
});
router.post("/backup-path", async (req, res) => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: "Unauthorized" });

View File

@@ -16,51 +16,7 @@ import { prisma } from "@repo/db/client";
import { PaymentStatusSchema } from "@repo/db/types";
import * as paymentService from "../services/paymentService";
import { callPythonSync } from "../queue/processors/_shared";
import fs from "fs";
import path from "path";
import axios from "axios";
import FormData from "form-data";
const VOUCHER_DIR = path.join(__dirname, "..", "..", "uploads", "MHVoucher");
const IMPORTED_LOG = path.join(VOUCHER_DIR, "imported_vouchers.json");
const OCR_BASE_URL = process.env.OCR_SERVICE_BASE_URL || "http://localhost:5003";
function loadImportedVouchers(): Set<string> {
try {
if (!fs.existsSync(IMPORTED_LOG)) return new Set();
const data = JSON.parse(fs.readFileSync(IMPORTED_LOG, "utf-8"));
return new Set(data.imported ?? []);
} catch {
return new Set();
}
}
function saveImportedVoucher(voucher: string) {
const existing = loadImportedVouchers();
existing.add(voucher);
fs.writeFileSync(IMPORTED_LOG, JSON.stringify({ imported: [...existing].sort() }, null, 2));
}
async function extractAndImportVoucherPdf(filePath: string, userId: number): Promise<{ paymentIds: number[]; rowCount: number }> {
const buffer = fs.readFileSync(filePath);
const filename = path.basename(filePath);
const form = new FormData();
form.append("files", buffer, { filename, contentType: "application/pdf", knownLength: buffer.length });
const resp = await axios.post<{ rows: any[] }>(
`${OCR_BASE_URL}/extract/pdf/json`,
form,
{ headers: form.getHeaders(), maxBodyLength: Infinity, maxContentLength: Infinity, timeout: 120_000 }
);
const rows = resp.data?.rows ?? [];
console.log(`[mh-batch] Extracted ${rows.length} rows from ${filename}`);
if (rows.length === 0) return { paymentIds: [], rowCount: 0 };
const paymentIds = await paymentService.pdfImportService.importRows(rows, userId);
return { paymentIds, rowCount: rows.length };
}
import { runMhBatchPaymentCheck } from "../services/mhBatchPaymentService";
const paymentFilterSchema = z.object({
from: z.string().datetime(),
@@ -246,60 +202,8 @@ router.post(
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { fromDate, toDate } = req.body;
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(userId, "MH");
if (!credentials) {
return res.status(404).json({
message: "No MassHealth credentials found. Please add them in Settings.",
});
}
const seleniumResult = await callPythonSync("/mh-batch-payment-check", {
data: {
massdhpUsername: credentials.username,
massdhpPassword: credentials.password,
fromDate,
toDate,
},
});
// --- PDF import phase ---
const alreadyImported = loadImportedVouchers();
const allPdfs = fs.existsSync(VOUCHER_DIR)
? fs.readdirSync(VOUCHER_DIR).filter((f) => f.endsWith(".pdf") && !f.startsWith("remittance_search_"))
: [];
const newPdfs = allPdfs.filter((f) => !alreadyImported.has(f.replace(".pdf", "")));
console.log(`[mh-batch] ${allPdfs.length} voucher PDFs total, ${newPdfs.length} new to import`);
const importResults: { voucher: string; paymentIds: number[]; rowCount: number; error?: string }[] = [];
for (const pdfFile of newPdfs) {
const voucher = pdfFile.replace(".pdf", "");
const filePath = path.join(VOUCHER_DIR, pdfFile);
console.log(`[mh-batch] Extracting and importing: ${voucher}`);
try {
const { paymentIds, rowCount } = await extractAndImportVoucherPdf(filePath, userId);
saveImportedVoucher(voucher);
importResults.push({ voucher, paymentIds, rowCount });
console.log(`[mh-batch] ✓ ${voucher}: ${rowCount} rows → ${paymentIds.length} payment(s)`);
} catch (err: any) {
const errMsg = err?.response?.data?.detail ?? err.message ?? "Unknown error";
console.error(`[mh-batch] ✗ ${voucher}: ${errMsg}`);
importResults.push({ voucher, paymentIds: [], rowCount: 0, error: errMsg });
}
}
const succeeded = importResults.filter((r) => !r.error);
const failed = importResults.filter((r) => r.error);
return res.json({
...seleniumResult,
importResults,
importSummary: newPdfs.length === 0
? "All PDFs already imported."
: `${succeeded.length} of ${newPdfs.length} PDFs imported successfully${failed.length ? `, ${failed.length} failed` : ""}.`,
});
const result = await runMhBatchPaymentCheck(userId, fromDate, toDate);
return res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "MH batch payment check failed";
return res.status(500).json({ message });

View File

@@ -0,0 +1,107 @@
import fs from "fs";
import path from "path";
import axios from "axios";
import FormData from "form-data";
import { storage } from "../storage";
import { callPythonSync } from "../queue/processors/_shared";
import * as paymentService from "./paymentService";
const VOUCHER_DIR = path.join(__dirname, "..", "..", "uploads", "MHVoucher");
const IMPORTED_LOG = path.join(VOUCHER_DIR, "imported_vouchers.json");
const OCR_BASE_URL = process.env.OCR_SERVICE_BASE_URL || "http://localhost:5003";
function loadImportedVouchers(): Set<string> {
try {
if (!fs.existsSync(IMPORTED_LOG)) return new Set();
const data = JSON.parse(fs.readFileSync(IMPORTED_LOG, "utf-8"));
return new Set(data.imported ?? []);
} catch {
return new Set();
}
}
function saveImportedVoucher(voucher: string) {
const existing = loadImportedVouchers();
existing.add(voucher);
fs.writeFileSync(IMPORTED_LOG, JSON.stringify({ imported: [...existing].sort() }, null, 2));
}
async function extractAndImportVoucherPdf(filePath: string, userId: number): Promise<{ paymentIds: number[]; rowCount: number }> {
const buffer = fs.readFileSync(filePath);
const filename = path.basename(filePath);
const form = new FormData();
form.append("files", buffer, { filename, contentType: "application/pdf", knownLength: buffer.length });
const resp = await axios.post<{ rows: any[] }>(
`${OCR_BASE_URL}/extract/pdf/json`,
form,
{ headers: form.getHeaders(), maxBodyLength: Infinity, maxContentLength: Infinity, timeout: 120_000 }
);
const rows = resp.data?.rows ?? [];
console.log(`[mh-batch] Extracted ${rows.length} rows from ${filename}`);
if (rows.length === 0) return { paymentIds: [], rowCount: 0 };
const paymentIds = await paymentService.pdfImportService.importRows(rows, userId);
return { paymentIds, rowCount: rows.length };
}
export interface MhBatchCheckResult {
noResults?: boolean;
message?: string;
importSummary?: string;
importResults?: { voucher: string; paymentIds: number[]; rowCount: number; error?: string }[];
}
export async function runMhBatchPaymentCheck(userId: number, fromDate: string, toDate: string): Promise<MhBatchCheckResult> {
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(userId, "MH");
if (!credentials) {
throw new Error("No MassHealth credentials found. Please add them in Settings.");
}
const seleniumResult = await callPythonSync("/mh-batch-payment-check", {
data: {
massdhpUsername: credentials.username,
massdhpPassword: credentials.password,
fromDate,
toDate,
},
});
const alreadyImported = loadImportedVouchers();
const allPdfs = fs.existsSync(VOUCHER_DIR)
? fs.readdirSync(VOUCHER_DIR).filter((f) => f.endsWith(".pdf") && !f.startsWith("remittance_search_"))
: [];
const newPdfs = allPdfs.filter((f) => !alreadyImported.has(f.replace(".pdf", "")));
console.log(`[mh-batch] ${allPdfs.length} voucher PDFs total, ${newPdfs.length} new to import`);
const importResults: { voucher: string; paymentIds: number[]; rowCount: number; error?: string }[] = [];
for (const pdfFile of newPdfs) {
const voucher = pdfFile.replace(".pdf", "");
const filePath = path.join(VOUCHER_DIR, pdfFile);
try {
const { paymentIds, rowCount } = await extractAndImportVoucherPdf(filePath, userId);
saveImportedVoucher(voucher);
importResults.push({ voucher, paymentIds, rowCount });
console.log(`[mh-batch] ✓ ${voucher}: ${rowCount} rows → ${paymentIds.length} payment(s)`);
} catch (err: any) {
const errMsg = err?.response?.data?.detail ?? err.message ?? "Unknown error";
console.error(`[mh-batch] ✗ ${voucher}: ${errMsg}`);
importResults.push({ voucher, paymentIds: [], rowCount: 0, error: errMsg });
}
}
const succeeded = importResults.filter((r) => !r.error);
const failed = importResults.filter((r) => r.error);
return {
...seleniumResult,
importResults,
importSummary: newPdfs.length === 0
? "All PDFs already imported."
: `${succeeded.length} of ${newPdfs.length} PDFs imported successfully${failed.length ? `, ${failed.length} failed` : ""}.`,
};
}