src/billing/billing.service.ts
Methods |
|
constructor(prisma: PrismaService)
|
||||||
|
Defined in src/billing/billing.service.ts:32
|
||||||
|
Parameters :
|
| Async generateInvoice |
generateInvoice(clientId: string, periodStart: Date, periodEnd: Date)
|
|
Defined in src/billing/billing.service.ts:40
|
|
Generate an invoice for a client for a given period. Uses the Rate model to calculate costs per order.
Returns :
Promise<Invoice>
|
| Async getBillingSummary |
getBillingSummary(orgId: string, periodStart: Date, periodEnd: Date)
|
|
Defined in src/billing/billing.service.ts:181
|
|
Get billing summary for all clients
Returns :
unknown
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface InvoiceLineItem {
orderId: string;
orderNumber: string;
description: string;
quantity: number;
unitPrice: number;
total: number;
}
export interface Invoice {
id: string;
invoiceNumber: string;
clientId: string;
clientName: string;
lineItems: InvoiceLineItem[];
subtotal: number;
fuelSurcharge: number;
tax: number;
total: number;
currency: string;
periodStart: Date;
periodEnd: Date;
status: string;
createdAt: Date;
}
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
constructor(private prisma: PrismaService) {}
/**
* Generate an invoice for a client for a given period.
* Uses the Rate model to calculate costs per order.
*/
async generateInvoice(clientId: string, periodStart: Date, periodEnd: Date): Promise<Invoice> {
const client = await this.prisma.client.findUnique({ where: { id: clientId } });
if (!client) throw new NotFoundException('Client not found');
// Get delivered orders in the period
const orders = await this.prisma.order.findMany({
where: {
clientId,
status: 'DELIVERED',
actualDelivery: { gte: periodStart, lte: periodEnd },
},
include: { trip: true },
});
if (orders.length === 0) {
throw new NotFoundException('No delivered orders found in this period');
}
// Get applicable rates
const rates = await this.prisma.rate.findMany({
where: {
clientId,
effectiveFrom: { lte: periodEnd },
OR: [
{ effectiveTo: null },
{ effectiveTo: { gte: periodStart } },
],
},
});
// Calculate line items
const lineItems: InvoiceLineItem[] = [];
for (const order of orders) {
let cost = 0;
let description = '';
// Find matching rate
const rate = rates.find(r =>
!r.vehicleType || r.vehicleType === order.vehicleType
) || rates[0];
if (rate) {
switch (rate.rateType) {
case 'PER_KM':
const distKm = order.trip?.totalDistance || 100;
cost = Number(rate.baseRate) * distKm;
description = `${order.orderNumber}: ${order.originAddress || 'Origin'} → ${order.destAddress || 'Dest'} (${Math.round(distKm)}km @ ${rate.baseRate}/km)`;
break;
case 'PER_KG':
const weightKg = order.weight || 0;
cost = Number(rate.baseRate) * weightKg;
description = `${order.orderNumber}: ${weightKg}kg @ ${rate.baseRate}/kg`;
break;
case 'FLAT':
default:
cost = Number(rate.baseRate);
description = `${order.orderNumber}: ${order.originAddress || 'Origin'} → ${order.destAddress || 'Dest'} (flat rate)`;
break;
}
// Apply minimum charge
if (rate.minCharge && cost < Number(rate.minCharge)) {
cost = Number(rate.minCharge);
}
} else {
// No rate found — use default flat rate
cost = 50000; // Default 50,000 KES
description = `${order.orderNumber}: ${order.originAddress || 'Origin'} → ${order.destAddress || 'Dest'} (default rate)`;
}
lineItems.push({
orderId: order.id,
orderNumber: order.orderNumber,
description,
quantity: 1,
unitPrice: Math.round(cost * 100) / 100,
total: Math.round(cost * 100) / 100,
});
}
const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0);
const fuelSurchargeRate = rates[0]?.fuelSurchargePercent ? Number(rates[0].fuelSurchargePercent) / 100 : 0;
const fuelSurcharge = Math.round(subtotal * fuelSurchargeRate * 100) / 100;
const taxRate = 0.16; // 16% VAT (Kenya)
const tax = Math.round((subtotal + fuelSurcharge) * taxRate * 100) / 100;
const total = Math.round((subtotal + fuelSurcharge + tax) * 100) / 100;
// Generate invoice number
const invoiceCount = await this.prisma.auditLog.count({ where: { action: 'invoice.generated' } });
const invoiceNumber = `INV-${new Date().getFullYear()}-${String(invoiceCount + 1).padStart(5, '0')}`;
// Get org for audit log
const org = await this.prisma.organization.findFirst();
const orgId = org?.id || '';
// Log the invoice generation
await this.prisma.auditLog.create({
data: {
organizationId: orgId,
action: 'invoice.generated',
entityType: 'Invoice',
entityId: invoiceNumber,
newValues: {
clientId,
clientName: client.name,
lineItems: lineItems as unknown as Record<string, unknown>[],
subtotal,
fuelSurcharge,
tax,
total,
currency: 'KES',
periodStart: periodStart.toISOString(),
periodEnd: periodEnd.toISOString(),
} as any,
},
});
this.logger.log(`Invoice ${invoiceNumber} generated for ${client.name}: ${total} KES`);
return {
id: invoiceNumber,
invoiceNumber,
clientId,
clientName: client.name,
lineItems,
subtotal,
fuelSurcharge,
tax,
total,
currency: 'KES',
periodStart,
periodEnd,
status: 'GENERATED',
createdAt: new Date(),
};
}
/**
* Get billing summary for all clients
*/
async getBillingSummary(orgId: string, periodStart: Date, periodEnd: Date) {
const clients = await this.prisma.client.findMany({
where: { organizationId: orgId },
include: {
orders: {
where: {
status: 'DELIVERED',
actualDelivery: { gte: periodStart, lte: periodEnd },
},
select: { id: true, weight: true },
},
},
});
return clients.map(client => ({
clientId: client.id,
clientName: client.name,
deliveredOrders: client.orders.length,
totalWeightKg: client.orders.reduce((sum, o) => sum + (o.weight || 0), 0),
})).filter(c => c.deliveredOrders > 0);
}
}