From 1c15cbc36e93e065357fb7ceb138a5b9b5322ccd Mon Sep 17 00:00:00 2001 From: grabowski Date: Mon, 20 Apr 2026 12:46:05 +0700 Subject: [PATCH] Add sales CRUD with line items, taxes, withholding, and package linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New routes: /companies/[id]/sales (list + create) and [saleId] (detail). Per-line tax rate, single withholding % on sale. Computed totals: subtotal, tax, gross, withholding, net receivable. Status flow: draft → confirmed → voided. Packages linked via sale_packages junction. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../[companyId]/sales/+page.server.ts | 110 ++++++ .../companies/[companyId]/sales/+page.svelte | 142 ++++++++ .../sales/[saleId]/+page.server.ts | 297 ++++++++++++++++ .../[companyId]/sales/[saleId]/+page.svelte | 327 ++++++++++++++++++ 4 files changed, 876 insertions(+) create mode 100644 src/routes/(app)/companies/[companyId]/sales/+page.server.ts create mode 100644 src/routes/(app)/companies/[companyId]/sales/+page.svelte create mode 100644 src/routes/(app)/companies/[companyId]/sales/[saleId]/+page.server.ts create mode 100644 src/routes/(app)/companies/[companyId]/sales/[saleId]/+page.svelte diff --git a/src/routes/(app)/companies/[companyId]/sales/+page.server.ts b/src/routes/(app)/companies/[companyId]/sales/+page.server.ts new file mode 100644 index 0000000..5c6a645 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/sales/+page.server.ts @@ -0,0 +1,110 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { sales, saleLineItems, parties, projects, users } from '$lib/server/db/schema.js'; +import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { logCompanyEvent } from '$lib/server/audit.js'; +import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm'; + +function trimOrNull(v: FormDataEntryValue | null): string | null { + const s = v?.toString().trim(); + return s ? s : null; +} + +export const load: PageServerLoad = async ({ locals, params, parent, url }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + await parent(); + + const status = url.searchParams.get('status') ?? 'all'; + + const whereClauses = [eq(sales.companyId, params.companyId), isNull(sales.deletedAt)]; + if (status !== 'all' && ['draft', 'confirmed', 'voided'].includes(status)) { + whereClauses.push(eq(sales.status, status as 'draft' | 'confirmed' | 'voided')); + } + + const salesList = await db + .select({ + id: sales.id, + title: sales.title, + saleDate: sales.saleDate, + status: sales.status, + currency: sales.currency, + projectId: sales.projectId, + projectName: projects.name, + partyId: sales.partyId, + partyName: parties.name, + withholdingTaxRate: sales.withholdingTaxRate, + createdByName: users.displayName, + createdAt: sales.createdAt, + grossTotal: sql`coalesce(( + select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate}))::text + from sale_line_items + where sale_id = ${sales.id} + ), '0')` + }) + .from(sales) + .leftJoin(projects, eq(sales.projectId, projects.id)) + .leftJoin(parties, eq(sales.partyId, parties.id)) + .leftJoin(users, eq(sales.createdBy, users.id)) + .where(and(...whereClauses)) + .orderBy(desc(sales.saleDate)); + + const partyList = await db + .select({ id: parties.id, name: parties.name }) + .from(parties) + .where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt))) + .orderBy(asc(parties.name)); + + const projectList = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true))) + .orderBy(asc(projects.name)); + + return { + sales: salesList, + statusFilter: status, + parties: partyList, + projects: projectList + }; +}; + +export const actions: Actions = { + createSale: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'accountant' + ]); + const fd = await request.formData(); + const title = trimOrNull(fd.get('title')); + const saleDate = trimOrNull(fd.get('saleDate')); + const projectId = trimOrNull(fd.get('projectId')); + const partyId = trimOrNull(fd.get('partyId')); + const withholdingRaw = fd.get('withholdingTaxRate')?.toString().trim(); + const withholdingTaxRate = withholdingRaw ? Number(withholdingRaw) / 100 : 0; + + if (!title) return fail(400, { action: 'createSale', error: 'Title is required' }); + if (!saleDate) return fail(400, { action: 'createSale', error: 'Sale date is required' }); + if (withholdingTaxRate < 0 || withholdingTaxRate > 1) { + return fail(400, { action: 'createSale', error: 'Withholding rate must be 0–100%' }); + } + + const [inserted] = await db + .insert(sales) + .values({ + companyId: params.companyId, + title, + saleDate, + projectId, + partyId, + withholdingTaxRate: withholdingTaxRate.toFixed(4), + createdBy: user.id, + status: 'draft' + }) + .returning({ id: sales.id }); + + await logCompanyEvent(params.companyId, user.id, 'sale_created', + `Sale "${title}" created`, { saleId: inserted.id }); + + redirect(303, `/companies/${params.companyId}/sales/${inserted.id}`); + } +}; diff --git a/src/routes/(app)/companies/[companyId]/sales/+page.svelte b/src/routes/(app)/companies/[companyId]/sales/+page.svelte new file mode 100644 index 0000000..c5e8149 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/sales/+page.svelte @@ -0,0 +1,142 @@ + + + + Sales - {data.company.name} + + +
+
+
+

Sales

+

+ Revenue events. Confirmed sales contribute to project budget. +

+
+ +
+ + {#if form?.error} +
{form.error}
+ {/if} + + {#if showAddForm} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

+ After creating the sale, add line items on the detail page. +

+
+ {/if} + + +
+ {#each ['all', 'draft', 'confirmed', 'voided'] as s (s)} + + {s.charAt(0).toUpperCase() + s.slice(1)} + + {/each} +
+ + {#if data.sales.length === 0} +
+

No sales yet.

+
+ {:else} +
+ + + + + + + + + + + + {#each data.sales as sale (sale.id)} + (window.location.href = `/companies/${data.company.id}/sales/${sale.id}`)}> + + + + + + + {/each} + +
TitleDateCustomerGrossStatus
+
{sale.title}
+ {#if sale.projectName} +
Project: {sale.projectName}
+ {/if} +
{sale.saleDate}{sale.partyName ?? '—'} + {formatCurrency(sale.grossTotal, sale.currency)} + + + {sale.status} + +
+
+ {/if} +
diff --git a/src/routes/(app)/companies/[companyId]/sales/[saleId]/+page.server.ts b/src/routes/(app)/companies/[companyId]/sales/[saleId]/+page.server.ts new file mode 100644 index 0000000..7cb220e --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/sales/[saleId]/+page.server.ts @@ -0,0 +1,297 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { + sales, + saleLineItems, + salePackages, + parties, + projects, + packages, + invoices +} from '$lib/server/db/schema.js'; +import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { logCompanyEvent } from '$lib/server/audit.js'; +import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm'; + +function trimOrNull(v: FormDataEntryValue | null): string | null { + const s = v?.toString().trim(); + return s ? s : null; +} + +export const load: PageServerLoad = async ({ locals, params, parent }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + await parent(); + + const [sale] = await db + .select() + .from(sales) + .where( + and( + eq(sales.id, params.saleId), + eq(sales.companyId, params.companyId), + isNull(sales.deletedAt) + ) + ) + .limit(1); + + if (!sale) error(404, 'Sale not found'); + + const lineItems = await db + .select() + .from(saleLineItems) + .where(eq(saleLineItems.saleId, params.saleId)) + .orderBy(asc(saleLineItems.sortOrder), asc(saleLineItems.createdAt)); + + const linkedPkgRows = await db + .select({ + packageId: salePackages.packageId, + trackingNumber: packages.trackingNumber, + carrier: packages.carrier, + status: packages.status, + direction: packages.direction + }) + .from(salePackages) + .innerJoin(packages, eq(salePackages.packageId, packages.id)) + .where(eq(salePackages.saleId, params.saleId)); + + const availablePackages = await db + .select({ + id: packages.id, + trackingNumber: packages.trackingNumber, + carrier: packages.carrier, + direction: packages.direction + }) + .from(packages) + .where(eq(packages.companyId, params.companyId)) + .orderBy(sql`${packages.createdAt} desc`); + + const [party] = sale.partyId + ? await db + .select({ id: parties.id, name: parties.name }) + .from(parties) + .where(eq(parties.id, sale.partyId)) + .limit(1) + : [null]; + + const [project] = sale.projectId + ? await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.id, sale.projectId)) + .limit(1) + : [null]; + + const partyList = await db + .select({ id: parties.id, name: parties.name }) + .from(parties) + .where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt))) + .orderBy(asc(parties.name)); + + const projectList = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true))) + .orderBy(asc(projects.name)); + + const invoiceList = await db + .select({ + id: invoices.id, + invoiceNumber: invoices.invoiceNumber, + direction: invoices.direction + }) + .from(invoices) + .where( + and( + eq(invoices.companyId, params.companyId), + eq(invoices.direction, 'outgoing'), + ne(invoices.status, 'voided') + ) + ) + .orderBy(asc(invoices.invoiceNumber)); + + return { + sale, + lineItems, + linkedPackages: linkedPkgRows, + availablePackages, + party, + project, + parties: partyList, + projects: projectList, + invoices: invoiceList + }; +}; + +export const actions: Actions = { + updateSale: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'accountant' + ]); + const fd = await request.formData(); + const title = trimOrNull(fd.get('title')); + const saleDate = trimOrNull(fd.get('saleDate')); + const projectId = trimOrNull(fd.get('projectId')); + const partyId = trimOrNull(fd.get('partyId')); + const invoiceId = trimOrNull(fd.get('invoiceId')); + const notes = trimOrNull(fd.get('notes')); + const withholdingRaw = fd.get('withholdingTaxRate')?.toString().trim(); + const withholdingTaxRate = withholdingRaw ? Number(withholdingRaw) / 100 : 0; + + if (!title) return fail(400, { action: 'updateSale', error: 'Title is required' }); + if (!saleDate) return fail(400, { action: 'updateSale', error: 'Date is required' }); + + await db + .update(sales) + .set({ + title, + saleDate, + projectId, + partyId, + invoiceId, + notes, + withholdingTaxRate: withholdingTaxRate.toFixed(4), + updatedAt: new Date() + }) + .where( + and(eq(sales.id, params.saleId), eq(sales.companyId, params.companyId)) + ); + + return { success: true, action: 'updateSale' }; + }, + + addLineItem: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const fd = await request.formData(); + const productName = trimOrNull(fd.get('productName')); + const description = trimOrNull(fd.get('description')); + const quantity = fd.get('quantity')?.toString().trim(); + const unitPrice = fd.get('unitPrice')?.toString().trim(); + const taxPct = fd.get('taxRate')?.toString().trim(); + + if (!productName) return fail(400, { action: 'addLineItem', error: 'Product name required' }); + if (!quantity || Number(quantity) <= 0) return fail(400, { action: 'addLineItem', error: 'Valid quantity required' }); + if (!unitPrice || Number(unitPrice) < 0) return fail(400, { action: 'addLineItem', error: 'Valid unit price required' }); + + const taxRate = taxPct ? Number(taxPct) / 100 : 0; + + const [maxRow] = await db + .select({ max: sql`coalesce(max(${saleLineItems.sortOrder}), -1)::int` }) + .from(saleLineItems) + .where(eq(saleLineItems.saleId, params.saleId)); + + await db.insert(saleLineItems).values({ + saleId: params.saleId, + productName, + description, + quantity: Number(quantity).toFixed(4), + unitPrice: Number(unitPrice).toFixed(2), + taxRate: taxRate.toFixed(4), + sortOrder: (maxRow?.max ?? -1) + 1 + }); + + return { success: true, action: 'addLineItem' }; + }, + + removeLineItem: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const fd = await request.formData(); + const itemId = trimOrNull(fd.get('itemId')); + if (!itemId) return fail(400, { action: 'removeLineItem', error: 'Item id required' }); + + await db + .delete(saleLineItems) + .where(and(eq(saleLineItems.id, itemId), eq(saleLineItems.saleId, params.saleId))); + + return { success: true, action: 'removeLineItem' }; + }, + + confirmSale: async ({ locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'accountant' + ]); + + const [lineCount] = await db + .select({ count: sql`count(*)::int` }) + .from(saleLineItems) + .where(eq(saleLineItems.saleId, params.saleId)); + + if (!lineCount || lineCount.count === 0) { + return fail(400, { action: 'confirmSale', error: 'Add at least one line item before confirming' }); + } + + const [updated] = await db + .update(sales) + .set({ status: 'confirmed', updatedAt: new Date() }) + .where( + and( + eq(sales.id, params.saleId), + eq(sales.companyId, params.companyId), + eq(sales.status, 'draft') + ) + ) + .returning({ title: sales.title }); + + if (!updated) return fail(400, { action: 'confirmSale', error: 'Sale not found or not in draft' }); + + await logCompanyEvent(params.companyId, user.id, 'sale_confirmed', + `Sale "${updated.title}" confirmed`, { saleId: params.saleId }); + + return { success: true, action: 'confirmSale' }; + }, + + voidSale: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); + const fd = await request.formData(); + const reason = trimOrNull(fd.get('reason')); + if (!reason) return fail(400, { action: 'voidSale', error: 'Void reason is required' }); + + const [updated] = await db + .update(sales) + .set({ + status: 'voided', + voidedAt: new Date(), + voidReason: reason, + updatedAt: new Date() + }) + .where( + and(eq(sales.id, params.saleId), eq(sales.companyId, params.companyId)) + ) + .returning({ title: sales.title }); + + if (!updated) return fail(404, { action: 'voidSale', error: 'Sale not found' }); + + await logCompanyEvent(params.companyId, user.id, 'sale_voided', + `Sale "${updated.title}" voided: ${reason}`, + { saleId: params.saleId, reason }); + + return { success: true, action: 'voidSale' }; + }, + + linkPackage: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const fd = await request.formData(); + const packageId = trimOrNull(fd.get('packageId')); + if (!packageId) return fail(400, { error: 'Package id required' }); + + await db + .insert(salePackages) + .values({ saleId: params.saleId, packageId }) + .onConflictDoNothing(); + + return { success: true, action: 'linkPackage' }; + }, + + unlinkPackage: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const fd = await request.formData(); + const packageId = trimOrNull(fd.get('packageId')); + if (!packageId) return fail(400, { error: 'Package id required' }); + + await db + .delete(salePackages) + .where(and(eq(salePackages.saleId, params.saleId), eq(salePackages.packageId, packageId))); + + return { success: true, action: 'unlinkPackage' }; + } +}; diff --git a/src/routes/(app)/companies/[companyId]/sales/[saleId]/+page.svelte b/src/routes/(app)/companies/[companyId]/sales/[saleId]/+page.svelte new file mode 100644 index 0000000..cd27f73 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/sales/[saleId]/+page.svelte @@ -0,0 +1,327 @@ + + + + {data.sale.title} - Sales + + +
+
+ ← Sales +
+
+

{data.sale.title}

+
+ + {data.sale.status} + + {data.sale.saleDate} + {#if data.party}Customer: {data.party.name}{/if} + {#if data.project}Project: {data.project.name}{/if} +
+
+ +
+ {#if data.sale.status === 'voided' && data.sale.voidReason} +
+ Voided: + {data.sale.voidReason} +
+ {/if} +
+ + {#if form?.error} +
{form.error}
+ {/if} + + {#if editingMeta} +
async ({ result, update }) => { + await update({ reset: false }); + if (result.type === 'success') editingMeta = false; + }} + class="grid grid-cols-1 gap-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 md:grid-cols-2"> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ {/if} + + +
+
+

Line Items

+ {#if isLive} + + {/if} +
+ + {#if showAddItem && isLive} +
async ({ result, update, formElement }) => { + await update({ reset: false }); + if (result.type === 'success') { showAddItem = false; formElement.reset(); } + }} + class="grid grid-cols-1 gap-3 border-b border-gray-100 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-700/30 md:grid-cols-4"> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ {/if} + + {#if data.lineItems.length === 0} +

No line items yet.

+ {:else} + + + + + + + + + + + + + {#each data.lineItems as li (li.id)} + {@const lineNet = Number(li.quantity) * Number(li.unitPrice)} + {@const lineGross = lineNet * (1 + Number(li.taxRate))} + + + + + + + + + {/each} + + + + + + + + + + + + + + + + + + {#if Number(data.sale.withholdingTaxRate) > 0} + + + + + + + + + + + {/if} + +
ProductQtyUnitTax %Line Total
+
{li.productName}
+ {#if li.description}
{li.description}
{/if} +
{Number(li.quantity)}{formatCurrency(li.unitPrice, data.sale.currency)}{(Number(li.taxRate) * 100).toFixed(1)}%{formatCurrency(lineGross.toFixed(2), data.sale.currency)} + {#if isLive} +
async ({ update }) => await update({ reset: false })}> + + +
+ {/if} +
Subtotal{formatCurrency(totals.subtotal.toFixed(2), data.sale.currency)}
Tax{formatCurrency(totals.tax.toFixed(2), data.sale.currency)}
Gross{formatCurrency(totals.gross.toFixed(2), data.sale.currency)}
+ Withholding ({(Number(data.sale.withholdingTaxRate) * 100).toFixed(2)}%) + + -{formatCurrency(totals.withholding.toFixed(2), data.sale.currency)} +
Net Receivable + {formatCurrency(totals.net.toFixed(2), data.sale.currency)} +
+ {/if} +
+ + +
+

Linked Packages

+ {#if data.linkedPackages.length > 0} +
+ {#each data.linkedPackages as pkg (pkg.packageId)} + + 📦 {pkg.trackingNumber} ({pkg.carrier}) + {#if isLive} +
async ({ update }) => await update({ reset: false })}> + + +
+ {/if} +
+ {/each} +
+ {/if} + {#if isLive && data.availablePackages.length > 0} +
+ + +
+ {/if} +
+ + + {#if isLive} +
+
+ +
+ +
+ {/if} + + {#if data.sale.status === 'confirmed'} +
+ +
+ {/if} + + {#if showVoidForm} +
async ({ update }) => { await update({ reset: false }); showVoidForm = false; }} + class="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20"> + + +
+ + +
+
+ {/if} +