@happyvertical/smrt-commerce
E-commerce with Contract STI hierarchy (9 types), invoice lifecycle, payment tracking, fulfillment, and optional ledger integration.
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
npm install @happyvertical/smrt-commerceQuick Start
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.
// 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.
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().
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.
// 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()).
// 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).
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.
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
// 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 PayablePayment Allocation
// 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 PAIDCross-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-profilesarJournalId,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
writeOffinstead - 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)