Added discounts to invoices
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
This commit is contained in:
@@ -277,6 +277,9 @@ export const invoices = sqliteTable(
|
||||
notes: text("notes"),
|
||||
currency: text("currency").default("USD").notNull(),
|
||||
subtotal: integer("subtotal").notNull().default(0), // in cents
|
||||
discountValue: real("discount_value").default(0),
|
||||
discountType: text("discount_type").default("percentage"), // 'percentage' or 'fixed'
|
||||
discountAmount: integer("discount_amount").default(0), // in cents
|
||||
taxRate: real("tax_rate").default(0), // percentage
|
||||
taxAmount: integer("tax_amount").notNull().default(0), // in cents
|
||||
total: integer("total").notNull().default(0), // in cents
|
||||
|
||||
@@ -4,12 +4,7 @@ import { invoices, members } from "../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
||||
|
||||
export const POST: APIRoute = async ({
|
||||
request,
|
||||
redirect,
|
||||
locals,
|
||||
params,
|
||||
}) => {
|
||||
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
@@ -38,8 +33,8 @@ export const POST: APIRoute = async ({
|
||||
.where(
|
||||
and(
|
||||
eq(members.userId, user.id),
|
||||
eq(members.organizationId, invoice.organizationId)
|
||||
)
|
||||
eq(members.organizationId, invoice.organizationId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
@@ -53,6 +48,8 @@ export const POST: APIRoute = async ({
|
||||
const issueDateStr = formData.get("issueDate") as string;
|
||||
const dueDateStr = formData.get("dueDate") as string;
|
||||
const taxRateStr = formData.get("taxRate") as string;
|
||||
const discountType = (formData.get("discountType") as string) || "percentage";
|
||||
const discountValueStr = formData.get("discountValue") as string;
|
||||
const notes = formData.get("notes") as string;
|
||||
|
||||
if (!number || !currency || !issueDateStr || !dueDateStr) {
|
||||
@@ -64,6 +61,11 @@ export const POST: APIRoute = async ({
|
||||
const dueDate = new Date(dueDateStr);
|
||||
const taxRate = taxRateStr ? parseFloat(taxRateStr) : 0;
|
||||
|
||||
let discountValue = discountValueStr ? parseFloat(discountValueStr) : 0;
|
||||
if (discountType === "fixed") {
|
||||
discountValue = Math.round(discountValue * 100);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
@@ -72,6 +74,8 @@ export const POST: APIRoute = async ({
|
||||
issueDate,
|
||||
dueDate,
|
||||
taxRate,
|
||||
discountType: discountType as "percentage" | "fixed",
|
||||
discountValue,
|
||||
notes: notes || null,
|
||||
})
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
|
||||
@@ -294,6 +294,15 @@ const isDraft = invoice.status === 'draft';
|
||||
<span class="text-base-content/60">Subtotal</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
|
||||
</div>
|
||||
{(invoice.discountAmount > 0) && (
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">
|
||||
Discount
|
||||
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
|
||||
</span>
|
||||
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
|
||||
<div class="flex justify-between text-sm items-center group">
|
||||
<span class="text-base-content/60 flex items-center gap-2">
|
||||
|
||||
@@ -38,6 +38,10 @@ if (!membership) {
|
||||
// Format dates for input[type="date"]
|
||||
const issueDateStr = invoice.issueDate.toISOString().split('T')[0];
|
||||
const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
|
||||
|
||||
const discountValueDisplay = invoice.discountType === 'fixed'
|
||||
? (invoice.discountValue || 0) / 100
|
||||
: (invoice.discountValue || 0);
|
||||
---
|
||||
|
||||
<DashboardLayout title={`Edit ${invoice.number} - Chronus`}>
|
||||
@@ -112,6 +116,27 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Discount -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Discount</span>
|
||||
</label>
|
||||
<div class="join w-full">
|
||||
<select name="discountType" class="select select-bordered join-item">
|
||||
<option value="percentage" selected={!invoice.discountType || invoice.discountType === 'percentage'}>%</option>
|
||||
<option value="fixed" selected={invoice.discountType === 'fixed'}>Fixed</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
name="discountValue"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input input-bordered join-item w-full"
|
||||
value={discountValueDisplay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tax Rate -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
|
||||
@@ -40,6 +40,9 @@ interface Invoice {
|
||||
dueDate: Date;
|
||||
currency: string;
|
||||
subtotal: number;
|
||||
discountValue: number | null;
|
||||
discountType: string | null;
|
||||
discountAmount: number | null;
|
||||
taxRate: number | null;
|
||||
taxAmount: number;
|
||||
total: number;
|
||||
@@ -503,6 +506,24 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
|
||||
formatCurrency(invoice.subtotal),
|
||||
),
|
||||
]),
|
||||
(invoice.discountAmount ?? 0) > 0
|
||||
? h(View, { style: styles.totalRow }, [
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.totalLabel },
|
||||
`Discount${
|
||||
invoice.discountType === "percentage"
|
||||
? ` (${invoice.discountValue}%)`
|
||||
: ""
|
||||
}`,
|
||||
),
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.totalValue },
|
||||
`-${formatCurrency(invoice.discountAmount ?? 0)}`,
|
||||
),
|
||||
])
|
||||
: null,
|
||||
(invoice.taxRate ?? 0) > 0
|
||||
? h(View, { style: styles.totalRow }, [
|
||||
h(
|
||||
|
||||
@@ -27,16 +27,34 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
|
||||
// Note: amounts are in cents
|
||||
const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
|
||||
|
||||
const taxRate = invoice.taxRate || 0;
|
||||
const taxAmount = Math.round(subtotal * (taxRate / 100));
|
||||
// Calculate discount
|
||||
const discountType = invoice.discountType || "percentage";
|
||||
const discountValue = invoice.discountValue || 0;
|
||||
let discountAmount = 0;
|
||||
|
||||
const total = subtotal + taxAmount;
|
||||
if (discountType === "percentage") {
|
||||
discountAmount = Math.round(subtotal * (discountValue / 100));
|
||||
} else {
|
||||
// Fixed amount is assumed to be in cents
|
||||
discountAmount = Math.round(discountValue);
|
||||
}
|
||||
|
||||
// Ensure discount doesn't exceed subtotal
|
||||
discountAmount = Math.max(0, Math.min(discountAmount, subtotal));
|
||||
|
||||
const taxableAmount = subtotal - discountAmount;
|
||||
|
||||
const taxRate = invoice.taxRate || 0;
|
||||
const taxAmount = Math.round(taxableAmount * (taxRate / 100));
|
||||
|
||||
const total = taxableAmount + taxAmount;
|
||||
|
||||
// Update invoice
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
subtotal,
|
||||
discountAmount,
|
||||
taxAmount,
|
||||
total,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user