From 3717655e1e6901865c1fe1b538aefbf9fee21bcf Mon Sep 17 00:00:00 2001 From: Darsh Gupta Date: Wed, 15 Apr 2026 11:03:33 +0530 Subject: [PATCH 1/2] fix: calculate tax on discounted amount and add consistent rounding Tax was calculated on the full subtotal before discount, producing wrong totals (e.g. $400 item with $400 discount and 10% tax showed $40 instead of $0). Now tax is proportionally reduced when a discount is applied. Also fixes rounding mismatches between client previews and server: - Added per-item rounding to quote form subtotal calculations - Added Math.round to all client-side discount/tax calculations - Added per-item rounding to legacy useQuoteTotals hook - Fixed seed data to use 2-decimal rounding instead of integer rounding - Quote edit form was missing tax in total calculation entirely Affects: invoice actions, quote actions, all 4 form previews, quote-builder-store, useQuoteTotals hook, seed data. Fixes #56 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../invoices/[id]/edit/edit-invoice-form.tsx | 7 ++++--- .../invoices/new/new-invoice-form.tsx | 7 ++++--- .../quotes/[id]/edit/edit-quote-form.tsx | 18 +++++++++++------- .../(dashboard)/quotes/new/new-quote-form.tsx | 18 ++++++++++-------- .../quotes/editor/hooks/useQuoteTotals.ts | 12 ++++++------ apps/web/lib/invoices/actions.ts | 10 ++++++++-- apps/web/lib/quotes/actions.ts | 5 +++++ apps/web/lib/stores/quote-builder-store.ts | 5 +++++ packages/database/prisma/seed.ts | 18 +++++++++--------- 9 files changed, 62 insertions(+), 38 deletions(-) diff --git a/apps/web/app/(dashboard)/invoices/[id]/edit/edit-invoice-form.tsx b/apps/web/app/(dashboard)/invoices/[id]/edit/edit-invoice-form.tsx index f30720c..552ab79 100644 --- a/apps/web/app/(dashboard)/invoices/[id]/edit/edit-invoice-form.tsx +++ b/apps/web/app/(dashboard)/invoices/[id]/edit/edit-invoice-form.tsx @@ -377,9 +377,10 @@ export function EditInvoiceForm({ return match?.[1] ? parseFloat(match[1]) : 0; }, [taxRate, customTaxRate]); - const taxAmount = subtotal * (parsedTaxPercent / 100); - const discountAmount = discountType === 'percent' ? subtotal * (discount / 100) : discount; - const total = Math.max(0, subtotal + taxAmount - discountAmount); + const discountAmount = Math.round((discountType === 'percent' ? subtotal * (discount / 100) : discount) * 100) / 100; + const discountedSubtotal = Math.max(0, Math.round((subtotal - discountAmount) * 100) / 100); + const taxAmount = Math.round(discountedSubtotal * (parsedTaxPercent / 100) * 100) / 100; + const total = Math.max(0, Math.round((discountedSubtotal + taxAmount) * 100) / 100); // ─── Handlers ──────────────────────────────────────── const [addItemOpen, setAddItemOpen] = useState(false); diff --git a/apps/web/app/(dashboard)/invoices/new/new-invoice-form.tsx b/apps/web/app/(dashboard)/invoices/new/new-invoice-form.tsx index 66ea141..d20b41d 100644 --- a/apps/web/app/(dashboard)/invoices/new/new-invoice-form.tsx +++ b/apps/web/app/(dashboard)/invoices/new/new-invoice-form.tsx @@ -408,9 +408,10 @@ export function NewInvoiceForm({ return match?.[1] ? parseFloat(match[1]) : 0; }, [taxRate, customTaxRate]); - const taxAmount = subtotal * (parsedTaxPercent / 100); - const discountAmount = discountType === 'percent' ? subtotal * (discount / 100) : discount; - const total = Math.max(0, subtotal + taxAmount - discountAmount); + const discountAmount = Math.round((discountType === 'percent' ? subtotal * (discount / 100) : discount) * 100) / 100; + const discountedSubtotal = Math.max(0, Math.round((subtotal - discountAmount) * 100) / 100); + const taxAmount = Math.round(discountedSubtotal * (parsedTaxPercent / 100) * 100) / 100; + const total = Math.max(0, Math.round((discountedSubtotal + taxAmount) * 100) / 100); // ─── Handlers ──────────────────────────────────────── const [addItemOpen, setAddItemOpen] = useState(false); diff --git a/apps/web/app/(dashboard)/quotes/[id]/edit/edit-quote-form.tsx b/apps/web/app/(dashboard)/quotes/[id]/edit/edit-quote-form.tsx index 79d48eb..1d562d9 100644 --- a/apps/web/app/(dashboard)/quotes/[id]/edit/edit-quote-form.tsx +++ b/apps/web/app/(dashboard)/quotes/[id]/edit/edit-quote-form.tsx @@ -402,14 +402,18 @@ export default function EditQuoteForm({ quote }: EditQuoteFormProps) { const tpl = (QUOTE_TEMPLATES[templateName] ?? QUOTE_TEMPLATES.clean) as QuoteTemplate; - const subtotal = lineItems.reduce( - (acc, item) => acc + item.quantity * item.rate, + // Round each line item to match server-side calculation (Bug #178) + const subtotal = Math.round(lineItems.reduce( + (acc, item) => acc + Math.round(item.quantity * item.rate * 100) / 100, 0 - ); - const discountAmount = discountType === 'percent' - ? Math.round(subtotal * (discount / 100) * 100) / 100 - : discount; - const total = Math.max(0, subtotal - discountAmount); + ) * 100) / 100; + const discountAmount = Math.round((discountType === 'percent' + ? subtotal * (discount / 100) + : discount) * 100) / 100; + const effectiveTaxRate = customTaxRate ? parseFloat(customTaxRate) : (taxRate !== 'custom' && taxRate !== '0% - Default' ? parseFloat(taxRate) : 0); + const discountedSubtotal = Math.max(0, Math.round((subtotal - discountAmount) * 100) / 100); + const taxAmount = effectiveTaxRate > 0 ? Math.round(discountedSubtotal * (effectiveTaxRate / 100) * 100) / 100 : 0; + const total = Math.max(0, Math.round((discountedSubtotal + taxAmount) * 100) / 100); const expirationDate = issueDate ? addDays(issueDate, parseInt(expirationDays) || 30) diff --git a/apps/web/app/(dashboard)/quotes/new/new-quote-form.tsx b/apps/web/app/(dashboard)/quotes/new/new-quote-form.tsx index bbb95b1..a2bb73d 100644 --- a/apps/web/app/(dashboard)/quotes/new/new-quote-form.tsx +++ b/apps/web/app/(dashboard)/quotes/new/new-quote-form.tsx @@ -299,16 +299,18 @@ export default function NewQuoteForm({ defaultCurrency = 'USD' }: NewQuoteFormPr const tpl = (QUOTE_TEMPLATES[templateName] ?? QUOTE_TEMPLATES.clean) as QuoteTemplate; - const subtotal = lineItems.reduce( - (acc, item) => acc + item.quantity * item.rate, + // Round each line item to match server-side calculation (Bug #178) + const subtotal = Math.round(lineItems.reduce( + (acc, item) => acc + Math.round(item.quantity * item.rate * 100) / 100, 0 - ); - const discountAmount = discountType === 'percent' - ? Math.round(subtotal * (discount / 100) * 100) / 100 - : discount; + ) * 100) / 100; + const discountAmount = Math.round((discountType === 'percent' + ? subtotal * (discount / 100) + : discount) * 100) / 100; const effectiveTaxRate = customTaxRate ? parseFloat(customTaxRate) : (taxRate !== 'custom' && taxRate !== '0% - Default' ? parseFloat(taxRate) : 0); - const taxAmount = effectiveTaxRate > 0 ? Math.round((subtotal - discountAmount) * (effectiveTaxRate / 100) * 100) / 100 : 0; - const total = Math.max(0, subtotal - discountAmount + taxAmount); + const discountedSubtotal = Math.max(0, Math.round((subtotal - discountAmount) * 100) / 100); + const taxAmount = effectiveTaxRate > 0 ? Math.round(discountedSubtotal * (effectiveTaxRate / 100) * 100) / 100 : 0; + const total = Math.max(0, Math.round((discountedSubtotal + taxAmount) * 100) / 100); const expirationDate = issueDate ? addDays(issueDate, parseInt(expirationDays) || 30) diff --git a/apps/web/components/quotes/editor/hooks/useQuoteTotals.ts b/apps/web/components/quotes/editor/hooks/useQuoteTotals.ts index c2c9efb..00c8590 100644 --- a/apps/web/components/quotes/editor/hooks/useQuoteTotals.ts +++ b/apps/web/components/quotes/editor/hooks/useQuoteTotals.ts @@ -14,20 +14,20 @@ export function useQuoteTotals(blocks: QuoteBlock[] | undefined, taxRate: string }, [blocks]); const subtotal = useMemo(() => { - return serviceItems.reduce( - (sum, item) => sum + item.content.quantity * item.content.rate, + return Math.round(serviceItems.reduce( + (sum, item) => sum + Math.round(item.content.quantity * item.content.rate * 100) / 100, 0 - ); + ) * 100) / 100; }, [serviceItems]); const globalTaxRate = parseFloat(taxRate) || 0; const taxAmount = useMemo(() => { - return serviceItems.reduce((sum, item) => { - const lineTotal = item.content.quantity * item.content.rate; + return Math.round(serviceItems.reduce((sum, item) => { + const lineTotal = Math.round(item.content.quantity * item.content.rate * 100) / 100; const itemTaxRate = item.content.taxRate != null ? item.content.taxRate : globalTaxRate; return sum + lineTotal * (itemTaxRate / 100); - }, 0); + }, 0) * 100) / 100; }, [serviceItems, globalTaxRate]); const total = subtotal + taxAmount; diff --git a/apps/web/lib/invoices/actions.ts b/apps/web/lib/invoices/actions.ts index 845bc1c..cc2472f 100644 --- a/apps/web/lib/invoices/actions.ts +++ b/apps/web/lib/invoices/actions.ts @@ -79,11 +79,17 @@ function calculateTotals( discountAmount = Math.min(discountAmount, subtotal); discountAmount = Math.round(discountAmount * 100) / 100; + // Tax should apply to the discounted amount, not the full subtotal. + // Proportionally reduce tax by the same ratio as the discount. + if (subtotal > 0 && discountAmount > 0) { + taxTotal = Math.round(taxTotal * ((subtotal - discountAmount) / subtotal) * 100) / 100; + } + return { subtotal, taxTotal, discountAmount, - total: Math.round((subtotal + taxTotal - discountAmount) * 100) / 100, + total: Math.round((subtotal - discountAmount + taxTotal) * 100) / 100, }; } @@ -162,7 +168,7 @@ export async function createInvoice(data: CreateInvoiceData) { return { success: false, error: 'Discount amount cannot be negative' }; } // Cap fixed discount at subtotal (calculateTotals also clamps, but reject early for clarity) - const preliminarySubtotal = data.lineItems.reduce((sum, item) => sum + item.quantity * item.rate, 0); + const preliminarySubtotal = data.lineItems.reduce((sum, item) => sum + Math.round(item.quantity * item.rate * 100) / 100, 0); if (data.discountValue > preliminarySubtotal) { data.discountValue = preliminarySubtotal; } diff --git a/apps/web/lib/quotes/actions.ts b/apps/web/lib/quotes/actions.ts index 5775b7a..fdd001c 100644 --- a/apps/web/lib/quotes/actions.ts +++ b/apps/web/lib/quotes/actions.ts @@ -414,6 +414,11 @@ export async function updateQuote( } else if (discountType === 'fixed') { discountAmount = Math.min(discountValue, subtotal); } + + // Tax applies to the discounted amount, not the full subtotal + if (subtotal > 0 && discountAmount > 0) { + taxTotal = Math.round(taxTotal * ((subtotal - discountAmount) / subtotal) * 100) / 100; + } const total = subtotal - discountAmount + taxTotal; // Update quote diff --git a/apps/web/lib/stores/quote-builder-store.ts b/apps/web/lib/stores/quote-builder-store.ts index 370bfd5..0e880d9 100644 --- a/apps/web/lib/stores/quote-builder-store.ts +++ b/apps/web/lib/stores/quote-builder-store.ts @@ -499,6 +499,11 @@ export const useQuoteBuilderStore = create()( } } + // Tax applies to the discounted amount, not the full subtotal + if (subtotal > 0 && discountAmount > 0) { + taxTotal = taxTotal * ((subtotal - discountAmount) / subtotal); + } + state.document.totals.subtotal = subtotal; state.document.totals.discountAmount = discountAmount; state.document.totals.taxTotal = taxTotal; diff --git a/packages/database/prisma/seed.ts b/packages/database/prisma/seed.ts index 9837cd2..b5233e0 100644 --- a/packages/database/prisma/seed.ts +++ b/packages/database/prisma/seed.ts @@ -419,42 +419,42 @@ async function main() { // Add line items (C03 fix: add multiple items per quote for realistic preview) // Split the total into primary (70%) and secondary (30%) line items - const primaryAmount = Math.round(q.subtotal * 0.7); - const secondaryAmount = q.subtotal - primaryAmount; + const primaryAmount = Math.round(q.subtotal * 0.7 * 100) / 100; + const secondaryAmount = Math.round((q.subtotal - primaryAmount) * 100) / 100; const primaryQty = Math.max(1, Math.round(q.qty * 0.7)); const secondaryQty = Math.max(1, q.qty - primaryQty); - const primaryRate = Math.round(primaryAmount / primaryQty); - const secondaryRate = Math.round(secondaryAmount / secondaryQty); + const primaryRate = Math.round(primaryAmount / primaryQty * 100) / 100; + const secondaryRate = Math.round(secondaryAmount / secondaryQty * 100) / 100; await prisma.quoteLineItem.upsert({ where: { id: `quote-item-${quote.id}` }, - update: { name: q.itemName, quantity: primaryQty, rate: primaryRate, amount: primaryQty * primaryRate, sortOrder: 0 }, + update: { name: q.itemName, quantity: primaryQty, rate: primaryRate, amount: Math.round(primaryQty * primaryRate * 100) / 100, sortOrder: 0 }, create: { id: `quote-item-${quote.id}`, quoteId: quote.id, name: q.itemName, quantity: primaryQty, rate: primaryRate, - amount: primaryQty * primaryRate, + amount: Math.round(primaryQty * primaryRate * 100) / 100, sortOrder: 0, }, }); await prisma.quoteLineItem.upsert({ where: { id: `quote-item-2-${quote.id}` }, - update: { name: 'Project Management', quantity: secondaryQty, rate: secondaryRate, amount: secondaryQty * secondaryRate, sortOrder: 1 }, + update: { name: 'Project Management', quantity: secondaryQty, rate: secondaryRate, amount: Math.round(secondaryQty * secondaryRate * 100) / 100, sortOrder: 1 }, create: { id: `quote-item-2-${quote.id}`, quoteId: quote.id, name: 'Project Management', quantity: secondaryQty, rate: secondaryRate, - amount: secondaryQty * secondaryRate, + amount: Math.round(secondaryQty * secondaryRate * 100) / 100, sortOrder: 1, }, }); // Update totals to match actual line item sums - const actualTotal = (primaryQty * primaryRate) + (secondaryQty * secondaryRate); + const actualTotal = Math.round((primaryQty * primaryRate + secondaryQty * secondaryRate) * 100) / 100; if (actualTotal !== q.subtotal) { await prisma.quote.update({ where: { id: quote.id }, From 31fe50ac3b8b69d205140a65665bea2970238f91 Mon Sep 17 00:00:00 2001 From: Darsh Gupta Date: Wed, 15 Apr 2026 11:31:29 +0530 Subject: [PATCH 2/2] fix: change taxTotal from const to let for reassignment TypeScript correctly flagged that taxTotal was declared as const but reassigned when applying proportional tax reduction. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/lib/quotes/actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/quotes/actions.ts b/apps/web/lib/quotes/actions.ts index fdd001c..df7f10a 100644 --- a/apps/web/lib/quotes/actions.ts +++ b/apps/web/lib/quotes/actions.ts @@ -391,7 +391,7 @@ export async function updateQuote( // Calculate totals const subtotal = Math.round((lineItems?.reduce((sum, item) => sum + item.amount, 0) || 0) * 100) / 100; - const taxTotal = Math.round((lineItems?.reduce((sum, item) => sum + item.taxAmount, 0) || 0) * 100) / 100; + let taxTotal = Math.round((lineItems?.reduce((sum, item) => sum + item.taxAmount, 0) || 0) * 100) / 100; // Bug #123: Validate discount values server-side const mergedSettings = { ...(existingQuote.settings as Record), ...data.settings };