E-Commerce

Stacks.js Commerce provides a complete toolkit for building online stores, handling payments, managing inventory, and processing orders.

Configuration

Commerce Configuration

// config/commerce.ts
export default {
  // Currency settings
  currency: {
    default: 'USD',
    supported: ['USD', 'EUR', 'GBP', 'CAD'],
    locale: 'en-US',
  },

  // Tax configuration
  tax: {
    enabled: true,
    inclusive: false, // Tax included in price?
    defaultRate: 0.0, // Will be calculated per region
    provider: 'taxjar', // 'taxjar' | 'avalara' | 'manual'
  },

  // Inventory settings
  inventory: {
    trackStock: true,
    allowBackorders: false,
    lowStockThreshold: 10,
    reservationTimeout: 15, // minutes
  },

  // Shipping
  shipping: {
    providers: ['ups', 'fedex', 'usps'],
    freeShippingThreshold: 100,
    defaultMethod: 'standard',
  },

  // Payment providers
  payments: {
    default: 'stripe',
    providers: {
      stripe: {
        secretKey: process.env.STRIPE_SECRET_KEY,
        publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
        webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
      },
      paypal: {
        clientId: process.env.PAYPAL_CLIENT_ID,
        clientSecret: process.env.PAYPAL_CLIENT_SECRET,
        mode: 'sandbox', // 'sandbox' | 'live'
      },
    },
  },

  // Checkout settings
  checkout: {
    guestCheckout: true,
    requirePhone: false,
    requireShipping: true,
    termsRequired: true,
  },

  // Order settings
  orders: {
    numberPrefix: 'ORD-',
    numberLength: 8,
    statuses: ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'],
  },
}

Products

Product Model

// app/Models/Product.ts
import { Model } from '@stacksjs/orm'
import { Commerce } from '@stacksjs/commerce'

export default class Product extends Model {
  use = [Commerce.Purchasable]

  static fields = {
    name: { type: 'string', required: true },
    slug: { type: 'string', unique: true },
    description: { type: 'text' },
    price: { type: 'decimal', precision: 10, scale: 2 },
    compare_at_price: { type: 'decimal', precision: 10, scale: 2, nullable: true },
    cost: { type: 'decimal', precision: 10, scale: 2, nullable: true },
    sku: { type: 'string', unique: true },
    barcode: { type: 'string', nullable: true },
    weight: { type: 'decimal', nullable: true },
    weight_unit: { type: 'enum', values: ['g', 'kg', 'oz', 'lb'], default: 'g' },
    status: { type: 'enum', values: ['draft', 'active', 'archived'], default: 'draft' },
    featured: { type: 'boolean', default: false },
    taxable: { type: 'boolean', default: true },
    requires_shipping: { type: 'boolean', default: true },
  }

  // Relationships
  static relationships = {
    categories: { type: 'belongsToMany', model: 'Category' },
    variants: { type: 'hasMany', model: 'ProductVariant' },
    images: { type: 'hasMany', model: 'ProductImage' },
    reviews: { type: 'hasMany', model: 'Review' },
  }

  // Computed properties
  get isOnSale() {
    return this.compare_at_price && this.compare_at_price > this.price
  }

  get discount() {
    if (!this.isOnSale) return 0
    return Math.round((1 - this.price / this.compare_at_price) * 100)
  }

  get formattedPrice() {
    return Commerce.formatPrice(this.price)
  }
}

Product Variants

// app/Models/ProductVariant.ts
export default class ProductVariant extends Model {
  static fields = {
    product_id: { type: 'foreignId', references: 'products' },
    name: { type: 'string' },
    sku: { type: 'string', unique: true },
    price: { type: 'decimal', precision: 10, scale: 2 },
    stock_quantity: { type: 'integer', default: 0 },
    options: { type: 'json' }, // { color: 'Red', size: 'Large' }
  }

  get inStock() {
    return this.stock_quantity > 0
  }
}

Managing Products

import { Product } from '@/Models/Product'

// Create product
const product = await Product.create({
  name: 'Premium T-Shirt',
  slug: 'premium-t-shirt',
  description: 'Comfortable cotton t-shirt',
  price: 29.99,
  compare_at_price: 39.99,
  sku: 'TSHIRT-001',
  status: 'active',
})

// Add variants
await product.variants().createMany([
  { name: 'Small / Red', sku: 'TSHIRT-001-S-R', price: 29.99, stock_quantity: 50, options: { size: 'S', color: 'Red' } },
  { name: 'Medium / Red', sku: 'TSHIRT-001-M-R', price: 29.99, stock_quantity: 75, options: { size: 'M', color: 'Red' } },
  { name: 'Large / Red', sku: 'TSHIRT-001-L-R', price: 29.99, stock_quantity: 60, options: { size: 'L', color: 'Red' } },
])

// Add images
await product.images().create({
  url: '/images/products/tshirt-001.jpg',
  alt: 'Premium T-Shirt - Red',
  position: 1,
})

// Add to categories
await product.categories().attach([categoryId1, categoryId2])

Shopping Cart

Cart Operations

import { Cart } from '@stacksjs/commerce'

// Get or create cart
const cart = await Cart.current(request) // Uses session/cookie

// Add item to cart
await cart.add({
  productId: product.id,
  variantId: variant.id,
  quantity: 2,
})

// Update quantity
await cart.updateQuantity(lineItemId, 3)

// Remove item
await cart.remove(lineItemId)

// Clear cart
await cart.clear()

// Get cart contents
const items = await cart.items()
const subtotal = await cart.subtotal()
const total = await cart.total()
const itemCount = await cart.count()

// Apply discount code
await cart.applyDiscount('SAVE20')

// Remove discount
await cart.removeDiscount()

Cart API Endpoints

// routes/api.ts
router.group({ prefix: '/cart' }, () => {
  router.get('/', CartController.show)
  router.post('/items', CartController.addItem)
  router.put('/items/:id', CartController.updateItem)
  router.delete('/items/:id', CartController.removeItem)
  router.post('/discount', CartController.applyDiscount)
  router.delete('/discount', CartController.removeDiscount)
})

// app/Controllers/CartController.ts
export default {
  async show(request: Request) {
    const cart = await Cart.current(request)

    return {
      items: await cart.items(),
      subtotal: await cart.subtotal(),
      discount: await cart.discount(),
      tax: await cart.tax(),
      shipping: await cart.shipping(),
      total: await cart.total(),
    }
  },

  async addItem(request: Request) {
    const { productId, variantId, quantity } = await request.validate({
      productId: 'required|exists:products,id',
      variantId: 'exists:product_variants,id',
      quantity: 'required|integer|min:1',
    })

    const cart = await Cart.current(request)
    await cart.add({ productId, variantId, quantity })

    return { success: true, cart: await cart.toJSON() }
  },
}

Checkout

Checkout Flow

import { Checkout } from '@stacksjs/commerce'

// Initialize checkout from cart
const checkout = await Checkout.fromCart(cart)

// Set customer information
await checkout.setCustomer({
  email: 'customer@example.com',
  firstName: 'John',
  lastName: 'Doe',
  phone: '+1234567890',
})

// Set shipping address
await checkout.setShippingAddress({
  line1: '123 Main St',
  line2: 'Apt 4',
  city: 'New York',
  state: 'NY',
  postalCode: '10001',
  country: 'US',
})

// Get available shipping methods
const shippingMethods = await checkout.getShippingMethods()

// Select shipping method
await checkout.setShippingMethod(shippingMethods[0].id)

// Set billing address (or use shipping)
await checkout.setBillingAddress(shippingAddress)
// Or: await checkout.useSameAddress()

// Calculate totals
const summary = await checkout.summary()
// { subtotal, discount, shipping, tax, total }

// Create payment intent
const paymentIntent = await checkout.createPaymentIntent()

// Complete checkout (after payment confirmation)
const order = await checkout.complete(paymentIntentId)

Checkout Controller

// app/Controllers/CheckoutController.ts
export default {
  async show(request: Request) {
    const cart = await Cart.current(request)

    if (await cart.isEmpty()) {
      return redirect('/cart')
    }

    const checkout = await Checkout.fromCart(cart)

    return view('checkout', { checkout })
  },

  async process(request: Request) {
    const data = await request.validate({
      email: 'required|email',
      shipping: 'required|object',
      billing: 'object',
      shippingMethod: 'required|string',
    })

    const cart = await Cart.current(request)
    const checkout = await Checkout.fromCart(cart)

    await checkout.setCustomer({ email: data.email })
    await checkout.setShippingAddress(data.shipping)
    await checkout.setShippingMethod(data.shippingMethod)

    if (data.billing) {
      await checkout.setBillingAddress(data.billing)
    } else {
      await checkout.useSameAddress()
    }

    // Create payment intent
    const { clientSecret } = await checkout.createPaymentIntent()

    return { clientSecret, checkoutId: checkout.id }
  },

  async complete(request: Request) {
    const { checkoutId, paymentIntentId } = request.body

    const checkout = await Checkout.find(checkoutId)
    const order = await checkout.complete(paymentIntentId)

    // Clear cart
    const cart = await Cart.current(request)
    await cart.clear()

    // Send confirmation email
    await order.sendConfirmation()

    return { order: order.toJSON() }
  },
}

Payments

Stripe Integration

import { Payment } from '@stacksjs/commerce'

// Create payment intent
const intent = await Payment.stripe().createIntent({
  amount: 2999, // in cents
  currency: 'usd',
  metadata: { orderId: order.id },
})

// Confirm payment
const result = await Payment.stripe().confirmIntent(intentId)

// Capture payment (for manual capture)
await Payment.stripe().capture(intentId)

// Refund payment
await Payment.stripe().refund(paymentIntentId, {
  amount: 1000, // Partial refund
  reason: 'customer_request',
})

Stripe Webhooks

// routes/api.ts
router.post('/webhooks/stripe', StripeWebhookController.handle)

// app/Controllers/StripeWebhookController.ts
import { Payment } from '@stacksjs/commerce'

export default {
  async handle(request: Request) {
    const event = await Payment.stripe().constructWebhookEvent(
      request.body,
      request.headers.get('stripe-signature'),
    )

    switch (event.type) {
      case 'payment_intent.succeeded':
        await handlePaymentSuccess(event.data.object)
        break

      case 'payment_intent.payment_failed':
        await handlePaymentFailure(event.data.object)
        break

      case 'charge.refunded':
        await handleRefund(event.data.object)
        break

      case 'customer.subscription.updated':
        await handleSubscriptionUpdate(event.data.object)
        break
    }

    return { received: true }
  },
}

async function handlePaymentSuccess(paymentIntent) {
  const order = await Order.where('payment_intent_id', paymentIntent.id).first()

  if (order) {
    await order.update({ status: 'processing', paid_at: new Date() })
    await order.sendConfirmation()
    await order.reduceInventory()
  }
}

PayPal Integration

import { Payment } from '@stacksjs/commerce'

// Create PayPal order
const paypalOrder = await Payment.paypal().createOrder({
  intent: 'CAPTURE',
  purchase_units: [{
    amount: {
      currency_code: 'USD',
      value: '29.99',
    },
  }],
})

// Capture payment after approval
const capture = await Payment.paypal().captureOrder(paypalOrderId)

Orders

Order Model

// app/Models/Order.ts
export default class Order extends Model {
  static fields = {
    number: { type: 'string', unique: true },
    customer_id: { type: 'foreignId', references: 'customers', nullable: true },
    email: { type: 'string' },
    status: { type: 'string', default: 'pending' },
    subtotal: { type: 'decimal', precision: 10, scale: 2 },
    discount_total: { type: 'decimal', precision: 10, scale: 2, default: 0 },
    shipping_total: { type: 'decimal', precision: 10, scale: 2, default: 0 },
    tax_total: { type: 'decimal', precision: 10, scale: 2, default: 0 },
    total: { type: 'decimal', precision: 10, scale: 2 },
    currency: { type: 'string', default: 'USD' },
    shipping_address: { type: 'json' },
    billing_address: { type: 'json' },
    payment_method: { type: 'string' },
    payment_intent_id: { type: 'string', nullable: true },
    paid_at: { type: 'datetime', nullable: true },
    shipped_at: { type: 'datetime', nullable: true },
    delivered_at: { type: 'datetime', nullable: true },
    notes: { type: 'text', nullable: true },
  }

  static relationships = {
    customer: { type: 'belongsTo', model: 'Customer' },
    items: { type: 'hasMany', model: 'OrderItem' },
    transactions: { type: 'hasMany', model: 'Transaction' },
    shipments: { type: 'hasMany', model: 'Shipment' },
  }

  // Generate order number
  static boot() {
    this.creating(async (order) => {
      order.number = await Order.generateNumber()
    })
  }

  static async generateNumber() {
    const prefix = config('commerce.orders.numberPrefix')
    const lastOrder = await Order.orderBy('id', 'desc').first()
    const nextNumber = (lastOrder?.id || 0) + 1
    return `${prefix}${String(nextNumber).padStart(8, '0')}`
  }

  async sendConfirmation() {
    await Mail.to(this.email).send(new OrderConfirmation(this))
  }

  async reduceInventory() {
    for (const item of await this.items) {
      if (item.variant_id) {
        await ProductVariant.where('id', item.variant_id)
          .decrement('stock_quantity', item.quantity)
      }
    }
  }
}

Order Management

// Fetch orders
const orders = await Order.with('items', 'customer')
  .orderBy('created_at', 'desc')
  .paginate(20)

// Update order status
await order.update({ status: 'processing' })

// Create shipment
const shipment = await order.shipments().create({
  carrier: 'ups',
  tracking_number: '1Z999AA10123456784',
  shipped_at: new Date(),
})

// Mark as shipped
await order.update({
  status: 'shipped',
  shipped_at: new Date(),
})

// Send shipping notification
await Mail.to(order.email).send(new ShipmentNotification(order, shipment))

// Process refund
const refund = await order.refund({
  amount: order.total,
  reason: 'Customer request',
})

Inventory

Stock Management

import { Inventory } from '@stacksjs/commerce'

// Check stock
const available = await Inventory.available(variantId)
const reserved = await Inventory.reserved(variantId)

// Reserve stock (during checkout)
const reservation = await Inventory.reserve(variantId, quantity, {
  expiresIn: 15 * 60, // 15 minutes
  reference: checkoutId,
})

// Commit reservation (after payment)
await Inventory.commit(reservation.id)

// Release reservation (if checkout abandoned)
await Inventory.release(reservation.id)

// Adjust stock
await Inventory.adjust(variantId, -5, 'Damaged goods')
await Inventory.adjust(variantId, 100, 'New shipment received')

// Get stock history
const history = await Inventory.history(variantId)

Low Stock Alerts

// app/Jobs/CheckLowStock.ts
export default {
  schedule: '0 9 * * *', // Daily at 9 AM

  async handle() {
    const lowStock = await ProductVariant
      .where('stock_quantity', '<=', config('commerce.inventory.lowStockThreshold'))
      .with('product')
      .get()

    if (lowStock.length > 0) {
      await Mail.to('inventory@example.com')
        .send(new LowStockAlert(lowStock))
    }
  },
}

Discounts & Coupons

Creating Discounts

import { Discount } from '@stacksjs/commerce'

// Percentage discount
const discount = await Discount.create({
  code: 'SAVE20',
  type: 'percentage',
  value: 20,
  minOrderAmount: 50,
  maxUses: 1000,
  startsAt: new Date(),
  expiresAt: new Date('2024-12-31'),
})

// Fixed amount discount
await Discount.create({
  code: 'FLAT10',
  type: 'fixed',
  value: 10,
  currency: 'USD',
})

// Free shipping
await Discount.create({
  code: 'FREESHIP',
  type: 'free_shipping',
})

// Product-specific discount
await Discount.create({
  code: 'TSHIRT15',
  type: 'percentage',
  value: 15,
  applicableProducts: [productId1, productId2],
})

Applying Discounts

// In cart
await cart.applyDiscount('SAVE20')

// Validate discount
const validation = await Discount.validate('SAVE20', cart)
if (!validation.valid) {
  console.log(validation.error) // 'Minimum order amount not met'
}

// Calculate discount amount
const discountAmount = await Discount.calculate('SAVE20', cart)

Subscriptions

Subscription Products

import { Subscription } from '@stacksjs/commerce'

// Create subscription plan
const plan = await Subscription.createPlan({
  name: 'Monthly Box',
  slug: 'monthly-box',
  price: 29.99,
  interval: 'month',
  intervalCount: 1,
  trialDays: 14,
})

// Subscribe customer
const subscription = await Subscription.create({
  customerId: customer.id,
  planId: plan.id,
  paymentMethodId: paymentMethod.id,
})

// Manage subscription
await subscription.pause()
await subscription.resume()
await subscription.cancel()
await subscription.cancelAtPeriodEnd()

// Change plan
await subscription.changePlan(newPlanId)

// Update payment method
await subscription.updatePaymentMethod(newPaymentMethodId)

Taxes

Tax Calculation

import { Tax } from '@stacksjs/commerce'

// Calculate tax for order
const taxAmount = await Tax.calculate({
  subtotal: 100,
  shippingAddress: {
    state: 'CA',
    postalCode: '90210',
    country: 'US',
  },
  items: cart.items,
})

// Get tax rate for location
const rate = await Tax.rateFor({
  state: 'CA',
  city: 'Los Angeles',
  country: 'US',
})

Reporting

Sales Reports

import { Commerce } from '@stacksjs/commerce'

// Sales summary
const summary = await Commerce.reports.sales({
  from: startDate,
  to: endDate,
})
// { totalSales, orderCount, averageOrderValue, ... }

// Best selling products
const topProducts = await Commerce.reports.topProducts({
  limit: 10,
  period: 'month',
})

// Revenue by category
const byCategory = await Commerce.reports.revenueByCategory({
  period: 'year',
})

// Customer acquisition
const customers = await Commerce.reports.customerAcquisition({
  period: 'month',
})