@happyvertical/smrt-commerce

E-commerce with Contract STI hierarchy (5 types), invoice lifecycle, payment tracking, fulfillment, and optional ledger integration.

v0.20.44Invoicing5 STI Types

Overview

smrt-commerce covers customers, vendors, contracts (5 STI types), invoices with ledger integration, payments, and fulfillment tracking. Customer and Vendor link to smrt-profiles via plain string IDs. Invoice and Payment optionally integrate with smrt-ledgers via dynamic import.

Key Features:

  • Customer/Vendor linked to Profile via string ID (creditLimit, paymentTerms, leadTimeDays)
  • Contract STI hierarchy: Estimate, Order, Lease, Agreement, PurchaseOrder
  • Invoice lifecycle: DRAFT, SENT, VIEWED, PARTIAL, PAID, OVERDUE, CANCELLED, WRITTEN_OFF
  • Revenue recognition via recognizeRevenue() (DR: AR, CR: Revenue, CR: Tax Payable)
  • Payment allocation to invoices with status tracking
  • Fulfillment/FulfillmentLineItem for shipment/delivery tracking
  • Optional ledger integration via dynamic import (returns null if not installed)

Installation

bash
npm install @happyvertical/smrt-commerce

Quick Start

typescript
import {
  Customer, CustomerCollection,
  Order, ContractCollection,
  Invoice, InvoiceCollection,
  Payment, PaymentCollection,
  ContractStatus, InvoiceStatus, PaymentMethod
} from '@happyvertical/smrt-commerce';

// Create a customer linked to a profile
const customers = new CustomerCollection(db);
const customer = await customers.create({
  profileId: 'profile-uuid',
  creditLimit: 10000.00,
  paymentTerms: 'Net 30',
});
await customer.save();

// Create an order (STI contract type)
const contracts = new ContractCollection(db);
const order = await contracts.create({
  _meta_type: 'Order',
  customerId: customer.id,
  subtotal: 1000.00,
  taxAmount: 50.00,
  totalAmount: 1050.00,
  currency: 'CAD',
});
await order.save();

// Create an invoice for the order
const invoices = new InvoiceCollection(db);
const invoiceNumber = await invoices.generateInvoiceNumber();
const invoice = await invoices.create({
  customerId: customer.id,
  contractId: order.id,
  invoiceNumber,
  subtotal: 1000.00,
  taxAmount: 50.00,
  totalAmount: 1050.00,
});
await invoice.save();

// Recognize revenue (creates balanced journal in smrt-ledgers)
await invoice.recognizeRevenue({
  arAccountId: 'ar-account-id',
  revenueAccountId: 'revenue-account-id',
  taxAccountId: 'tax-account-id',
});

// Record a payment
const payments = new PaymentCollection(db);
const payment = await payments.create({
  contractId: order.id,
  customerId: customer.id,
  amount: 1050.00,
  method: PaymentMethod.CREDIT_CARD,
});
await payment.save();

Core Models

Contract (STI Base -- 5 Types)

Contract is an STI base class. Five concrete types share one table: Estimate, Order, Lease, Agreement, and PurchaseOrder.

typescript
// STI types: Estimate, Order, Lease, Agreement, PurchaseOrder
class Contract extends SmrtObject {
  // Create via ContractCollection with _meta_type
  customerId?: string    // FK within package
  vendorId?: string
  subtotal: number       // decimal (not integer cents)
  taxAmount: number
  totalAmount: number
  currency: string
  status: 'draft' | 'sent' | 'accepted' | 'declined' | 'completed' | 'cancelled'
  issueDate: Date
  dueDate?: Date
  reference?: string
  terms?: string
}

Invoice

Status machine: DRAFT -> SENT -> VIEWED -> PARTIAL -> PAID (also OVERDUE, CANCELLED, WRITTEN_OFF). recognizeRevenue() creates balanced AR journal entry. Ledger integration is optional via dynamic import.

typescript
class Invoice extends SmrtObject {
  invoiceNumber: string
  customerId: string
  contractId?: string
  subtotal: number          // decimal (not integer cents)
  taxAmount: number
  totalAmount: number
  amountPaid: number
  status: 'draft' | 'sent' | 'viewed' | 'partial' | 'paid' | 'overdue' | 'cancelled' | 'written_off'
  arJournalId?: string      // Plain string ref to smrt-ledgers
  revenueJournalId?: string // Plain string ref to smrt-ledgers

  // Status management (Invoice controls payment status, not Payment model)
  markSent(): void
  markViewed(): void
  updatePaymentStatus(amountPaid: number): void

  // Accounting integration (optional -- returns null if smrt-ledgers not installed)
  async recognizeRevenue(options: RecognizeRevenueOptions): Promise<Journal | null>
  // Creates: DR Accounts Receivable, CR Revenue, CR Tax Payable
}

Payment / PaymentAllocation

Payment tracks payments against invoices. PaymentAllocation handles payment-to-invoice allocation. Note: Invoice controls payment status, not the Payment model -- use Invoice.updatePaymentStatus().

typescript
class Payment extends SmrtObject {
  contractId: string
  customerId: string
  amount: number           // decimal
  currency: string
  method: 'cash' | 'check' | 'credit_card' | 'bank_transfer' | 'crypto' | 'other'
  status: 'pending' | 'completed' | 'failed' | 'refunded' | 'cancelled'
  journalId?: string       // Plain string ref to smrt-ledgers

  // Ledger integration (optional)
  async recordPayment(options: RecordPaymentOptions): Promise<Journal | null>
  // Creates: DR Cash, CR Accounts Receivable
}

Invoice Management

Invoice Lifecycle

typescript
// 1. Create draft invoice
const invoice = await invoices.create({
  customerId: customer.id,
  invoiceNumber: await invoices.generateInvoiceNumber({
    prefix: 'INV',           // Custom prefix
    format: 'prefix-year-seq' // INV-2025-0001
  }),
  issueDate: new Date(),
  dueDate: new Date(Date.now() + 30 * 86400000),
  status: 'DRAFT'
});
await invoice.save();

// 2. Add line items
const item = await lineItems.create({
  invoiceId: invoice.id,
  description: 'Web Development',
  quantity: 40,
  unitPrice: 125,
  taxRate: 0.05,
  sourceType: 'contract',
  sourceId: order.id
});
item.amount = item.calculateAmount();
await item.save();

// 3. Update invoice totals
invoice.subtotal = await lineItems.getSubtotalForInvoice(invoice.id);
invoice.taxAmount = await lineItems.getTaxForInvoice(invoice.id);
invoice.totalAmount = await lineItems.getTotalForInvoice(invoice.id);
await invoice.save();

// 4. Send to customer
invoice.markSent();
await invoice.save();

// 5. Track customer view
invoice.markViewed();
await invoice.save();

// 6. Recognize revenue (accounting)
const journal = await invoice.recognizeRevenue({
  ledgerId: ledger.id,
  receivablesAccountId: arAccount.id,
  revenueAccountId: revenueAccount.id,
  taxPayableAccountId: taxAccount.id
});
// Creates: Debit AR, Credit Revenue, Credit Tax Payable

Payment Allocation

typescript
// Record payment
const payment = await payments.create({
  contractId: order.id,
  customerId: customer.id,
  amount: 5250,
  method: 'BANK_TRANSFER',
  transactionId: 'bank_123'
});
await payment.save();

// Record with ledger
const journal = await payment.recordPayment({
  ledgerId: ledger.id,
  receivablesAccountId: arAccount.id,
  cashAccountId: bankAccount.id
});
// Creates: Debit Cash, Credit AR

// Check available amount
const available = await allocations.getUnallocatedFromPayment(
  payment.id, payment.amount
);

// Allocate to invoices
const allocation1 = await allocations.create({
  paymentId: payment.id,
  invoiceId: invoice1.id,
  amount: 1050,
  allocatedBy: 'user-uuid'
});
await allocation1.save();

const allocation2 = await allocations.create({
  paymentId: payment.id,
  invoiceId: invoice2.id,
  amount: 4200,
  allocatedBy: 'user-uuid'
});
await allocation2.save();

// Update invoice statuses
const total1 = await allocations.getTotalAllocatedToInvoice(invoice1.id);
invoice1.updatePaymentStatus(total1);
await invoice1.save(); // Status becomes PAID

const total2 = await allocations.getTotalAllocatedToInvoice(invoice2.id);
invoice2.updatePaymentStatus(total2);
await invoice2.save(); // Status becomes PARTIAL or PAID

Best Practices

DOs

  • Use generateInvoiceNumber() for consistent numbering
  • Always allocate payments to specific invoices
  • Check getUnallocatedFromPayment() before allocating
  • Call recognizeRevenue() after sending invoices
  • Recalculate invoice totals after line item changes

DON'Ts

  • Don't manually set invoice numbers (race conditions)
  • Don't over-allocate payments (check available first)
  • Don't cancel paid invoices (use writeOff if needed)
  • Don't skip updatePaymentStatus() after allocation
  • Don't modify line items without recalculating totals