initial release testing

This commit is contained in:
2026-03-23 16:16:45 -05:00
parent f079fdca62
commit 6659707890
37 changed files with 3374 additions and 37 deletions

865
lib/actions.ts Normal file
View File

@@ -0,0 +1,865 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { authenticateUser, createSession, destroySession } from "@/lib/auth";
import { getDb } from "@/lib/db";
type ParsedLine = {
sku: string;
quantity: number;
amount: number;
};
type ParsedFulfillmentLine = {
sku: string;
quantity: number;
};
function db() {
return getDb();
}
function getText(formData: FormData, key: string) {
return String(formData.get(key) ?? "").trim();
}
function getNumber(formData: FormData, key: string) {
const value = Number(getText(formData, key));
return Number.isFinite(value) ? value : 0;
}
function parseLines(raw: string): ParsedLine[] {
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [sku, quantity, amount] = line.split(",").map((piece) => piece.trim());
if (!sku || !quantity || !amount) {
throw new Error(`Invalid line format: "${line}". Use SKU,quantity,amount.`);
}
const parsedQuantity = Number(quantity);
const parsedAmount = Number(amount);
if (!Number.isFinite(parsedQuantity) || parsedQuantity <= 0 || !Number.isFinite(parsedAmount) || parsedAmount < 0) {
throw new Error(`Invalid line values: "${line}".`);
}
return {
sku,
quantity: parsedQuantity,
amount: parsedAmount
};
});
}
function parseFulfillmentLines(raw: string): ParsedFulfillmentLine[] {
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [sku, quantity] = line.split(",").map((piece) => piece.trim());
if (!sku || !quantity) {
throw new Error(`Invalid fulfillment line format: "${line}". Use SKU,quantity.`);
}
const parsedQuantity = Number(quantity);
if (!Number.isFinite(parsedQuantity) || parsedQuantity <= 0) {
throw new Error(`Invalid fulfillment quantity: "${line}".`);
}
return { sku, quantity: parsedQuantity };
});
}
function getPartIdBySku(sku: string) {
const row = db().prepare(`SELECT id FROM parts WHERE sku = ?`).get(sku) as { id: number } | undefined;
if (!row) {
throw new Error(`Part with SKU "${sku}" does not exist.`);
}
return row.id;
}
function getOrderNumber(prefix: string, table: "sales_orders" | "purchase_orders") {
const row = db().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
return `${prefix}-${String((row.count ?? 0) + 1).padStart(5, "0")}`;
}
function getDocumentNumber(prefix: string, table: "customer_invoices" | "vendor_bills") {
const row = db().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
return `${prefix}-${String((row.count ?? 0) + 1).padStart(5, "0")}`;
}
function createJournalEntry(
entryType: string,
referenceType: string,
referenceId: number | null,
description: string,
lines: Array<{ accountCode: string; accountName: string; debit: number; credit: number }>
) {
const tx = db().transaction(() => {
const result = db()
.prepare(
`
INSERT INTO journal_entries (entry_type, reference_type, reference_id, description)
VALUES (?, ?, ?, ?)
`
)
.run(entryType, referenceType, referenceId, description);
const journalEntryId = Number(result.lastInsertRowid);
const insertLine = db().prepare(
`
INSERT INTO journal_lines (journal_entry_id, account_code, account_name, debit, credit)
VALUES (?, ?, ?, ?, ?)
`
);
for (const line of lines) {
insertLine.run(journalEntryId, line.accountCode, line.accountName, line.debit, line.credit);
}
});
tx();
}
function resolveAccount(code: string) {
const row = db().prepare(`SELECT code, name FROM accounts WHERE code = ?`).get(code) as { code: string; name: string } | undefined;
if (!row) {
throw new Error(`Account "${code}" does not exist.`);
}
return row;
}
function parseJournalLines(raw: string) {
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [accountCode, debit, credit] = line.split(",").map((piece) => piece.trim());
if (!accountCode || debit === undefined || credit === undefined) {
throw new Error(`Invalid journal line format: "${line}". Use account code,debit,credit.`);
}
const parsedDebit = Number(debit);
const parsedCredit = Number(credit);
if (!Number.isFinite(parsedDebit) || !Number.isFinite(parsedCredit) || parsedDebit < 0 || parsedCredit < 0) {
throw new Error(`Invalid journal amounts: "${line}".`);
}
if ((parsedDebit === 0 && parsedCredit === 0) || (parsedDebit > 0 && parsedCredit > 0)) {
throw new Error(`Journal lines must have either a debit or a credit: "${line}".`);
}
const account = resolveAccount(accountCode);
return {
accountCode: account.code,
accountName: account.name,
debit: parsedDebit,
credit: parsedCredit
};
});
}
export async function createPart(formData: FormData) {
db()
.prepare(
`
INSERT INTO parts (sku, name, description, kind, unit_cost, sale_price, reorder_point, unit_of_measure)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
)
.run(
getText(formData, "sku"),
getText(formData, "name"),
getText(formData, "description"),
getText(formData, "kind"),
getNumber(formData, "unitCost"),
getNumber(formData, "salePrice"),
getNumber(formData, "reorderPoint"),
getText(formData, "unitOfMeasure") || "ea"
);
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/assemblies");
}
export async function addKitComponent(formData: FormData) {
const assemblyId = getPartIdBySku(getText(formData, "assemblySku"));
const componentId = getPartIdBySku(getText(formData, "componentSku"));
db()
.prepare(
`
INSERT INTO kit_components (assembly_part_id, component_part_id, quantity)
VALUES (?, ?, ?)
ON CONFLICT (assembly_part_id, component_part_id)
DO UPDATE SET quantity = excluded.quantity
`
)
.run(assemblyId, componentId, getNumber(formData, "quantity"));
revalidatePath("/assemblies");
}
export async function buildAssembly(formData: FormData) {
const assemblySku = getText(formData, "assemblySku");
const buildQuantity = getNumber(formData, "quantity");
if (buildQuantity <= 0) {
throw new Error("Build quantity must be greater than zero.");
}
const assemblyId = getPartIdBySku(assemblySku);
const components = db()
.prepare(
`
SELECT
kc.component_part_id AS componentId,
kc.quantity AS componentQuantity,
p.sku,
p.unit_cost AS unitCost,
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand
FROM kit_components kc
INNER JOIN parts p ON p.id = kc.component_part_id
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
WHERE kc.assembly_part_id = ?
`
)
.all(assemblyId) as Array<{
componentId: number;
componentQuantity: number;
sku: string;
unitCost: number;
quantityOnHand: number;
}>;
if (components.length === 0) {
throw new Error("Assembly has no bill of materials defined.");
}
for (const component of components) {
const needed = component.componentQuantity * buildQuantity;
if (component.quantityOnHand < needed) {
throw new Error(`Not enough stock for component ${component.sku}. Need ${needed}, have ${component.quantityOnHand}.`);
}
}
const buildCost = components.reduce((sum, component) => sum + component.unitCost * component.componentQuantity, 0);
const tx = db().transaction(() => {
const insertInventory = db().prepare(
`
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
);
const updateAssemblyCost = db().prepare(`UPDATE parts SET unit_cost = ? WHERE id = ?`);
for (const component of components) {
insertInventory.run(
component.componentId,
component.componentQuantity * buildQuantity * -1,
component.unitCost,
"assembly_consume",
"assembly_build",
assemblyId,
`Consumed for ${assemblySku}`
);
}
insertInventory.run(
assemblyId,
buildQuantity,
buildCost,
"assembly_build",
"assembly_build",
assemblyId,
`Built ${buildQuantity} of ${assemblySku}`
);
updateAssemblyCost.run(buildCost, assemblyId);
});
tx();
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/assemblies");
}
export async function recordAdjustment(formData: FormData) {
const partId = getPartIdBySku(getText(formData, "sku"));
const quantityDelta = getNumber(formData, "quantityDelta");
const unitCost = getNumber(formData, "unitCost");
const notes = getText(formData, "notes");
const inventoryImpact = Math.abs(quantityDelta * unitCost);
db()
.prepare(
`
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, notes)
VALUES (?, ?, ?, 'adjustment', 'manual_adjustment', ?)
`
)
.run(partId, quantityDelta, unitCost, notes);
if (inventoryImpact > 0) {
createJournalEntry("adjustment", "manual_adjustment", partId, notes || "Inventory adjustment posted", [
{
accountCode: quantityDelta >= 0 ? "1200" : "6100",
accountName: quantityDelta >= 0 ? "Inventory" : "Inventory Adjustments",
debit: quantityDelta >= 0 ? inventoryImpact : 0,
credit: quantityDelta >= 0 ? 0 : inventoryImpact
},
{
accountCode: quantityDelta >= 0 ? "3000" : "1200",
accountName: quantityDelta >= 0 ? "Owner Equity" : "Inventory",
debit: quantityDelta >= 0 ? 0 : inventoryImpact,
credit: quantityDelta >= 0 ? inventoryImpact : 0
}
]);
}
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/accounting");
}
export async function createCustomer(formData: FormData) {
db()
.prepare(
`
INSERT INTO customers (code, name, email, phone, billing_address, shipping_address)
VALUES (?, ?, ?, ?, ?, ?)
`
)
.run(
getText(formData, "code"),
getText(formData, "name"),
getText(formData, "email"),
getText(formData, "phone"),
getText(formData, "billingAddress"),
getText(formData, "shippingAddress")
);
revalidatePath("/customers");
revalidatePath("/");
}
export async function createAccount(formData: FormData) {
const category = getText(formData, "category");
if (!["asset", "liability", "equity", "revenue", "expense"].includes(category)) {
throw new Error("Invalid account category.");
}
db()
.prepare(
`
INSERT INTO accounts (code, name, category, is_system)
VALUES (?, ?, ?, 0)
`
)
.run(getText(formData, "code"), getText(formData, "name"), category);
revalidatePath("/accounting");
}
export async function createVendor(formData: FormData) {
db()
.prepare(
`
INSERT INTO vendors (code, name, email, phone, address)
VALUES (?, ?, ?, ?, ?)
`
)
.run(
getText(formData, "code"),
getText(formData, "name"),
getText(formData, "email"),
getText(formData, "phone"),
getText(formData, "address")
);
revalidatePath("/vendors");
revalidatePath("/");
}
export async function createSalesOrder(formData: FormData) {
const customerCode = getText(formData, "customerCode");
const lines = parseLines(getText(formData, "lines"));
const customerRow = db().prepare(`SELECT id FROM customers WHERE code = ?`).get(customerCode) as { id: number } | undefined;
if (!customerRow) {
throw new Error(`Customer "${customerCode}" does not exist.`);
}
const tx = db().transaction(() => {
const result = db()
.prepare(
`
INSERT INTO sales_orders (order_number, customer_id, status, notes)
VALUES (?, ?, 'open', ?)
`
)
.run(getOrderNumber("SO", "sales_orders"), customerRow.id, getText(formData, "notes"));
const orderId = Number(result.lastInsertRowid);
const insertLine = db().prepare(
`
INSERT INTO sales_order_lines (sales_order_id, part_id, quantity, unit_price)
VALUES (?, ?, ?, ?)
`
);
for (const line of lines) {
insertLine.run(orderId, getPartIdBySku(line.sku), line.quantity, line.amount);
}
});
tx();
revalidatePath("/");
revalidatePath("/sales-orders");
}
export async function shipSalesOrder(formData: FormData) {
const orderId = Number(getText(formData, "orderId"));
const order = db()
.prepare(`SELECT customer_id AS customerId, status FROM sales_orders WHERE id = ?`)
.get(orderId) as { customerId: number; status: string } | undefined;
if (!order) {
throw new Error("Sales order not found.");
}
if (order.status === "shipped") {
throw new Error("Sales order has already been shipped.");
}
const orderLines = db()
.prepare(
`
SELECT
sol.id AS lineId,
so.order_number AS orderNumber,
p.id AS partId,
p.sku,
p.unit_cost AS unitCost,
sol.quantity,
sol.shipped_quantity AS shippedQuantity,
sol.unit_price AS unitPrice,
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand
FROM sales_order_lines sol
INNER JOIN sales_orders so ON so.id = sol.sales_order_id
INNER JOIN parts p ON p.id = sol.part_id
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
WHERE sol.sales_order_id = ?
`
)
.all(orderId) as Array<{
lineId: number;
orderNumber: string;
partId: number;
sku: string;
unitCost: number;
quantity: number;
shippedQuantity: number;
unitPrice: number;
quantityOnHand: number;
}>;
if (orderLines.length === 0) {
throw new Error("Sales order has no lines.");
}
const requestedLines = parseFulfillmentLines(getText(formData, "lines"));
const fulfilledLines = requestedLines.length
? requestedLines.map((request) => {
const matchingLine = orderLines.find((line) => line.sku === request.sku);
if (!matchingLine) {
throw new Error(`SKU ${request.sku} is not on this sales order.`);
}
const remaining = matchingLine.quantity - matchingLine.shippedQuantity;
if (request.quantity > remaining) {
throw new Error(`Cannot ship ${request.quantity} of ${request.sku}; only ${remaining} remain.`);
}
if (matchingLine.quantityOnHand < request.quantity) {
throw new Error(`Insufficient stock for ${matchingLine.sku}. Need ${request.quantity}, have ${matchingLine.quantityOnHand}.`);
}
return { ...matchingLine, shipQuantity: request.quantity };
})
: orderLines
.map((line) => {
const remaining = line.quantity - line.shippedQuantity;
return remaining > 0 ? { ...line, shipQuantity: remaining } : null;
})
.filter((line): line is NonNullable<typeof line> => line !== null);
if (fulfilledLines.length === 0) {
throw new Error("No shippable quantities were provided.");
}
const revenue = fulfilledLines.reduce((sum, line) => sum + line.shipQuantity * line.unitPrice, 0);
const cogs = fulfilledLines.reduce((sum, line) => sum + line.shipQuantity * line.unitCost, 0);
const orderNumber = orderLines[0].orderNumber;
const tx = db().transaction(() => {
const insertInventory = db().prepare(
`
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
VALUES (?, ?, ?, 'sales_shipment', 'sales_order', ?, ?)
`
);
const updateLine = db().prepare(`UPDATE sales_order_lines SET shipped_quantity = shipped_quantity + ? WHERE id = ?`);
for (const line of fulfilledLines) {
insertInventory.run(line.partId, line.shipQuantity * -1, line.unitCost, orderId, `Shipment for ${orderNumber}`);
updateLine.run(line.shipQuantity, line.lineId);
}
const remainingCount = db()
.prepare(`SELECT COUNT(*) AS count FROM sales_order_lines WHERE sales_order_id = ? AND shipped_quantity < quantity`)
.get(orderId) as { count: number };
const nextStatus = remainingCount.count > 0 ? "partial" : "shipped";
db().prepare(`UPDATE sales_orders SET status = ?, shipped_at = CURRENT_TIMESTAMP WHERE id = ?`).run(nextStatus, orderId);
db()
.prepare(
`
INSERT INTO customer_invoices (invoice_number, sales_order_id, customer_id, status, due_date, total_amount, paid_amount)
VALUES (?, ?, ?, 'open', DATE('now', '+30 day'), ?, 0)
`
)
.run(getDocumentNumber("INV", "customer_invoices"), orderId, order.customerId, revenue);
});
tx();
createJournalEntry("shipment", "sales_order", orderId, `Shipment posted for ${orderNumber}`, [
{ accountCode: "1100", accountName: "Accounts Receivable", debit: revenue, credit: 0 },
{ accountCode: "4000", accountName: "Sales Revenue", debit: 0, credit: revenue },
{ accountCode: "5000", accountName: "Cost of Goods Sold", debit: cogs, credit: 0 },
{ accountCode: "1200", accountName: "Inventory", debit: 0, credit: cogs }
]);
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/sales-orders");
revalidatePath("/accounting");
revalidatePath("/invoices");
}
export async function createPurchaseOrder(formData: FormData) {
const vendorCode = getText(formData, "vendorCode");
const lines = parseLines(getText(formData, "lines"));
const vendorRow = db().prepare(`SELECT id FROM vendors WHERE code = ?`).get(vendorCode) as { id: number } | undefined;
if (!vendorRow) {
throw new Error(`Vendor "${vendorCode}" does not exist.`);
}
const tx = db().transaction(() => {
const result = db()
.prepare(
`
INSERT INTO purchase_orders (order_number, vendor_id, status, notes)
VALUES (?, ?, 'ordered', ?)
`
)
.run(getOrderNumber("PO", "purchase_orders"), vendorRow.id, getText(formData, "notes"));
const orderId = Number(result.lastInsertRowid);
const insertLine = db().prepare(
`
INSERT INTO purchase_order_lines (purchase_order_id, part_id, quantity, unit_cost)
VALUES (?, ?, ?, ?)
`
);
for (const line of lines) {
insertLine.run(orderId, getPartIdBySku(line.sku), line.quantity, line.amount);
}
});
tx();
revalidatePath("/");
revalidatePath("/purchase-orders");
}
export async function receivePurchaseOrder(formData: FormData) {
const orderId = Number(getText(formData, "orderId"));
const order = db()
.prepare(`SELECT vendor_id AS vendorId, status FROM purchase_orders WHERE id = ?`)
.get(orderId) as { vendorId: number; status: string } | undefined;
if (!order) {
throw new Error("Purchase order not found.");
}
if (order.status === "received") {
throw new Error("Purchase order has already been received.");
}
const lines = db()
.prepare(
`
SELECT
pol.id AS lineId,
po.order_number AS orderNumber,
pol.part_id AS partId,
pol.quantity,
pol.received_quantity AS receivedQuantity,
p.sku,
pol.unit_cost AS unitCost
FROM purchase_order_lines pol
INNER JOIN purchase_orders po ON po.id = pol.purchase_order_id
INNER JOIN parts p ON p.id = pol.part_id
WHERE pol.purchase_order_id = ?
`
)
.all(orderId) as Array<{
lineId: number;
orderNumber: string;
partId: number;
quantity: number;
receivedQuantity: number;
sku: string;
unitCost: number;
}>;
if (lines.length === 0) {
throw new Error("Purchase order has no lines.");
}
const requestedLines = parseFulfillmentLines(getText(formData, "lines"));
const fulfilledLines = requestedLines.length
? requestedLines.map((request) => {
const matchingLine = lines.find((line) => line.sku === request.sku);
if (!matchingLine) {
throw new Error(`SKU ${request.sku} is not on this purchase order.`);
}
const remaining = matchingLine.quantity - matchingLine.receivedQuantity;
if (request.quantity > remaining) {
throw new Error(`Cannot receive ${request.quantity} of ${request.sku}; only ${remaining} remain.`);
}
return { ...matchingLine, receiveQuantity: request.quantity };
})
: lines
.map((line) => {
const remaining = line.quantity - line.receivedQuantity;
return remaining > 0 ? { ...line, receiveQuantity: remaining } : null;
})
.filter((line): line is NonNullable<typeof line> => line !== null);
if (fulfilledLines.length === 0) {
throw new Error("No receivable quantities were provided.");
}
const receiptValue = fulfilledLines.reduce((sum, line) => sum + line.receiveQuantity * line.unitCost, 0);
const orderNumber = lines[0].orderNumber;
const tx = db().transaction(() => {
const insertInventory = db().prepare(
`
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
VALUES (?, ?, ?, 'purchase_receipt', 'purchase_order', ?, ?)
`
);
const updatePartCost = db().prepare(`UPDATE parts SET unit_cost = ? WHERE id = ?`);
const updateLine = db().prepare(`UPDATE purchase_order_lines SET received_quantity = received_quantity + ? WHERE id = ?`);
for (const line of fulfilledLines) {
insertInventory.run(line.partId, line.receiveQuantity, line.unitCost, orderId, `Receipt for ${orderNumber}`);
updatePartCost.run(line.unitCost, line.partId);
updateLine.run(line.receiveQuantity, line.lineId);
}
const remainingCount = db()
.prepare(`SELECT COUNT(*) AS count FROM purchase_order_lines WHERE purchase_order_id = ? AND received_quantity < quantity`)
.get(orderId) as { count: number };
const nextStatus = remainingCount.count > 0 ? "partial" : "received";
db().prepare(`UPDATE purchase_orders SET status = ?, received_at = CURRENT_TIMESTAMP WHERE id = ?`).run(nextStatus, orderId);
db()
.prepare(
`
INSERT INTO vendor_bills (bill_number, purchase_order_id, vendor_id, status, due_date, total_amount, paid_amount)
VALUES (?, ?, ?, 'open', DATE('now', '+30 day'), ?, 0)
`
)
.run(getDocumentNumber("BILL", "vendor_bills"), orderId, order.vendorId, receiptValue);
});
tx();
createJournalEntry("receipt", "purchase_order", orderId, `Receipt posted for ${orderNumber}`, [
{ accountCode: "1200", accountName: "Inventory", debit: receiptValue, credit: 0 },
{ accountCode: "2000", accountName: "Accounts Payable", debit: 0, credit: receiptValue }
]);
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/purchase-orders");
revalidatePath("/accounting");
revalidatePath("/vendor-bills");
}
export async function createManualJournalEntry(formData: FormData) {
const description = getText(formData, "description");
const lines = parseJournalLines(getText(formData, "lines"));
const debitTotal = lines.reduce((sum, line) => sum + line.debit, 0);
const creditTotal = lines.reduce((sum, line) => sum + line.credit, 0);
if (Math.abs(debitTotal - creditTotal) > 0.005) {
throw new Error("Manual journal entry is not balanced.");
}
createJournalEntry("manual", "manual_journal", null, description || "Manual journal entry", lines);
revalidatePath("/accounting");
}
export async function loginAction(formData: FormData) {
const email = getText(formData, "email");
const password = getText(formData, "password");
const user = authenticateUser(db(), email, password);
if (!user) {
throw new Error("Invalid email or password.");
}
await createSession(user);
redirect("/");
}
export async function logoutAction() {
await destroySession();
redirect("/login");
}
export async function receiveCustomerPayment(formData: FormData) {
const invoiceId = Number(getText(formData, "invoiceId"));
const amount = getNumber(formData, "amount");
const notes = getText(formData, "notes");
if (amount <= 0) {
throw new Error("Payment amount must be greater than zero.");
}
const invoice = db()
.prepare(
`
SELECT id, invoice_number AS invoiceNumber, total_amount AS totalAmount, paid_amount AS paidAmount
FROM customer_invoices
WHERE id = ?
`
)
.get(invoiceId) as { id: number; invoiceNumber: string; totalAmount: number; paidAmount: number } | undefined;
if (!invoice) {
throw new Error("Invoice not found.");
}
const balanceDue = invoice.totalAmount - invoice.paidAmount;
if (amount > balanceDue) {
throw new Error("Payment cannot exceed invoice balance.");
}
const newPaidAmount = invoice.paidAmount + amount;
const newStatus = Math.abs(newPaidAmount - invoice.totalAmount) <= 0.005 ? "paid" : "partial";
const tx = db().transaction(() => {
db()
.prepare(`INSERT INTO customer_payments (invoice_id, amount, notes) VALUES (?, ?, ?)`)
.run(invoiceId, amount, notes);
db()
.prepare(`UPDATE customer_invoices SET paid_amount = ?, status = ? WHERE id = ?`)
.run(newPaidAmount, newStatus, invoiceId);
});
tx();
createJournalEntry("customer_payment", "customer_invoice", invoiceId, `Payment received for ${invoice.invoiceNumber}`, [
{ accountCode: "1000", accountName: "Cash", debit: amount, credit: 0 },
{ accountCode: "1100", accountName: "Accounts Receivable", debit: 0, credit: amount }
]);
revalidatePath("/");
revalidatePath("/accounting");
revalidatePath("/invoices");
}
export async function payVendorBill(formData: FormData) {
const vendorBillId = Number(getText(formData, "vendorBillId"));
const amount = getNumber(formData, "amount");
const notes = getText(formData, "notes");
if (amount <= 0) {
throw new Error("Payment amount must be greater than zero.");
}
const bill = db()
.prepare(
`
SELECT id, bill_number AS billNumber, total_amount AS totalAmount, paid_amount AS paidAmount
FROM vendor_bills
WHERE id = ?
`
)
.get(vendorBillId) as { id: number; billNumber: string; totalAmount: number; paidAmount: number } | undefined;
if (!bill) {
throw new Error("Vendor bill not found.");
}
const balanceDue = bill.totalAmount - bill.paidAmount;
if (amount > balanceDue) {
throw new Error("Payment cannot exceed vendor bill balance.");
}
const newPaidAmount = bill.paidAmount + amount;
const newStatus = Math.abs(newPaidAmount - bill.totalAmount) <= 0.005 ? "paid" : "partial";
const tx = db().transaction(() => {
db()
.prepare(`INSERT INTO vendor_payments (vendor_bill_id, amount, notes) VALUES (?, ?, ?)`)
.run(vendorBillId, amount, notes);
db()
.prepare(`UPDATE vendor_bills SET paid_amount = ?, status = ? WHERE id = ?`)
.run(newPaidAmount, newStatus, vendorBillId);
});
tx();
createJournalEntry("vendor_payment", "vendor_bill", vendorBillId, `Vendor payment posted for ${bill.billNumber}`, [
{ accountCode: "2000", accountName: "Accounts Payable", debit: amount, credit: 0 },
{ accountCode: "1000", accountName: "Cash", debit: 0, credit: amount }
]);
revalidatePath("/");
revalidatePath("/accounting");
revalidatePath("/vendor-bills");
}