# Sales Module Implementation Guide

Practical walkthrough applying the module architecture blueprint to a new Sales domain module.

## Overview

Sales module handles customer invoices, line items, discounts, tax, payments, and workflow.

**Key Entities:**
- SalesInvoice (main aggregate)
- SalesInvoiceLine
- SalesPayment
- SalesReturn

**Initial Scope:** `createSalesInvoice()` command (similar to purchase order creation)

---

## Step 1: Domain Layer

### File: `lib/modules/sales/domain/sales-invoice.model.ts`

```typescript
import type { SalesInvoiceItem, SalesInvoiceDocument, SalesPaymentMethod } from '@/lib/types';

// Enums: match purchase patterns
export type SalesPostingStatus = 'draft' | 'saved' | 'posted';
export type SalesTaxMethod = 'tax-inclusive' | 'invoice-level' | 'line-level' | 'tax-exempt';
export type SalesWorkflowStatus = 'active' | 'cancelled' | 'returned' | 'archived';

// Input Command
export type SalesInvoiceCreateCommand = {
  customerId: string;
  customerName: string;
  customerInvoiceNumber?: string;
  customerInvoiceDate?: string;
  warehouseId?: string;
  allowDuplicateInvoice?: boolean;
  notes?: string;
  documents: SalesInvoiceDocument[];
  items: Array<{
    materialId: string;
    name: string;
    quantity: number;
    unitPrice: number;
    total: number;
    lineDiscountPercent?: number;
    lineDiscountAmount?: number;
    warehouseId?: string;
    shelfId?: string;
    taxRate?: number;
    taxAmount?: number;
  }>;
  taxMethod: SalesTaxMethod;
  postingStatus: SalesPostingStatus;
  invoiceTaxRate: number;
  invoiceDiscountPercent: number;
  invoiceDiscountAmount: number;
  amountPaid: number;
  paymentMethods: SalesPaymentMethod[];
  currencyCode: string;
  baseCurrencyCode: string;
  exchangeRate: number;
  rateDate: string;
  rateSource?: string;
  isAutoRate?: boolean;
};

// Calculated totals (domain responsibility)
export type SalesCalculatedTotals = {
  processedItems: SalesInvoiceItem[];
  subTotal: number;
  discountedSubTotal: number;
  lineDiscountTotal: number;
  invoiceDiscountAmount: number;
  totalDiscount: number;
  taxAmount: number;
  grandTotal: number;
  amountDue: number;
};

// Resolver for lookups
export type TaxRateResolver = (materialId: string) => number;
```

### File: `lib/modules/sales/domain/sales-invoice.domain.ts`

```typescript
import type { SalesInvoiceCreateCommand, SalesCalculatedTotals, TaxRateResolver } from './sales-invoice.model';

export type SalesPostingStatus = 'draft' | 'saved' | 'posted';
export type SalesWorkflowStatus = 'active' | 'cancelled' | 'returned' | 'archived';

// Pure domain function: calculate totals (matches purchase pattern)
export function calculateSalesInvoiceTotals(
  command: SalesInvoiceCreateCommand,
  getTaxRate: TaxRateResolver
): SalesCalculatedTotals {
  const items = (command.items || []).filter((item) => Number(item.quantity || 0) > 0);

  let subTotal = 0;
  let lineDiscountTotal = 0;
  const processedItems = items.map((item) => {
    const qty = Number(item.quantity || 0);
    const unitPrice = Number(item.unitPrice || 0);
    const lineSubTotal = qty * unitPrice;
    const lineDiscount = Number(item.lineDiscountAmount || 0);
    const lineNet = lineSubTotal - lineDiscount;

    subTotal += lineNet;
    lineDiscountTotal += lineDiscount;

    const taxRate = command.taxMethod === 'line-level' ? getTaxRate(item.materialId) : 0;
    const lineTax = command.taxMethod === 'line-level' ? lineNet * (taxRate / 100) : 0;

    return {
      ...item,
      taxAmount: lineTax,
    };
  });

  const invoiceDiscount = Number(command.invoiceDiscountAmount || 0);
  const discountedSubTotal = Math.max(0, subTotal - invoiceDiscount);
  const totalDiscount = lineDiscountTotal + invoiceDiscount;

  let taxAmount = 0;
  if (command.taxMethod === 'invoice-level') {
    const invoiceTaxRate = Number(command.invoiceTaxRate || 0);
    taxAmount = discountedSubTotal * (invoiceTaxRate / 100);
  } else if (command.taxMethod === 'line-level') {
    taxAmount = processedItems.reduce((sum, item) => sum + Number(item.taxAmount || 0), 0);
  }

  const grandTotal = discountedSubTotal + taxAmount;
  const amountDue = Math.max(0, grandTotal - Number(command.amountPaid || 0));

  return {
    processedItems,
    subTotal,
    discountedSubTotal,
    lineDiscountTotal,
    invoiceDiscountAmount: invoiceDiscount,
    totalDiscount,
    taxAmount,
    grandTotal,
    amountDue,
  };
}

// Status transition validation
export function canTransitionPostingStatus(from: SalesPostingStatus, to: SalesPostingStatus): boolean {
  const transitions: Record<SalesPostingStatus, SalesPostingStatus[]> = {
    draft: ['saved', 'posted'],
    saved: ['posted', 'draft'],
    posted: [], // Posted invoices are immutable in this domain
  };
  return transitions[from].includes(to);
}

export function canTransitionWorkflowStatus(from: SalesWorkflowStatus, to: SalesWorkflowStatus): boolean {
  const transitions: Record<SalesWorkflowStatus, SalesWorkflowStatus[]> = {
    active: ['cancelled', 'returned', 'archived'],
    cancelled: ['archived'],
    returned: ['archived'],
    archived: [],
  };
  return transitions[from].includes(to);
}

// Label helpers
export function getSalesPostingStatusLabel(status: SalesPostingStatus): string {
  return { draft: 'مسودة', saved: 'محفوظة', posted: 'مرسلة' }[status];
}

export function getSalesWorkflowStatusLabel(status: SalesWorkflowStatus): string {
  return { active: 'نشطة', cancelled: 'ملغاة', returned: 'مرتجعة', archived: 'مؤرشفة' }[status];
}
```

---

## Step 2: Application Layer (Ports)

### File: `lib/modules/sales/application/sales-invoice.repository.ts`

```typescript
import type { Customer, SalesInvoice } from '@/lib/types';

export type CreateSalesInvoiceRecord = Omit<
  SalesInvoice,
  'invoiceNumber' | 'status' | 'createdAt' | 'lockedById' | 'lockedByName' | 'lockedAt' | 'cancelledAt' | 'cancelledBy' | 'cancelledReason'
> & {
  id: string;
  createdBy: string;
};

// Repository interface
export interface ISalesInvoiceRepository {
  findCustomerById(id: string): Promise<Customer | null>;
  listSalesInvoices(): Promise<SalesInvoice[]>;
  getSalesInvoiceById(id: string): Promise<SalesInvoice | null>;
  createSalesInvoice(invoice: CreateSalesInvoiceRecord): Promise<SalesInvoice>;
  addCustomerBalance(customerId: string, delta: number): Promise<void>;
}
```

### File: `lib/modules/sales/application/sales-id-generator.port.ts`

```typescript
export interface ISalesIdGeneratorPort {
  generateSalesInvoiceId(): string;
}
```

### File: `lib/modules/sales/application/sales-accounting.port.ts`

```typescript
import type { ShipmentType } from '@/lib/types';

export type SalesJournalPostingInput = {
  invoiceNumber: string;
  customerId: string;
  customerName: string;
  totalAmountBase: number;
  taxAmountBase: number;
  discountAmountBase: number;
  amountPaidBase: number;
  createdBy: string;
  opts?: {
    invoiceId?: string;
    shipmentId?: string;
    shipmentType?: ShipmentType;
    paymentMethods?: any[];
    fxRate?: number;
  };
};

export interface ISalesAccountingPort {
  postSalesInvoiceJournalEntry(input: SalesJournalPostingInput): Promise<void>;
}
```

### File: `lib/modules/sales/application/sales-side-effects.port.ts`

```typescript
import type { SalesInvoiceItem } from '@/lib/types';

export type LinkedShipment = { id: string; shipmentType: 'internal' | 'customer' | 'return' } | null;

export interface ISalesSideEffectsPort {
  // Shipment creation
  async createSalesShipment(input: {
    invoice: { id: string; date: string };
    items: SalesInvoiceItem[];
    customerId: string;
    customerName: string;
    warehouseId?: string;
    settings: Record<string, unknown>;
  }): Promise<LinkedShipment>;

  // Inventory reduction
  async deductInventoryForPostedInvoice(input: {
    invoice: { id: string; date: string };
    items: SalesInvoiceItem[];
  }): Promise<void>;

  // Price sync (update COGS if needed)
  async syncMaterialCostFromInvoice(input: {
    items: SalesInvoiceItem[];
    invoiceDate: string;
  }): Promise<void>;
}
```

### File: `lib/modules/sales/application/sales-reference-data.port.ts`

```typescript
export interface ISalesReferenceDataPort {
  getSettings(): Record<string, unknown>;
  getDefaultBaseCurrencyCode(): string;
  getTaxRateForMaterial(materialId: string, defaultRate: number): number;
  convertCurrency(amount: number, from: string, to: string): number;
  assertNotLockedByPeriod(date: string, periodCloseDate?: string): void;
  async getItemGroupsForTaxCalc(): Promise<Array<{ id: string; taxRate: number }>>;
  getStockLocationsByMaterial(materialIds: string[]): any[];
  nextPrefixedId(prefix: string): string;
}
```

### File: `lib/modules/sales/application/sales-invoice-application-service.ts`

```typescript
import type { SalesInvoice, SalesInvoiceLogEntry } from '@/lib/types';
import { calculateSalesInvoiceTotals, getSalesPostingStatusLabel } from '../domain/sales-invoice.domain';
import type { SalesInvoiceCreateCommand } from '../domain/sales-invoice.model';
import type { CreateSalesInvoiceRecord, ISalesInvoiceRepository } from './sales-invoice.repository';
import { createSalesInvoiceRepository } from '../infrastructure/sales-invoice.repository.factory';
import type { ISalesSideEffectsPort, LinkedShipment } from './sales-side-effects.port';
import { createDefaultSalesSideEffectsPort } from '../infrastructure/default-sales-side-effects.port';
import type { ISalesReferenceDataPort } from './sales-reference-data.port';
import { createDefaultSalesReferenceDataPort } from '../infrastructure/default-sales-reference-data.port';
import type { ISalesAccountingPort, SalesJournalPostingInput } from './sales-accounting.port';
import { createDefaultSalesAccountingPort } from '../infrastructure/default-sales-accounting.port';
import type { ISalesIdGeneratorPort } from './sales-id-generator.port';
import { createDefaultSalesIdGeneratorPort } from '../infrastructure/default-sales-id-generator.port';

export type SalesJournalPayload = SalesJournalPostingInput;

export type CreateSalesInvoiceResult =
  | {
      success: true;
      invoiceNumber: string;
      stockReport: any[];
      postingStatus: 'draft' | 'saved' | 'posted';
      journalPayload?: SalesJournalPayload;
      revalidateInventory: boolean;
    }
  | {
      error: string;
      existing?: {
        invoiceNumber: string;
        date: string;
        grandTotal: number;
        amountDue: number;
      };
    };

function createSalesInvoiceLogEntry(
  action: SalesInvoiceLogEntry['action'],
  message: string,
  by: string | undefined,
  referenceData: ISalesReferenceDataPort
): SalesInvoiceLogEntry {
  return {
    id: referenceData.nextPrefixedId('si-log'),
    at: new Date().toISOString(),
    action,
    message,
    by,
  };
}

export class SalesInvoiceApplicationService {
  constructor(
    private readonly repository: ISalesInvoiceRepository,
    private readonly sideEffects: ISalesSideEffectsPort,
    private readonly referenceData: ISalesReferenceDataPort = createDefaultSalesReferenceDataPort(),
    private readonly accounting: ISalesAccountingPort = createDefaultSalesAccountingPort(),
    private readonly idGenerator: ISalesIdGeneratorPort = createDefaultSalesIdGeneratorPort()
  ) {}

  static createDefault(): SalesInvoiceApplicationService {
    return new SalesInvoiceApplicationService(
      createSalesInvoiceRepository(),
      createDefaultSalesSideEffectsPort(),
      createDefaultSalesReferenceDataPort(),
      createDefaultSalesAccountingPort(),
      createDefaultSalesIdGeneratorPort()
    );
  }

  private async validateCustomerAndDuplicates(command: SalesInvoiceCreateCommand): Promise<
    { customerId: string; customerName: string } | { error: string; existing?: any }
  > {
    const customer = await this.repository.findCustomerById(command.customerId);
    if (!customer) {
      return { error: 'Customer not found.' };
    }

    const normalizedInvoice = String(command.customerInvoiceNumber || '').trim().toLowerCase();
    if (normalizedInvoice) {
      const existingInvoices = await this.repository.listSalesInvoices();
      const duplicate = existingInvoices.find(
        (inv) =>
          inv.customerId === command.customerId &&
          String(inv.customerInvoiceNumber || '').trim().toLowerCase() === normalizedInvoice
      );

      if (duplicate && !command.allowDuplicateInvoice) {
        return {
          error: 'DUPLICATE_CUSTOMER_INVOICE',
          existing: {
            invoiceNumber: duplicate.invoiceNumber,
            date: duplicate.date,
            grandTotal: duplicate.grandTotal,
            amountDue: duplicate.amountDue,
          },
        };
      }
    }

    return { customerId: command.customerId, customerName: command.customerName };
  }

  private async persistInvoice(
    command: SalesInvoiceCreateCommand,
    actor: { id: string; name?: string },
    totals: any,
    settings: Record<string, unknown>
  ): Promise<{ invoice: SalesInvoice; fxRate: number }> {
    const fxRate = Number(command.exchangeRate || 1);
    const netSalesBase = totals.discountedSubTotal * fxRate;
    const taxAmountBase = totals.taxAmount * fxRate;

    if (command.postingStatus === 'posted') {
      await this.repository.addCustomerBalance(command.customerId, netSalesBase + taxAmountBase);
    }

    const activityLog: SalesInvoiceLogEntry[] = [
      createSalesInvoiceLogEntry(
        'created',
        `تم إنشاء الفاتورة بحالة ${getSalesPostingStatusLabel(command.postingStatus)}.`,
        actor.name || actor.id,
        this.referenceData
      ),
    ];

    const generatedInvoiceId = this.idGenerator.generateSalesInvoiceId();

    const toCreate: CreateSalesInvoiceRecord = {
      id: generatedInvoiceId,
      date: new Date().toISOString(),
      customerId: command.customerId,
      customerName: command.customerName,
      items: totals.processedItems,
      subTotal: totals.subTotal,
      taxAmount: totals.taxAmount,
      grandTotal: totals.grandTotal,
      amountPaid: command.amountPaid,
      amountDue: totals.amountDue,
      postingStatus: command.postingStatus,
      currencyCode: command.currencyCode,
      baseCurrencyCode: command.baseCurrencyCode,
      exchangeRate: fxRate,
      rateDate: command.rateDate,
      rateSource: command.rateSource || 'manual',
      isAutoRate: command.isAutoRate ?? true,
      createdBy: actor.name || 'admin',
      activityLog,
      documents: command.documents,
      notes: command.notes,
    };

    const invoice = await this.repository.createSalesInvoice(toCreate);
    return { invoice, fxRate };
  }

  private async executePostedSideEffects(
    command: SalesInvoiceCreateCommand,
    invoice: SalesInvoice,
    totals: any,
    settings: Record<string, unknown>,
    actor: { id: string; name?: string }
  ): Promise<{ linkedShipment: LinkedShipment; error?: string }> {
    if (command.postingStatus !== 'posted') {
      return { linkedShipment: null };
    }

    // Create shipment
    const linkedShipment = await this.sideEffects.createSalesShipment({
      invoice: { id: invoice.id, date: invoice.date },
      items: totals.processedItems,
      customerId: command.customerId,
      customerName: command.customerName,
      warehouseId: command.warehouseId,
      settings,
    });

    // Deduct inventory
    await this.sideEffects.deductInventoryForPostedInvoice({
      invoice: { id: invoice.id, date: invoice.date },
      items: totals.processedItems,
    });

    return { linkedShipment };
  }

  async createSalesInvoice(
    command: SalesInvoiceCreateCommand,
    actor: { id: string; name?: string }
  ): Promise<CreateSalesInvoiceResult> {
    const settings = this.referenceData.getSettings();

    const totals = calculateSalesInvoiceTotals(command, (materialId: string) =>
      this.referenceData.getTaxRateForMaterial(materialId, 0)
    );

    const customerValidation = await this.validateCustomerAndDuplicates(command);
    if ('error' in customerValidation) {
      return customerValidation;
    }

    const persisted = await this.persistInvoice(command, actor, totals, settings as Record<string, unknown>);
    const { invoice, fxRate } = persisted;

    const sideEffectsResult = await this.executePostedSideEffects(
      command,
      invoice,
      totals,
      settings as Record<string, unknown>,
      actor
    );

    if (command.postingStatus === 'posted') {
      await this.sideEffects.syncMaterialCostFromInvoice({
        items: totals.processedItems,
        invoiceDate: invoice.date,
      });
    }

    const journalPayload =
      command.postingStatus === 'posted'
        ? {
            invoiceNumber: invoice.invoiceNumber,
            customerId: command.customerId,
            customerName: command.customerName,
            totalAmountBase: totals.discountedSubTotal * fxRate,
            taxAmountBase: totals.taxAmount * fxRate,
            discountAmountBase: totals.totalDiscount * fxRate,
            amountPaidBase: command.amountPaid * fxRate,
            createdBy: actor.name || 'admin',
            opts: {
              invoiceId: invoice.id,
              shipmentId: sideEffectsResult.linkedShipment?.id,
              shipmentType: sideEffectsResult.linkedShipment?.shipmentType,
              paymentMethods: command.paymentMethods,
              fxRate,
            },
          }
        : undefined;

    if (journalPayload) {
      await this.accounting.postSalesInvoiceJournalEntry(journalPayload);
    }

    const stockReport: any[] = [];
    if (command.postingStatus === 'posted') {
      const materialIds = Array.from(new Set(totals.processedItems.map((item: any) => item.materialId)));
      stockReport = this.referenceData.getStockLocationsByMaterial(materialIds);
    }

    return {
      success: true,
      invoiceNumber: invoice.invoiceNumber,
      stockReport,
      postingStatus: command.postingStatus,
      journalPayload,
      revalidateInventory: command.postingStatus === 'posted',
    };
  }
}
```

---

## Step 3: Infrastructure Layer (Adapters)

### File: `lib/modules/sales/infrastructure/default-sales-id-generator.port.ts`

```typescript
import { nextPrefixedId } from '@/lib/data';
import type { ISalesIdGeneratorPort } from '../application/sales-id-generator.port';

export class DefaultSalesIdGeneratorPort implements ISalesIdGeneratorPort {
  generateSalesInvoiceId(): string {
    return nextPrefixedId('si');
  }
}

export function createDefaultSalesIdGeneratorPort(): ISalesIdGeneratorPort {
  return new DefaultSalesIdGeneratorPort();
}
```

### File: `lib/modules/sales/infrastructure/default-sales-accounting.port.ts`

```typescript
import type { ISalesAccountingPort, SalesJournalPostingInput } from '../application/sales-accounting.port';
import { createLegacySalesInvoiceJournalEntry } from './legacy-sales-invoice-journal';

export class DefaultSalesAccountingPort implements ISalesAccountingPort {
  async postSalesInvoiceJournalEntry(input: SalesJournalPostingInput): Promise<void> {
    try {
      createLegacySalesInvoiceJournalEntry(input);
    } catch (error) {
      console.error('Failed to create sales invoice journal entry:', error);
    }
  }
}

export function createDefaultSalesAccountingPort(): ISalesAccountingPort {
  return new DefaultSalesAccountingPort();
}
```

### File: `lib/modules/sales/infrastructure/json-sales-invoice.repository.ts`

```typescript
import { addSalesInvoice, getCustomers, getSalesInvoices, writeData } from '@/lib/data';
import type { Customer } from '@/lib/types';
import type { CreateSalesInvoiceRecord, ISalesInvoiceRepository } from '../application/sales-invoice.repository';

export class JsonSalesInvoiceRepository implements ISalesInvoiceRepository {
  async findCustomerById(id: string): Promise<Customer | null> {
    const customers = getCustomers();
    return customers.find((entry) => entry.id === id) || null;
  }

  async listSalesInvoices() {
    return getSalesInvoices();
  }

  async getSalesInvoiceById(id: string) {
    const invoices = getSalesInvoices();
    return invoices.find((entry) => entry.id === id) || null;
  }

  async createSalesInvoice(invoice: CreateSalesInvoiceRecord) {
    // Pass pre-generated ID to legacy helper
    return addSalesInvoice(invoice, { id: invoice.id });
  }

  async addCustomerBalance(customerId: string, delta: number): Promise<void> {
    if (!Number.isFinite(delta) || delta === 0) return;

    const customers = getCustomers();
    const customer = customers.find((entry) => entry.id === customerId);
    if (!customer) return;

    customer.balance = (customer.balance || 0) + delta;
    writeData('customers.json', customers);
  }
}
```

### File: `lib/modules/sales/infrastructure/postgres-sales-invoice.repository.ts`

```typescript
import {
  pgGetCustomers,
  pgGetSalesInvoices,
  pgSaveSalesInvoices,
  pgUpdateCustomer,
} from '@/lib/postgres/data-access';
import type { Customer, SalesInvoice } from '@/lib/types';
import type { CreateSalesInvoiceRecord, ISalesInvoiceRepository } from '../application/sales-invoice.repository';

function generateSalesInvoiceNumber(existingInvoices: SalesInvoice[]): string {
  const currentYear = new Date().getFullYear();
  const currentYearInvoices = existingInvoices.filter((inv) => {
    if (!inv.invoiceNumber || typeof inv.invoiceNumber !== 'string') return false;
    const parts = inv.invoiceNumber.split('/');
    if (parts.length < 2) return false;
    const invoiceYear = Number(parts[1]);
    return Number.isFinite(invoiceYear) && invoiceYear === currentYear;
  });

  const nextSequence = currentYearInvoices.length + 1;
  return `S/${currentYear}/${String(nextSequence).padStart(4, '0')}`;
}

export class PostgresSalesInvoiceRepository implements ISalesInvoiceRepository {
  async findCustomerById(id: string): Promise<Customer | null> {
    const result = await pgGetCustomers({ page: 1, pageSize: 5000 });
    const customers = (result.items || []) as Customer[];
    return customers.find((entry) => entry.id === id) || null;
  }

  async listSalesInvoices() {
    const result = await pgGetSalesInvoices({ page: 1, pageSize: 5000 });
    return (result.items || []) as SalesInvoice[];
  }

  async getSalesInvoiceById(id: string) {
    const invoices = await this.listSalesInvoices();
    return invoices.find((entry) => entry.id === id) || null;
  }

  async createSalesInvoice(invoice: CreateSalesInvoiceRecord): Promise<SalesInvoice> {
    const existingInvoices = await this.listSalesInvoices();
    const now = new Date().toISOString();

    const newInvoice: SalesInvoice = {
      ...invoice,
      id: invoice.id, // Pre-generated from service
      invoiceNumber: generateSalesInvoiceNumber(existingInvoices),
      status: 'active',
      createdAt: now,
      lockedById: undefined,
      lockedByName: undefined,
      lockedAt: undefined,
      cancelledAt: undefined,
      cancelledBy: undefined,
      cancelledReason: undefined,
    };

    await pgSaveSalesInvoices([newInvoice]);
    return newInvoice;
  }

  async addCustomerBalance(customerId: string, delta: number): Promise<void> {
    if (!Number.isFinite(delta) || delta === 0) return;

    const customer = await this.findCustomerById(customerId);
    if (!customer) return;

    const nextBalance = Number(customer.balance || 0) + delta;
    await pgUpdateCustomer(customerId, { balance: nextBalance });
  }
}
```

### File: `lib/modules/sales/infrastructure/sales-invoice.repository.factory.ts`

```typescript
import { isPostgresProviderEnabled } from '@/lib/config';
import type { ISalesInvoiceRepository } from '../application/sales-invoice.repository';
import { JsonSalesInvoiceRepository } from './json-sales-invoice.repository';
import { PostgresSalesInvoiceRepository } from './postgres-sales-invoice.repository';

export function createSalesInvoiceRepository(): ISalesInvoiceRepository {
  if (isPostgresProviderEnabled()) {
    return new PostgresSalesInvoiceRepository();
  }
  return new JsonSalesInvoiceRepository();
}
```

---

## Step 4: Testing

### File: `tests/sales/sales-invoice-domain.test.ts`

```typescript
import test from 'node:test';
import assert from 'node:assert/strict';
import {
  calculateSalesInvoiceTotals,
  canTransitionPostingStatus,
} from '@/lib/modules/sales/domain/sales-invoice.domain';

test('calculateSalesInvoiceTotals computes line-level tax', () => {
  const command = {
    items: [{ materialId: 'mat-1', quantity: 2, unitPrice: 100, total: 200, lineDiscountAmount: 0 }],
    taxMethod: 'line-level',
    invoiceDiscountAmount: 0,
    invoiceTaxRate: 0,
  };

  const result = calculateSalesInvoiceTotals(command as any, () => 0.1);

  assert.equal(result.subTotal, 200);
  assert.equal(result.taxAmount, 20); // 200 * 10%
  assert.equal(result.grandTotal, 220);
});

test('canTransitionPostingStatus validates state machine', () => {
  assert.equal(canTransitionPostingStatus('draft', 'posted'), true);
  assert.equal(canTransitionPostingStatus('posted', 'draft'), false);
  assert.equal(canTransitionPostingStatus('posted', 'posted'), false);
});
```

### File: `tests/sales/sales-invoice-application-service.test.ts`

```typescript
import test from 'node:test';
import assert from 'node:assert/strict';
import { SalesInvoiceApplicationService } from '@/lib/modules/sales/application/sales-invoice-application-service.ts';
import type { ISalesInvoiceRepository } from '@/lib/modules/sales/application/sales-invoice.repository';
import type { ISalesSideEffectsPort } from '@/lib/modules/sales/application/sales-side-effects.port';
// ... imports for mocks

test('Posted invoice -> all side effects executed', async () => {
  // Create mocks for all ports
  const repo = createRepositoryMock();
  const side = createSideEffectsMock();
  const ref = createReferenceDataMock();
  const acct = createAccountingMock();
  const idGen = createIdGeneratorMock('si-posted-1');

  const service = new SalesInvoiceApplicationService(repo, side, ref, acct, idGen);

  const result = await service.createSalesInvoice(postedCommand, { id: 'u-1', name: 'Admin' });

  assert.equal('success' in result, true);
  assert.deepEqual(sideCalls, [
    'side.createSalesShipment',
    'side.deductInventoryForPostedInvoice',
    'side.syncMaterialCostFromInvoice',
  ]);
  assert.deepEqual(accountingCalls, ['accounting.postSalesInvoiceJournalEntry']);
});
```

---

## Step 5: Wire Action Layer

### File: `lib/actions.ts` (existing, add new handler)

```typescript
export async function handleCreateSalesInvoice(values: any) {
  const session = await getSession();
  if (!session?.user || session.user.role !== 'admin') return { error: 'Unauthorized' };

  const parsed = salesInvoiceSchema.safeParse(values);
  if (!parsed.success) {
    return { error: 'Invalid data submitted.', validationErrors: parsed.error.flatten().fieldErrors };
  }

  const command = mapSalesInvoiceDtoToCommand(parsed.data, getDefaultCurrency()?.code || 'USD');
  const salesService = SalesInvoiceApplicationService.createDefault();
  const result = await salesService.createSalesInvoice(command, {
    id: session.user.id,
    name: session.user.name || session.user.id,
  });

  if ('error' in result) {
    return result;
  }

  if (result.revalidateInventory) {
    revalidatePath('/admin/data/inventory');
    revalidatePath('/admin/sales/fulfillment');
  }

  revalidatePath('/admin/sales');
  return {
    success: true,
    invoiceNumber: result.invoiceNumber,
    stockReport: result.stockReport,
    postingStatus: result.postingStatus,
  };
}
```

---

## Step 6: Project Setup

### Update: `package.json`

```json
{
  "scripts": {
    "test:sales": "tsx --test tests/sales/**/*.test.ts",
    "test:sales:domain": "tsx --test tests/sales/sales-invoice-domain.test.ts",
    "test:sales:service": "tsx --test tests/sales/sales-invoice-application-service.test.ts"
  }
}
```

---

## Migration Checklist (Sales Module)

- [ ] Domain types, enums, commands defined
- [ ] Pure domain functions (totals, validators, labels)
- [ ] Repository interface with CRUD + customer lookups
- [ ] 5 Required ports defined (repository, accounting, side-effects, reference-data, id-generator)
- [ ] Application service with DI + static createDefault()
- [ ] JSON adapter wrapping legacy helpers
- [ ] Postgres adapter with pgGet/pgSave calls
- [ ] Repository factory (DB provider check)
- [ ] 5 Default port adapters (thin wrappers)
- [ ] Mapper for DTO → Command
- [ ] Domain unit tests
- [ ] Service integration tests (mocked ports)
- [ ] package.json test scripts added
- [ ] Action layer wired to service

---

## Key Differences: Sales vs Purchases

| Aspect | Purchases | Sales |
|--------|-----------|-------|
| Entity | SalesInvoice | SalesInvoice |
| Prefix | `po` | `si` |
| Number Format | `P/2026/0001` | `S/2026/0001` |
| Actor | Supplier | Customer |
| Posting Effect | Add supplier balance | Add customer balance |
| Inventory | Increase stock | Decrease stock |
| Shipment Type | Internal (for warehousing) | Customer (for delivery) |
| Tax Direction | Input tax (recoverable) | Output tax (payable) |

**Pattern remains identical; only domain logic and side effects differ.**

---

## Next Modules

Using this Sales template as reference:

- **Inventory Module:** Stock movements, warehousing, shelf distribution
- **Accounting Module:** Chart of accounts, journal entries, period closes
- **Payroll Module:** Employee records, salary calculations, tax withholding
