Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions apps/web/app/(dashboard)/invoices/new/new-invoice-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 11 additions & 7 deletions apps/web/app/(dashboard)/quotes/[id]/edit/edit-quote-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 10 additions & 8 deletions apps/web/app/(dashboard)/quotes/new/new-quote-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,16 +299,18 @@

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)
Expand Down Expand Up @@ -447,7 +449,7 @@
} finally {
setPdfGenerating(false);
}
}, [quoteNumber, toast]);

Check warning on line 452 in apps/web/app/(dashboard)/quotes/new/new-quote-form.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useCallback has an unnecessary dependency: 'toast'. Either exclude it or remove the dependency array. Outer scope values like 'toast' aren't valid dependencies because mutating them doesn't re-render the component

return (
<div className="flex flex-col h-[calc(100vh-64px)]">
Expand Down
12 changes: 6 additions & 6 deletions apps/web/components/quotes/editor/hooks/useQuoteTotals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions apps/web/lib/invoices/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion apps/web/lib/quotes/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>), ...data.settings };
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/web/lib/stores/quote-builder-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,11 @@ export const useQuoteBuilderStore = create<QuoteBuilderStore>()(
}
}

// 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;
Expand Down
18 changes: 9 additions & 9 deletions packages/database/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading