Skip to main content

Overview

The Sale data model represents service transactions and electronic invoices in MotorDesk. It integrates with Peru’s SUNAT (tax authority) system for electronic billing and tracks vehicle maintenance services.

TypeScript Interface

interface Sale {
  id: string;
  branchId: string;
  userId: string;
  customerId: string;
  vehicleId: string;
  choferId: string | null;
  tipoComprobante: string;
  serie: string;
  correlativo: string;
  fechaEmision: string;
  moneda: string;
  subtotal: number;
  igv: number;
  total: number;
  kilometrajeIngreso: number;
  proximoCambioKm: number;
  estadoSunat: string;
  documentoReferenciaId: string | null;
  motivoNotaReferencia: string | null;
  createdAt: string;
  syncedAt: string | null;
  isDeleted: boolean;
}

Field Reference

Identification Fields

id
string
required
Unique identifier for the sale transactionFormat: sale-XXX where XXX is a sequential numberExample: sale-001
branchId
string
required
Reference to the branch where the sale was madeType: Foreign key to Branch modelExample: branch-001
userId
string
required
Reference to the user who created the saleType: Foreign key to User modelExample: user-001

Customer & Vehicle Fields

customerId
string
required
Reference to the customer (invoice recipient)Type: Foreign key to Customer modelExample: cust-001Note: Typically the vehicle owner, but can be different
vehicleId
string
required
Reference to the vehicle being servicedType: Foreign key to Vehicle modelExample: veh-001
choferId
string | null
Reference to the driver who brought the vehicleType: Foreign key to Customer model (nullable)Example: cust-002 or nullNote: Can be null if no driver is specified

Invoice Fields (SUNAT)

tipoComprobante
string
required
Type of tax document (voucher type)Valid Values:
  • FACTURA - Invoice (for companies with RUC)
  • BOLETA - Receipt (for individuals with DNI)
  • NOTA_CREDITO - Credit note
  • NOTA_DEBITO - Debit note
Example: FACTURA
serie
string
required
Document series identifierFormat: Alphanumeric series code assigned by SUNATExamples: F001 (Factura), B002 (Boleta)Note: Series are configured per branch and document type
correlativo
string
required
Sequential document number within the seriesFormat: Zero-padded numeric string (typically 6 digits)Example: 000150, 000320Note: Auto-increments per series
fechaEmision
string
required
Date and time when the invoice was issuedFormat: ISO 8601 datetime stringExample: 2023-10-26T14:30:00Z

Financial Fields

moneda
string
required
Currency code for the transactionValid Values:
  • PEN - Peruvian Sol (default)
  • USD - US Dollar
Example: PEN
subtotal
number
required
Total amount before taxesType: Decimal number with 2 decimal placesExample: 122.88Calculation: Sum of all sale detail subtotals
igv
number
required
IGV (Value Added Tax) amountType: Decimal number with 2 decimal placesExample: 22.12Calculation: subtotal * 0.18 (18% IGV rate in Peru)
total
number
required
Total amount including taxesType: Decimal number with 2 decimal placesExample: 145.00Calculation: subtotal + igv

Vehicle Maintenance Fields

kilometrajeIngreso
number
required
Odometer reading when vehicle arrived for serviceType: IntegerExample: 45000Note: Used to track vehicle mileage history
proximoCambioKm
number
required
Recommended mileage for next serviceType: IntegerExample: 50000Calculation: kilometrajeIngreso + product.frecuenciaCambioKm

SUNAT Integration Fields

estadoSunat
string
required
Electronic invoice status with SUNATValid Values:
  • PENDIENTE - Pending submission to SUNAT
  • ACEPTADO - Accepted by SUNAT
  • RECHAZADO - Rejected by SUNAT
  • ERROR - Error during submission
Example: ACEPTADO
documentoReferenciaId
string | null
Reference to another document (for credit/debit notes)Type: Foreign key to Sale model (nullable)Example: sale-001 or nullNote: Required when tipoComprobante is NOTA_CREDITO or NOTA_DEBITO
motivoNotaReferencia
string | null
Reason for credit/debit noteType: Text description (nullable)Example: "Devolución parcial de productos" or nullNote: Required when tipoComprobante is NOTA_CREDITO or NOTA_DEBITO

System Fields

createdAt
string
required
Timestamp when the sale record was createdFormat: ISO 8601 datetime stringExample: 2023-10-26T14:30:00Z
syncedAt
string | null
Timestamp of last synchronization with SUNATFormat: ISO 8601 datetime string or nullExample: 2023-10-26T14:31:00Z or nullNote: Null indicates the document hasn’t been synced with SUNAT yet
isDeleted
boolean
required
Soft delete flagDefault: falseNote: Sales are soft-deleted to maintain audit trail

Relationships

Each sale has multiple line items (sale details) that specify products, quantities, and prices.
// Get all line items for a sale
const lineItems = saleDetails.filter(sd => sd.saleId === sale.id);

// Get line items with product information
const itemsWithProducts = lineItems.map(item => ({
  ...item,
  product: products.find(p => p.id === item.productId)
}));
The customer who receives the invoice (usually the vehicle owner).
const customer = customers.find(c => c.id === sale.customerId);
The vehicle being serviced in this transaction.
const vehicle = vehicles.find(v => v.id === sale.vehicleId);
The driver who brought the vehicle for service (optional).
const driver = sale.choferId
  ? customers.find(c => c.id === sale.choferId)
  : null;
The business location where the sale was made.
const branch = branches.find(b => b.id === sale.branchId);
The employee who processed the sale.
const user = users.find(u => u.id === sale.userId);
For credit/debit notes, reference to the original document.
const originalDocument = sale.documentoReferenciaId
  ? sales.find(s => s.id === sale.documentoReferenciaId)
  : null;

Example Data

Invoice (Factura) - Accepted by SUNAT

{
  "id": "sale-001",
  "branchId": "branch-001",
  "userId": "user-001",
  "customerId": "cust-001",
  "vehicleId": "veh-001",
  "choferId": "cust-002",
  "tipoComprobante": "FACTURA",
  "serie": "F001",
  "correlativo": "000150",
  "fechaEmision": "2023-10-26T14:30:00Z",
  "moneda": "PEN",
  "subtotal": 122.88,
  "igv": 22.12,
  "total": 145.00,
  "kilometrajeIngreso": 45000,
  "proximoCambioKm": 50000,
  "estadoSunat": "ACEPTADO",
  "documentoReferenciaId": null,
  "motivoNotaReferencia": null,
  "createdAt": "2023-10-26T14:30:00Z",
  "syncedAt": "2023-10-26T14:31:00Z",
  "isDeleted": false
}

Receipt (Boleta) - Pending SUNAT Submission

{
  "id": "sale-002",
  "branchId": "branch-002",
  "userId": "user-003",
  "customerId": "cust-003",
  "vehicleId": "veh-002",
  "choferId": "cust-003",
  "tipoComprobante": "BOLETA",
  "serie": "B002",
  "correlativo": "000320",
  "fechaEmision": "2023-10-27T10:15:00Z",
  "moneda": "PEN",
  "subtotal": 131.36,
  "igv": 23.64,
  "total": 155.00,
  "kilometrajeIngreso": 62500,
  "proximoCambioKm": 67500,
  "estadoSunat": "PENDIENTE",
  "documentoReferenciaId": null,
  "motivoNotaReferencia": null,
  "createdAt": "2023-10-27T10:15:00Z",
  "syncedAt": null,
  "isDeleted": false
}

Business Logic

IGV Calculation

Peru’s Value Added Tax (IGV) is 18% and must be calculated correctly:
const calculateTotals = (lineItems: SaleDetail[]) => {
  const subtotal = lineItems.reduce((sum, item) => sum + item.subtotal, 0);
  const igv = subtotal * 0.18; // 18% IGV
  const total = subtotal + igv;
  
  return {
    subtotal: parseFloat(subtotal.toFixed(2)),
    igv: parseFloat(igv.toFixed(2)),
    total: parseFloat(total.toFixed(2))
  };
};

Document Series Generation

const generateDocumentNumber = (tipoComprobante: string, branchId: string) => {
  // Get the document series for this branch and type
  const series = documentSeries.find(
    ds => ds.branchId === branchId && ds.tipoComprobante === tipoComprobante
  );
  
  if (!series) {
    throw new Error('No series configured for this document type');
  }
  
  // Increment correlativo
  const nextNumber = series.correlativoActual + 1;
  series.correlativoActual = nextNumber;
  
  return {
    serie: series.serie,
    correlativo: nextNumber.toString().padStart(6, '0')
  };
};

Next Service Calculation

const calculateNextServiceKm = (
  currentKm: number, 
  products: Product[]
): number => {
  // Find the shortest maintenance interval from purchased products
  const intervals = products
    .filter(p => p.frecuenciaCambioKm > 0)
    .map(p => p.frecuenciaCambioKm);
  
  if (intervals.length === 0) return 0;
  
  const shortestInterval = Math.min(...intervals);
  return currentKm + shortestInterval;
};

SUNAT Status Updates

Electronic Billing Flow
  1. Sale is created with estadoSunat: "PENDIENTE"
  2. Document is submitted to SUNAT via API
  3. Status updates to "ACEPTADO" or "RECHAZADO"
  4. If accepted, syncedAt timestamp is recorded
  5. System generates PDF and XML for the customer

Validation Rules

Document Type Rules
  • FACTURA: Customer must have RUC (11-digit tax ID)
  • BOLETA: Customer must have DNI (8-digit ID)
  • NOTA_CREDITO/DEBITO: Must reference an existing document via documentoReferenciaId
Financial Validation
  • Subtotal must equal sum of all line item subtotals
  • IGV must equal exactly 18% of subtotal
  • Total must equal subtotal + IGV
  • All amounts must have exactly 2 decimal places
Mileage Validation
  • kilometrajeIngreso must be greater than or equal to the vehicle’s last recorded mileage
  • proximoCambioKm must be greater than kilometrajeIngreso

Data Storage

Mock Data Location

/src/data/mock/sales.tsTypeScript export with sample sale data

JSON Data Location

/src/data/json/sales.jsonRaw JSON array of sale objects
  • Customers - Invoice recipients
  • Vehicles - Vehicles being serviced
  • Products - Items sold in the transaction
  • Sale Details - Line items for each sale (linked via saleId)

Usage Examples

Complete Sale with Details

const getCompleteSale = (saleId: string) => {
  const sale = sales.find(s => s.id === saleId);
  if (!sale || sale.isDeleted) return null;
  
  const customer = customers.find(c => c.id === sale.customerId);
  const vehicle = vehicles.find(v => v.id === sale.vehicleId);
  const driver = sale.choferId
    ? customers.find(c => c.id === sale.choferId)
    : null;
  const branch = branches.find(b => b.id === sale.branchId);
  const user = users.find(u => u.id === sale.userId);
  
  const lineItems = saleDetails
    .filter(sd => sd.saleId === saleId)
    .map(item => ({
      ...item,
      product: products.find(p => p.id === item.productId)
    }));
  
  return {
    ...sale,
    customer,
    vehicle,
    driver,
    branch,
    user,
    lineItems
  };
};

Vehicle Service History

const getVehicleServiceHistory = (vehicleId: string) => {
  return sales
    .filter(s => s.vehicleId === vehicleId && !s.isDeleted)
    .sort((a, b) => 
      new Date(b.fechaEmision).getTime() - new Date(a.fechaEmision).getTime()
    )
    .map(sale => ({
      ...sale,
      items: saleDetails
        .filter(sd => sd.saleId === sale.id)
        .map(item => ({
          ...item,
          product: products.find(p => p.id === item.productId)
        }))
    }));
};

Sales by Date Range

const getSalesByDateRange = (startDate: string, endDate: string) => {
  const start = new Date(startDate).getTime();
  const end = new Date(endDate).getTime();
  
  return sales
    .filter(s => {
      const saleDate = new Date(s.fechaEmision).getTime();
      return saleDate >= start && saleDate <= end && !s.isDeleted;
    })
    .sort((a, b) => 
      new Date(b.fechaEmision).getTime() - new Date(a.fechaEmision).getTime()
    );
};

Pending SUNAT Submissions

const getPendingSunatSubmissions = () => {
  return sales.filter(
    s => s.estadoSunat === 'PENDIENTE' && !s.isDeleted
  );
};

Build docs developers (and LLMs) love