@happyvertical/smrt-commerce

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

v0.29.34InvoicingContract STI (9)Optional Tenancy

Overview

smrt-commerce covers customers, vendors, contracts (9 STI types), invoices, 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 β€” if ledgers isn't installed, recognizeRevenue() and recordPayment() simply return null.

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 β€” 9 Types)

Contract is an STI base class. Nine concrete types share one table: Estimate, Order, Lease, Agreement, PurchaseOrder, WholesaleOrder, ProductionOrder, Cart, and LicenseSale. Create via ContractCollection with a _meta_type discriminator.

typescript
// STI types: Estimate, Order, Lease, Agreement, PurchaseOrder,
//            WholesaleOrder, ProductionOrder, Cart, LicenseSale
class Contract extends SmrtObject {
  // Create via ContractCollection with _meta_type
  customerId?: string    // @foreignKey('Customer') β€” within-package FK
  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
  // tenantScoped: optional
}

Invoice

Status machine: draft β†’ sent β†’ viewed β†’ partial β†’ paid (also overdue, cancelled, written_off). No tax-rate field β€” tax rates must be calculated externally. recognizeRevenue() creates a balanced AR journal; ledger integration is optional via dynamic import and returns null if smrt-ledgers isn't installed.

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 β€” dynamic import; 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
}

PaymentIntent (multi-rail quote)

A PaymentIntent is a short-lived (minutes, not days) pre-payment commitment. It locks a USD price for a fixed window and lists one or more PaymentOptions, each describing a different payment rail (USDC-on-Base, BTC, Stripe, …). The first option that receives a payment wins; the others are implicitly retired. It is deliberately distinct from Estimate: no line items, just coordination of the moment-of-payment. The optional x402Capable flag on an option marks rails usable in agent-driven HTTP-402 flows.

typescript
// status: awaiting_payment -> paid -> (issued | retired),
//         with expired / cancelled as alternate terminal exits.
class PaymentIntent extends SmrtObject {
  offeringRef: string        // abstract idempotency scope (e.g. a Sku id)
  licenseeEmail: string
  paymentOptions: PaymentOption[]   // JSON column; one rail each
  usdPriceLocked: number     // decimal; canonical USD price
  priceLockWindowMs: number  // default 15 min
  priceLockExpiresAt: Date | null
  status: PaymentIntentStatus
  paidOptionBackendId: string       // set by the verified paid path
  paymentId: string                 // plain string ref to the Payment

  // Verified transition: loads the Payment, requires it COMPLETED, and
  // reconciles rail (backendId) + currency + nativeAmount before marking paid.
  async verifyAndMarkPaid(args: { backendId: string; paymentId: string }): Promise<void>
  markIssued(): void   // PAID -> ISSUED once rights/fulfillment are created
  expire(): void       // AWAITING_PAYMENT -> EXPIRED
  cancel(reason?: string): void
  retire(reason?: string): void     // PAID/ISSUED -> RETIRED (refund/chargeback)
  isOptionRetired(backendId: string): boolean   // flag inbound funds to losing rails
}

interface PaymentOption {
  backendId: string    // 'base-usdc' | 'btc' | 'stripe' | ...
  currency: string     // rail-qualified, e.g. 'USDC-base'
  payTo: string        // address / account / Stripe intent id
  nativeAmount: number // decimal, in 'currency'
  chain?: string
  memo?: string
  x402Capable?: boolean
  expiresAt?: string
}

Payout (read-only by audit)

Payout is the outgoing side of a Payment: funds leaving the operator's wallet for a vendor's payout address. It carries a gross / fee / net triple and a pending β†’ sent β†’ confirmed machine (with failed, resettable to pending via resetFromFailed()).

typescript
// Build a PENDING payout from a settled Payment, then advance it explicitly.
const payouts = await PayoutCollection.create({ db });
const payout = await payouts.createFromPayment({
  payment,                 // the source Payment row
  vendorId: vendor.id,
  operatorFee: 5.0,        // in the payment's native currency
});
await payout.save();       // validates gross/fee/net + caps by source Payment

payout.markSent('0xabc…'); // PENDING -> SENT (backendTxRef required)
await payout.save();
payout.markConfirmed();    // SENT -> CONFIRMED (only reachable from SENT)
await payout.save();

LicenseSale (immutable once issued)

LicenseSale is the licensing STI subtype: one sale of rights for a fee, with a typed rights snapshot (medium, distribution scope, exclusivity, duration, territory, sublicensing, derivatives) plus signed-PDF artefacts (pdfUrl, pdfHash, optional onChainHashRegistryRef).

typescript
const contracts = new ContractCollection(db);
const license = await contracts.create({
  _meta_type: 'LicenseSale',
  skuId: 'sku-uuid',
  licenseeEmail: 'buyer@example.com',
  paymentId: payment.id,
  rightsMedium: 'web,social',
  rightsExclusivity: 'non-exclusive',
  rightsDuration: 'perpetual',
  rightsTerritory: 'worldwide',
  status: 'accepted',     // freezes the rights snapshot on save
});
await license.save();

license.getRightsSnapshot();  // typed LicenseRightsSnapshot
license.revoke();             // ACCEPTED -> CANCELLED, snapshot untouched
await license.save();

Cart β†’ Order at checkout

Cart and Order are both Contract STI subtypes sharing the contracts table, so a cart holds the same line items, totals, and customer reference as a real order. Checkout promotes the row in place β€” the application flips _meta_type from Cart to Order rather than copying data between tables. A Cart starts in draft via the base Contract.status default; the framework does not enforce a cart β†’ order state machine, so application code advances the status at checkout.

typescript
const contracts = new ContractCollection(db);

// Shopper's working cart (transient order-in-progress).
const cart = await contracts.create({
  _meta_type: 'Cart',
  customerId: customer.id,
  currency: 'USD',
});
await cart.save();

// …add line items / totals onto the same row…

// Promote in place at checkout: same row, new discriminator + status.
cart._meta_type = 'Order';
cart.status = 'pending';
await cart.save();

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({
  arAccountId: arAccount.id,
  revenueAccountId: revenueAccount.id,
  taxAccountId: taxAccount.id   // optional
});
// 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

Cross-Package References

Within-package relationships use @foreignKey(). Cross-package links are plain string IDs β€” this avoids circular dependencies (see the framework standards in docs/content/standards.md Β§7):

  • customerId β†’ @foreignKey('Customer') (within-package hard reference)
  • profileId β†’ plain string to smrt-profiles
  • arJournalId, revenueJournalId β†’ plain string to smrt-ledgers

To fetch the linked Profile object you must instantiate a ProfileCollection separately and look it up by ID.

Tenancy

All models in this package apply @TenantScoped({ mode: 'optional' }) from smrt-tenancy with a nullable tenantId column. Rows created inside a withTenant() context are filtered automatically; rows created outside a tenant context are visible to all tenants β€” useful for shared customer catalogs or single-tenant deployments.

Best Practices

DOs

  • Use generateInvoiceNumber() for race-free numbering
  • Always allocate payments to specific invoices via PaymentAllocation
  • Check getUnallocatedFromPayment() before allocating
  • Call recognizeRevenue() after sending invoices (no-op if ledgers absent)
  • Recalculate invoice totals after line-item changes
  • Use Invoice.updatePaymentStatus(totalAllocated) to drive status β€” never set status manually on Payment

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 instead
  • Don't skip updatePaymentStatus() after allocation
  • Don't modify line items without recalculating totals
  • Don't store currency as integer cents here β€” commerce uses decimal fields (affiliates uses cents β€” different convention)