From f85563ce99734d71e29162da6d5c03ec19f2e166 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 18 Mar 2026 11:24:59 -0500 Subject: [PATCH] finance --- CHANGELOG.md | 1 + README.md | 21 +- ROADMAP.md | 8 + SHIPPED.md | 1 + client/src/components/AppShell.tsx | 13 + client/src/lib/api.ts | 22 + client/src/main.tsx | 7 + client/src/modules/finance/FinancePage.tsx | 481 ++++++++++++++ .../migration.sql | 83 +++ server/prisma/schema.prisma | 73 +++ server/src/app.ts | 2 + server/src/lib/bootstrap.ts | 11 +- server/src/modules/finance/router.ts | 104 +++ server/src/modules/finance/service.ts | 619 ++++++++++++++++++ shared/src/auth/permissions.ts | 2 + shared/src/finance/types.ts | 131 ++++ shared/src/index.ts | 1 + 17 files changed, 1578 insertions(+), 2 deletions(-) create mode 100644 client/src/modules/finance/FinancePage.tsx create mode 100644 server/prisma/migrations/20260318012000_finance_module_foundation/migration.sql create mode 100644 server/src/modules/finance/router.ts create mode 100644 server/src/modules/finance/service.ts create mode 100644 shared/src/finance/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f98438..68b9f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh ### Added +- Finance module with customer-payment posting against sales orders, finance costing assumptions, sales-order cash/spend ledger rollups, manufacturing cost snapshots, and CapEx tracking for equipment, tooling, and consumables - Inventory-backed shipment picking from shipment detail pages, including sales-order line remaining-quantity visibility, warehouse/location source selection, issued-stock posting, and shipment pick history - Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups, plus direct launch paths into prefilled work-order and purchase-order follow-through and a chronological project activity timeline - Planning workbench replacing the old one-note planning screen with mode switching, dense exception rail, heatmap load view, agenda view, and focus drawer diff --git a/README.md b/README.md index 839b7cc..5ab2a59 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Current foundation scope includes: - purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items - purchase-order revision history and revision comparison across commercial and receipt changes - purchase receiving with warehouse/location posting and receipt history against purchase orders +- finance with sales-order-linked customer payments, live purchasing/manufacturing spend rollups, costing assumptions, and CapEx tracking for equipment, tooling, and consumables - branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline - purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files - shipping shipments linked to sales orders with inventory-backed picking, stock issue posting, packing slips, shipping labels, bills of lading, and logistics attachments @@ -89,6 +90,24 @@ Navigation direction: - new modules should add a clear, domain-appropriate SVG icon when they are added to the shell - icons should stay lightweight, theme-aware, and dependency-free unless there is a strong reason to introduce a shared icon package +## Finance Direction + +Finance is now a first-class domain for commercial cash tracking and capital planning rather than a hidden report stitched together from sales and purchasing screens. The current slice ships sales-order-linked payment posting, labor/overhead costing assumptions, cross-linked revenue versus purchasing/manufacturing spend rollups, and CapEx tracking for equipment, tooling, and consumables with optional purchase-order linkage. + +Current interactions: + +- Sales: customer receipts post against sales orders and update finance-ledger visibility for booked revenue, payments received, and open A/R +- Purchasing: linked PO lines contribute committed and received spend visibility to the sales-order finance ledger +- Manufacturing: issued material and recorded labor drive derived manufacturing/assembly cost rollups using finance-side labor and overhead assumptions +- Dashboard direction: finance should later contribute margin, cash, CapEx, and payment-risk widgets without replacing the operational dashboard + +Next expansion areas: + +- AP-side disbursements, invoice matching, and vendor payment workflows +- More granular manufacturing costing with crew rates, burden rules, and variance reporting +- Project-level P&L and earned-value style rollups across commercial, supply, and execution +- Accounting export/integration once the internal finance operating model is deeper + ## Projects Direction Projects are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, milestones, project-side milestone/work-order rollups, cockpit-style commercial/supply/execution/delivery/purchasing visibility, readiness-risk scoring, a cost snapshot from linked purchasing and manufacturing data, direct launch paths into prefilled purchasing/manufacturing follow-through, an activity timeline across linked execution records, notes, commercial document links, shipment links, attachments, and dashboard visibility. @@ -394,7 +413,7 @@ Current follow-up direction: ## UI Notes - Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation. -- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, purchasing, shipping, projects, manufacturing, settings, and planning modules from the same app shell. +- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, purchasing, finance, shipping, projects, manufacturing, settings, and planning modules from the same app shell. - The active module screens now follow a tighter density baseline for forms, tables, and detail cards. - The dashboard should continue evolving as a modular metric board for future purchasing, shipping, planning, and audit data. - The client now ships with route-level lazy loading and vendor chunking, so future frontend work should preserve that split instead of re-centralizing module imports in `main.tsx`. diff --git a/ROADMAP.md b/ROADMAP.md index 5c7304d..d6ecfc2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -64,6 +64,14 @@ This file tracks work that still needs to be completed. Shipped phase history an - Better totals breakdown visibility on list pages and detail pages - Faster document cloning and quote-to-order style conversions across document types +### Finance + +- Expand from customer receipts into AP disbursements, invoice matching, and vendor-payment control +- Add project-level P&L, cash posture, and earned-value style rollups across sales, purchasing, manufacturing, and shipping +- Deepen manufacturing costing with crew rates, burden rules, and variance reporting instead of only the current labor/overhead assumptions +- Add accounting export or integration surfaces once the internal finance workflows mature +- Add richer dashboard widgets for margin pressure, open receivables, CapEx exposure, and payment coverage risk + ### Shipping and logistics - Partial shipment workflow and split-shipment visibility diff --git a/SHIPPED.md b/SHIPPED.md index 3deb6cc..b047fb3 100644 --- a/SHIPPED.md +++ b/SHIPPED.md @@ -28,6 +28,7 @@ This file tracks roadmap phases, slices, and major foundations that have already - Purchase orders with vendor lookup, item lines, totals, and quick status actions - Purchase-order line selection restricted to inventory items flagged as purchasable - Purchase receiving foundation with warehouse/location posting, receipt history, and per-line received quantity tracking +- Finance module with sales-order-linked customer payments, live spend/margin rollups across linked purchase orders and manufacturing, finance costing assumptions, and CapEx tracking for equipment, tooling, and consumables - Branded sales quote, sales order, and purchase-order PDF templates through the shared Puppeteer pipeline - Shipping shipment records linked to sales orders - Inventory-backed shipment picking with stock issue posting from warehouse locations and shipment-side pick history diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index d9d0400..f16e445 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -14,6 +14,7 @@ const links = [ { to: "/sales/quotes", label: "Quotes", icon: }, { to: "/sales/orders", label: "Sales Orders", icon: }, { to: "/purchasing/orders", label: "Purchase Orders", icon: }, + { to: "/finance", label: "Finance", icon: }, { to: "/shipping/shipments", label: "Shipments", icon: }, { to: "/projects", label: "Projects", icon: }, { to: "/manufacturing/work-orders", label: "Manufacturing", icon: }, @@ -146,6 +147,18 @@ function ShipmentIcon() { ); } +function FinanceIcon() { + return ( + + + + + + + + ); +} + function WorkbenchIcon() { return ( diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index f7c2fae..e22307c 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -14,6 +14,13 @@ import type { ApiResponse, CompanyProfileDto, CompanyProfileInput, + FinanceCapexDto, + FinanceCapexInput, + FinanceCustomerPaymentDto, + FinanceCustomerPaymentInput, + FinanceDashboardDto, + FinanceProfileDto, + FinanceProfileInput, FileAttachmentDto, PlanningTimelineDto, LoginRequest, @@ -287,6 +294,21 @@ export const api = { token ); }, + getFinanceDashboard(token: string) { + return request("/api/v1/finance/overview", undefined, token); + }, + updateFinanceProfile(token: string, payload: FinanceProfileInput) { + return request("/api/v1/finance/profile", { method: "PUT", body: JSON.stringify(payload) }, token); + }, + createFinancePayment(token: string, payload: FinanceCustomerPaymentInput) { + return request("/api/v1/finance/payments", { method: "POST", body: JSON.stringify(payload) }, token); + }, + createCapexEntry(token: string, payload: FinanceCapexInput) { + return request("/api/v1/finance/capex", { method: "POST", body: JSON.stringify(payload) }, token); + }, + updateCapexEntry(token: string, capexId: string, payload: FinanceCapexInput) { + return request(`/api/v1/finance/capex/${capexId}`, { method: "PUT", body: JSON.stringify(payload) }, token); + }, getCustomers( token: string, filters?: { diff --git a/client/src/main.tsx b/client/src/main.tsx index 57ca44e..341aea3 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -101,6 +101,9 @@ const ShipmentDetailPage = React.lazy(() => const ShipmentFormPage = React.lazy(() => import("./modules/shipping/ShipmentFormPage").then((module) => ({ default: module.ShipmentFormPage })) ); +const FinancePage = React.lazy(() => + import("./modules/finance/FinancePage").then((module) => ({ default: module.FinancePage })) +); const WorkbenchPage = React.lazy(() => import("./modules/workbench/WorkbenchPage").then((module) => ({ default: module.WorkbenchPage })) ); @@ -201,6 +204,10 @@ const router = createBrowserRouter([ { path: "/shipping/shipments/:shipmentId", element: lazyElement() }, ], }, + { + element: , + children: [{ path: "/finance", element: lazyElement() }], + }, { element: , children: [ diff --git a/client/src/modules/finance/FinancePage.tsx b/client/src/modules/finance/FinancePage.tsx new file mode 100644 index 0000000..0244ee4 --- /dev/null +++ b/client/src/modules/finance/FinancePage.tsx @@ -0,0 +1,481 @@ +import { permissions } from "@mrp/shared"; +import type { + CapexCategory, + CapexStatus, + FinanceCapexInput, + FinanceCustomerPaymentInput, + FinanceDashboardDto, + FinancePaymentMethod, + FinancePaymentType, + FinanceProfileInput, +} from "@mrp/shared"; +import { capexCategories, capexStatuses, financePaymentMethods, financePaymentTypes } from "@mrp/shared"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + +import { useAuth } from "../../auth/AuthProvider"; +import { api, ApiError } from "../../lib/api"; + +function formatCurrency(value: number, currencyCode = "USD") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currencyCode, + maximumFractionDigits: 0, + }).format(value); +} + +function formatPercent(value: number) { + return `${value.toFixed(1)}%`; +} + +export function FinancePage() { + const { token, user } = useAuth(); + const canManage = user?.permissions.includes(permissions.financeWrite) ?? false; + const [dashboard, setDashboard] = useState(null); + const [salesOrders, setSalesOrders] = useState>>([]); + const [purchaseOrders, setPurchaseOrders] = useState>>([]); + const [vendors, setVendors] = useState>>([]); + const [status, setStatus] = useState("Loading finance workbench..."); + const [isSavingProfile, setIsSavingProfile] = useState(false); + const [isPostingPayment, setIsPostingPayment] = useState(false); + const [isSavingCapex, setIsSavingCapex] = useState(false); + const [editingCapexId, setEditingCapexId] = useState(null); + const [profileForm, setProfileForm] = useState({ + currencyCode: "USD", + standardLaborRatePerHour: 45, + overheadRatePerHour: 18, + }); + const [paymentForm, setPaymentForm] = useState({ + salesOrderId: "", + paymentType: "DEPOSIT", + paymentMethod: "ACH", + paymentDate: new Date().toISOString(), + amount: 0, + reference: "", + notes: "", + }); + const [capexForm, setCapexForm] = useState({ + title: "", + category: "EQUIPMENT", + status: "PLANNED", + vendorId: null, + purchaseOrderId: null, + plannedAmount: 0, + actualAmount: 0, + requestDate: new Date().toISOString(), + targetInServiceDate: null, + purchasedAt: null, + notes: "", + }); + + async function loadFinance(activeToken: string) { + const [nextDashboard, nextSalesOrders, nextPurchaseOrders, nextVendors] = await Promise.all([ + api.getFinanceDashboard(activeToken), + api.getSalesOrders(activeToken), + api.getPurchaseOrders(activeToken), + api.getPurchaseVendors(activeToken), + ]); + setDashboard(nextDashboard); + setSalesOrders(nextSalesOrders); + setPurchaseOrders(nextPurchaseOrders); + setVendors(nextVendors); + setProfileForm({ + currencyCode: nextDashboard.profile.currencyCode, + standardLaborRatePerHour: nextDashboard.profile.standardLaborRatePerHour, + overheadRatePerHour: nextDashboard.profile.overheadRatePerHour, + }); + setPaymentForm((current) => ({ + ...current, + salesOrderId: current.salesOrderId || nextSalesOrders[0]?.id || "", + })); + setStatus("Finance workbench loaded."); + } + + useEffect(() => { + if (!token) { + return; + } + + loadFinance(token).catch((error: unknown) => { + setStatus(error instanceof ApiError ? error.message : "Unable to load finance workbench."); + }); + }, [token]); + + function resetCapexForm() { + setEditingCapexId(null); + setCapexForm({ + title: "", + category: "EQUIPMENT", + status: "PLANNED", + vendorId: null, + purchaseOrderId: null, + plannedAmount: 0, + actualAmount: 0, + requestDate: new Date().toISOString(), + targetInServiceDate: null, + purchasedAt: null, + notes: "", + }); + } + + async function handleSaveProfile() { + if (!token) { + return; + } + + setIsSavingProfile(true); + setStatus("Saving finance assumptions..."); + try { + const nextProfile = await api.updateFinanceProfile(token, profileForm); + setDashboard((current) => (current ? { ...current, profile: nextProfile } : current)); + setStatus("Finance assumptions updated."); + } catch (error: unknown) { + setStatus(error instanceof ApiError ? error.message : "Unable to save finance assumptions."); + } finally { + setIsSavingProfile(false); + } + } + + async function handlePostPayment() { + if (!token) { + return; + } + + setIsPostingPayment(true); + setStatus("Posting customer payment..."); + try { + await api.createFinancePayment(token, paymentForm); + await loadFinance(token); + setPaymentForm((current) => ({ + ...current, + amount: 0, + reference: "", + notes: "", + paymentDate: new Date().toISOString(), + })); + setStatus("Customer payment posted."); + } catch (error: unknown) { + setStatus(error instanceof ApiError ? error.message : "Unable to post customer payment."); + } finally { + setIsPostingPayment(false); + } + } + + async function handleSaveCapex() { + if (!token) { + return; + } + + setIsSavingCapex(true); + setStatus(editingCapexId ? "Updating CapEx entry..." : "Creating CapEx entry..."); + try { + if (editingCapexId) { + await api.updateCapexEntry(token, editingCapexId, capexForm); + } else { + await api.createCapexEntry(token, capexForm); + } + await loadFinance(token); + resetCapexForm(); + setStatus(editingCapexId ? "CapEx entry updated." : "CapEx entry created."); + } catch (error: unknown) { + setStatus(error instanceof ApiError ? error.message : "Unable to save CapEx entry."); + } finally { + setIsSavingCapex(false); + } + } + + if (!dashboard) { + return
{status}
; + } + + const { profile, summary, salesOrderLedgers, payments, capex } = dashboard; + const currencyCode = profile.currencyCode || "USD"; + + return ( +
+
+
+
+

Finance

+

Cash, spend, and CapEx control

+

+ Track customer payments against sales orders, compare them to linked purchasing and manufacturing spend, and manage capital purchases for equipment, tooling, and consumables. +

+
+
+ Live snapshot generated {new Date(dashboard.generatedAt).toLocaleString()} +
+
+
+ +
+ {[ + { label: "Booked Revenue", value: formatCurrency(summary.bookedRevenue, currencyCode) }, + { label: "Payments In", value: formatCurrency(summary.paymentsReceived, currencyCode) }, + { label: "A/R Open", value: formatCurrency(summary.accountsReceivableOpen, currencyCode) }, + { label: "PO Spend", value: formatCurrency(summary.linkedPurchaseReceivedValue, currencyCode) }, + { label: "Mfg Cost", value: formatCurrency(summary.manufacturingTotalCost, currencyCode) }, + { label: "CapEx Actual", value: formatCurrency(summary.capexActual, currencyCode) }, + ].map((card) => ( +
+

{card.label}

+
{card.value}
+
+ ))} +
+ +
+
+
+
+

Sales Order Ledger

+

Revenue, receipts, purchasing, and manufacturing cost by order.

+
+
+
+ + + + + + + + + + + + + + {salesOrderLedgers.map((ledger) => ( + + + + + + + + + + ))} + +
OrderRevenuePaymentsPOManufacturingSpendMargin
+ + {ledger.salesOrderNumber} + +
{ledger.customerName}
+
+ {ledger.linkedPurchaseOrderCount} PO / {ledger.linkedWorkOrderCount} WO +
+
{formatCurrency(ledger.revenueTotal, currencyCode)} +
{formatCurrency(ledger.paymentsReceived, currencyCode)}
+
A/R {formatCurrency(ledger.accountsReceivableOpen, currencyCode)}
+
+
{formatCurrency(ledger.linkedPurchaseReceivedValue, currencyCode)}
+
Committed {formatCurrency(ledger.linkedPurchaseCommitted, currencyCode)}
+
+
{formatCurrency(ledger.manufacturingTotalCost, currencyCode)}
+
+ Mat {formatCurrency(ledger.manufacturingMaterialCost, currencyCode)} / Lab+OH {formatCurrency(ledger.manufacturingLaborCost + ledger.manufacturingOverheadCost, currencyCode)} +
+
+
{formatCurrency(ledger.totalRecognizedSpend, currencyCode)}
+
Coverage {formatPercent(ledger.paymentCoveragePercent)}
+
+
= 0 ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300"}`}> + {formatCurrency(ledger.grossMarginEstimate, currencyCode)} +
+
{formatPercent(ledger.grossMarginPercent)}
+
+
+
+ +
+
+

Costing Assumptions

+
+ + + +
+ {canManage ? ( + + ) : null} +
+ +
+

Post Payment

+
+ +
+ + +
+
+ setPaymentForm((current) => ({ ...current, paymentDate: new Date(event.target.value).toISOString() }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" /> + setPaymentForm((current) => ({ ...current, amount: Number(event.target.value) }))} placeholder="Amount" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" /> +
+ setPaymentForm((current) => ({ ...current, reference: event.target.value }))} placeholder="Reference / remittance" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" /> +