Skip to content
Draft
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
## Billing and Invoicing

This workspace includes Prisma models for invoices and a TypeScript calculation module that supports:

- Line-level discounts and taxes (percent or flat)
- Header-level discounts and taxes
- Reverse-calculation for: given a desired total, compute the required discount

### Files

- `prisma/schema.prisma`: Database schema with `invoices`, `invoice_items`, `invoice_payments` and supporting enums.
- `src/billing/calculations.ts`: Pure functions for computing totals and reverse-calculation.

### Usage

```ts
import {
calculateLineTotals,
aggregateInvoice,
reverseDiscountForLine,
reverseHeaderDiscount,
} from "./src/billing/calculations";

const line = calculateLineTotals({
quantity: 2,
unitPrice: 500,
discountType: "PERCENT",
discountValue: 10,
taxType: "PERCENT",
taxValue: 18,
});

const totals = aggregateInvoice([line], {
discountType: "FLAT",
discountValue: 50,
taxType: "NONE",
});

// Reverse: find discount percent to hit a desired line total
const neededLineDiscountPercent = reverseDiscountForLine(900, {
quantity: 2,
unitPrice: 500,
discountType: "PERCENT",
taxType: "PERCENT",
taxValue: 18,
});

// Reverse header discount to hit desired grand total
const neededHeaderDiscountFlat = reverseHeaderDiscount(800, [line], {
discountType: "FLAT",
taxType: "NONE",
});
```

188 changes: 188 additions & 0 deletions dist/src/billing/calculations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function roundCurrency(value) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
export function calculateLineTotals(input) {
const quantity = Math.max(0, input.quantity || 0);
const unitPrice = Math.max(0, input.unitPrice || 0);
const discountType = input.discountType ?? "NONE";
const discountValueRaw = Math.max(0, input.discountValue ?? 0);
const taxType = input.taxType ?? "NONE";
const taxValueRaw = Math.max(0, input.taxValue ?? 0);
const lineSubtotal = quantity * unitPrice;
let lineDiscount = 0;
if (discountType === "PERCENT") {
const pct = clamp(discountValueRaw, 0, 100);
lineDiscount = (pct / 100) * lineSubtotal;
}
else if (discountType === "FLAT") {
lineDiscount = Math.min(discountValueRaw, lineSubtotal);
}
const afterDiscount = Math.max(0, lineSubtotal - lineDiscount);
let lineTax = 0;
if (taxType === "PERCENT") {
const pct = clamp(taxValueRaw, 0, 100);
lineTax = (pct / 100) * afterDiscount;
}
else if (taxType === "FLAT") {
lineTax = taxValueRaw;
}
const lineTotal = roundCurrency(afterDiscount + lineTax);
return {
lineSubtotal: roundCurrency(lineSubtotal),
lineDiscount: roundCurrency(lineDiscount),
lineTax: roundCurrency(lineTax),
lineTotal,
};
}
export function aggregateInvoice(lines, header = {}) {
const subtotal = roundCurrency(lines.reduce((sum, l) => sum + l.lineSubtotal, 0));
const lineDiscountSum = roundCurrency(lines.reduce((sum, l) => sum + l.lineDiscount, 0));
const lineTaxSum = roundCurrency(lines.reduce((sum, l) => sum + l.lineTax, 0));
// Header discount on subtotal (not on lineTotal)
const discountType = header.discountType ?? "NONE";
const discountValue = Math.max(0, header.discountValue ?? 0);
let headerDiscount = 0;
if (discountType === "PERCENT") {
headerDiscount = (clamp(discountValue, 0, 100) / 100) * subtotal;
}
else if (discountType === "FLAT") {
headerDiscount = Math.min(discountValue, subtotal);
}
const afterHeaderDiscount = Math.max(0, subtotal - headerDiscount);
// Header tax (post-discount) independent of line taxes
const taxType = header.taxType ?? "NONE";
const taxValue = Math.max(0, header.taxValue ?? 0);
let headerTax = 0;
if (taxType === "PERCENT") {
headerTax = (clamp(taxValue, 0, 100) / 100) * afterHeaderDiscount;
}
else if (taxType === "FLAT") {
headerTax = taxValue;
}
const totalDiscount = roundCurrency(lineDiscountSum + headerDiscount);
const totalTax = roundCurrency(lineTaxSum + headerTax);
const grandTotal = roundCurrency(afterHeaderDiscount + headerTax + lineTaxSum);
return { subtotal, totalDiscount, totalTax, grandTotal };
}
// Reverse calculation helpers
// Given a desired lineTotal, compute required discountValue for a given discountType
export function reverseDiscountForLine(desiredLineTotal, input) {
const baseTotals = calculateLineTotals({ ...input, discountValue: 0 });
const lineSubtotal = baseTotals.lineSubtotal;
const taxType = input.taxType ?? "NONE";
const taxValue = Math.max(0, input.taxValue ?? 0);
// We solve for discount so that: desiredTotal = (subtotal - discount) + tax((subtotal - discount))
// Casework by tax type and discount type
if (input.discountType === "FLAT") {
if (taxType === "NONE") {
// desired = subtotal - D => D = subtotal - desired
const flat = Math.max(0, lineSubtotal - desiredLineTotal);
return roundCurrency(clamp(flat, 0, lineSubtotal));
}
if (taxType === "PERCENT") {
const pct = clamp(taxValue, 0, 100) / 100;
// desired = (subtotal - D) * (1 + pct)
// => subtotal - D = desired / (1 + pct)
// => D = subtotal - desired/(1+pct)
const targetBase = desiredLineTotal / (1 + pct);
const flat = Math.max(0, lineSubtotal - targetBase);
return roundCurrency(clamp(flat, 0, lineSubtotal));
}
if (taxType === "FLAT") {
// desired = (subtotal - D) + taxFlat
const flat = Math.max(0, lineSubtotal + Math.max(0, taxValue) - desiredLineTotal);
return roundCurrency(clamp(flat, 0, lineSubtotal));
}
}
else if (input.discountType === "PERCENT") {
if (taxType === "NONE") {
// desired = subtotal * (1 - d)
// d = 1 - desired/subtotal
if (lineSubtotal === 0)
return 0;
const d = 1 - desiredLineTotal / lineSubtotal;
return roundCurrency(clamp(d * 100, 0, 100));
}
if (taxType === "PERCENT") {
const t = clamp(taxValue, 0, 100) / 100;
// desired = subtotal * (1 - d) * (1 + t)
// => (1 - d) = desired / (subtotal * (1 + t))
// => d = 1 - desired/(subtotal*(1+t))
if (lineSubtotal === 0)
return 0;
const d = 1 - desiredLineTotal / (lineSubtotal * (1 + t));
return roundCurrency(clamp(d * 100, 0, 100));
}
if (taxType === "FLAT") {
// desired = subtotal * (1 - d) + taxFlat
if (lineSubtotal === 0)
return 0;
const d = 1 - (desiredLineTotal - Math.max(0, taxValue)) / lineSubtotal;
return roundCurrency(clamp(d * 100, 0, 100));
}
}
return 0;
}
// Reverse at header level: compute the header discount value to hit a desired grand total
export function reverseHeaderDiscount(desiredGrandTotal, lines, header) {
const totalsNoHeaderDiscount = aggregateInvoice(lines, {
...header,
discountType: "NONE",
discountValue: 0,
});
const base = totalsNoHeaderDiscount.subtotal;
const lineTax = totalsNoHeaderDiscount.totalTax; // only line tax, since header.tax applied separately below
const taxType = header.taxType ?? "NONE";
const taxValue = Math.max(0, header.taxValue ?? 0);
if (header.discountType === "FLAT") {
if (taxType === "NONE") {
// desired = base - D + lineTax
const flat = Math.max(0, base + lineTax - desiredGrandTotal);
return roundCurrency(clamp(flat, 0, base));
}
if (taxType === "PERCENT") {
const t = clamp(taxValue, 0, 100) / 100;
// desired = (base - D) * (1 + t) + lineTax
// => base - D = (desired - lineTax) / (1 + t)
// => D = base - (desired - lineTax)/(1+t)
const targetBase = (desiredGrandTotal - lineTax) / (1 + t);
const flat = Math.max(0, base - targetBase);
return roundCurrency(clamp(flat, 0, base));
}
if (taxType === "FLAT") {
// desired = (base - D) + headerTax + lineTax
const flat = Math.max(0, base + Math.max(0, taxValue) + lineTax - desiredGrandTotal);
return roundCurrency(clamp(flat, 0, base));
}
}
else if (header.discountType === "PERCENT") {
if (taxType === "NONE") {
// desired = base * (1 - d) + lineTax
if (base === 0)
return 0;
const d = 1 - (desiredGrandTotal - lineTax) / base;
return roundCurrency(clamp(d * 100, 0, 100));
}
if (taxType === "PERCENT") {
const t = clamp(taxValue, 0, 100) / 100;
// desired = base * (1 - d) * (1 + t) + lineTax
// => (1 - d) = (desired - lineTax)/(base*(1+t))
// => d = 1 - (desired - lineTax)/(base*(1+t))
if (base === 0)
return 0;
const d = 1 - (desiredGrandTotal - lineTax) / (base * (1 + t));
return roundCurrency(clamp(d * 100, 0, 100));
}
if (taxType === "FLAT") {
// desired = base * (1 - d) + headerTax + lineTax
if (base === 0)
return 0;
const d = 1 - (desiredGrandTotal - Math.max(0, taxValue) - lineTax) / base;
return roundCurrency(clamp(d * 100, 0, 100));
}
}
return 0;
}
30 changes: 30 additions & 0 deletions dist/src/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { calculateLineTotals, aggregateInvoice, reverseDiscountForLine, reverseHeaderDiscount, } from "./billing/calculations.js";
const line = calculateLineTotals({
quantity: 3,
unitPrice: 400,
discountType: "PERCENT",
discountValue: 5,
taxType: "PERCENT",
taxValue: 18,
});
const totals = aggregateInvoice([line], {
discountType: "FLAT",
discountValue: 50,
taxType: "NONE",
});
console.log({ line, totals });
const targetLineTotal = 1000;
const neededLineDiscount = reverseDiscountForLine(targetLineTotal, {
quantity: 3,
unitPrice: 400,
discountType: "PERCENT",
taxType: "PERCENT",
taxValue: 18,
});
console.log({ targetLineTotal, neededLineDiscount });
const neededHeaderDiscount = reverseHeaderDiscount(1000, [line], {
discountType: "FLAT",
taxType: "PERCENT",
taxValue: 5,
});
console.log({ neededHeaderDiscount });
1 change: 1 addition & 0 deletions node_modules/.bin/acorn

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/prisma

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/ts-node

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/ts-node-cwd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/ts-node-esm

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/ts-node-script

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/ts-node-transpile-only

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/ts-script

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/tsc

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/tsserver

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading