File

src/billing/billing.service.ts

Index

Methods

Constructor

constructor(prisma: PrismaService)
Parameters :
Name Type Optional
prisma PrismaService No

Methods

Async generateInvoice
generateInvoice(clientId: string, periodStart: Date, periodEnd: Date)

Generate an invoice for a client for a given period. Uses the Rate model to calculate costs per order.

Parameters :
Name Type Optional
clientId string No
periodStart Date No
periodEnd Date No
Returns : Promise<Invoice>
Async getBillingSummary
getBillingSummary(orgId: string, periodStart: Date, periodEnd: Date)

Get billing summary for all clients

Parameters :
Name Type Optional
orgId string No
periodStart Date No
periodEnd Date No
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);
  }
}

results matching ""

    No results matching ""