Skip to content
ONIWC
iwc 0.1.0

Tutorial 11: Build an e-commerce application

Objective: create a complete store — catalogue, product pages, a cart, checkout with server-side pricing and stock, accounts, order history and an admin — and understand how its pieces connect.

Prerequisites: Tutorials 5–10. This tutorial reads a large, finished project rather than typing it: the IWC store example, which ships with the CLI as a template.

Step 1: Create the store

Terminal
iwc create my-store --template store
cd my-store
iwc validate
Output
✓ Created my-store from the store template — targets: web

  Sign in as casey@lumen.test / casey-pass-123 (or admin@lumen.test / admin-pass-123).

1/4 files and references
✓ 47 files · 13 screens · 9 apis · 5 schemas

2/4 tests
✓ 42 test(s) passed

3/4 production build
✓ my-store 0.1.0 builds (13 pages, 9 apis)

4/4 security
! app.iwc: actions send notifications but no mail integration is installed, so production can't deliver them

✓ valid in 1.062s

The security stage flags a real gap before you deploy: the store sends order emails, so install a mail provider (iwc add @iwc/mail) before going live.

Terminal
iwc dev --open

Step 2: The map

RouteScreenShows
/ui/home.ui.iwcHero, features, live featured products, reviews carousel, FAQ
/productsui/products.ui.iwcCategory chips, ?category= filter, search, pagination
/products/:slugui/product.ui.iwcProduct details, stock, add to cart, related products
/cartui/cart.ui.iwcClient-side cart with quantity controls
/checkoutui/checkout.ui.iwcSign-in required; shipping form; server-side checkout
/account/ordersui/orders.ui.iwcOrder history, owner-only
/adminui/admin.ui.iwcStock chart, orders with "Mark shipped", catalogue

Step 3: The product table

database/product.schema.iwc
{
  "name": "product",
  "fields": {
    "name": { "type": "string", "required": true, "maxLength": 120 },
    "slug": { "type": "string", "required": true, "unique": true, "pattern": "^[a-z0-9-]+$" },
    "description": { "type": "text", "maxLength": 4000 },
    "price": { "type": "number", "required": true, "min": 0 },
    "stock": { "type": "integer", "required": true, "min": 0, "default": 0 },
    "image": { "type": "string", "maxLength": 300 },
    "category": { "type": "relation", "to": "category.schema.iwc" },
    "featured": { "type": "boolean", "default": false },
    "active": { "type": "boolean", "default": true },
    "costPrice": { "type": "number", "sensitive": true, "description": "never sent to browsers" }
  },
  "indexes": [["active", "featured"]],
  "access": { "read": "public", "create": ["admin"], "update": ["admin"], "delete": ["admin"] },
  "seed": [
    { "id": "p-arc", "name": "Arc Floor Lamp", "slug": "arc-floor-lamp", "description": "A sweeping brass arc over a walnut base.\nDimmable, warm 2700K.", "price": 289, "stock": 12, "image": "/assets/products/arc.svg", "category": "cat-lighting", "featured": true, "costPrice": 120 },
    { "id": "p-moon", "name": "Moon Pendant", "slug": "moon-pendant", "description": "Hand-blown opal glass that glows like a winter moon.", "price": 149, "stock": 30, "image": "/assets/products/moon.svg", "category": "cat-lighting", "featured": true, "costPrice": 51 },
    { "id": "p-stool", "name": "Oak Milking Stool", "slug": "oak-milking-stool", "description": "Three legs, one solid piece of oak, a lifetime of sitting.", "price": 95, "stock": 3, "image": "/assets/products/stool.svg", "category": "cat-furniture", "featured": true, "costPrice": 30 },
    { "id": "p-shelf", "name": "Floating Shelf", "slug": "floating-shelf", "description": "Hidden-bracket ash shelf, 90cm.", "price": 120, "stock": 18, "image": "/assets/products/shelf.svg", "category": "cat-furniture", "featured": false, "costPrice": 44 },
    { "id": "p-bowl", "name": "Turned Walnut Bowl", "slug": "turned-walnut-bowl", "description": "Lathe-turned and oiled by hand.", "price": 68, "stock": 25, "image": "/assets/products/bowl.svg", "category": "cat-objects", "featured": true, "costPrice": 20 },
    { "id": "p-candle", "name": "Beeswax Taper Set", "slug": "beeswax-taper-set", "description": "Six hand-dipped tapers.", "price": 24, "stock": 80, "image": "/assets/products/candle.svg", "category": "cat-objects", "featured": false, "costPrice": 6 },
    { "id": "p-desk", "name": "Task Desk Lamp", "slug": "task-desk-lamp", "description": "Spring-balanced arm, matte black.", "price": 179, "stock": 9, "image": "/assets/products/desk.svg", "category": "cat-lighting", "featured": false, "costPrice": 70 },
    { "id": "p-chandelier", "name": "Grand Atelier Chandelier", "slug": "grand-atelier-chandelier", "description": "Twelve arms of forged brass. Made to order.", "price": 6400, "stock": 2, "image": "/assets/products/chandelier.svg", "category": "cat-lighting", "featured": false, "costPrice": 2900 },
    { "id": "p-retired", "name": "Retired Sconce", "slug": "retired-sconce", "description": "No longer made.", "price": 80, "stock": 4, "image": "/assets/products/moon.svg", "category": "cat-lighting", "active": false, "costPrice": 30 }
  ]
}

Step 4: The cart lives in the browser

state/cart.state.iwc
{
  "name": "cart",
  "persist": "local",
  "initial": { "items": [] },
  "mutations": {
    "add": { "op": "upsert", "path": "items", "key": "id", "count": "quantity" },
    "change": { "op": "upsert", "path": "items", "key": "id", "count": "quantity" },
    "remove": { "op": "remove", "path": "items", "key": "id" },
    "clear": { "op": "clear", "path": "items" }
  },
  "derived": {
    "count": { "sum": "items", "field": "quantity" },
    "lines": { "count": "items" },
    "total": { "sum": "items", "multiply": ["price", "quantity"] }
  }
}

upsert adds a product or raises its quantity; derived values (count, total) update everywhere they are bound, such as the header badge ("bind": "state.cart.count"). persist: "local" keeps the cart across visits. Add-to-cart buttons run "action": "state", "store": "cart", "mutation": "add".

Step 5: Checkout is server code

The checkout form sends the cart along with the address ("submit": {"include": ["STATE.cart.items"]}) to this API:

api/checkout.api.iwc
{
  "name": "checkout",
  "method": "POST",
  "path": "/checkout",
  "action": "checkout.action.iwc",
  "request": {
    "payload": {
      "items": {
        "type": "array", "required": true, "minItems": 1, "maxItems": 50,
        "items": {
          "type": "object",
          "fields": {
            "id": { "type": "string", "required": true },
            "quantity": { "type": "integer", "required": true, "min": 1, "max": 20 },
            "name": { "type": "string", "description": "display only; ignored for pricing" },
            "price": { "type": "number", "description": "display only; prices are read from the database" },
            "image": { "type": "string" },
            "slug": { "type": "string", "description": "display only" }
          }
        }
      },
      "shipName": { "type": "string", "required": true, "maxLength": 120 },
      "address": { "type": "string", "required": true, "maxLength": 200 },
      "city": { "type": "string", "required": true, "maxLength": 80 },
      "postalCode": { "type": "string", "required": true, "pattern": "^[A-Za-z0-9 -]{3,12}$" }
    }
  },
  "response": { "status": 200, "fields": { "orderId": "string", "total": "number", "status": "string" } },
  "security": { "auth": true, "rateLimit": "10/1m" }
}

Its action never trusts the browser's prices:

actions/checkout.action.iwc
{
  "name": "Checkout",
  "description": "Prices come from the database, never the request. Stock is re-checked inside the transaction and decremented atomically; a declined payment rolls everything back.",
  "auth": "required",
  "transaction": true,
  "input": {
    "items": {
      "type": "array", "required": true, "minItems": 1, "maxItems": 50,
      "items": { "type": "object", "fields": {
        "id": { "type": "string", "required": true },
        "quantity": { "type": "integer", "required": true, "min": 1, "max": 20 },
        "name": { "type": "string" }, "price": { "type": "number" }, "image": { "type": "string" }, "slug": { "type": "string" }
      } }
    },
    "shipName": { "type": "string", "required": true },
    "address": { "type": "string", "required": true },
    "city": { "type": "string", "required": true },
    "postalCode": { "type": "string", "required": true }
  },
  "steps": [
    { "operation": "validate", "id": "validate-cart", "condition": "len(input.items) > 0", "error": "Your cart is empty" },
    { "operation": "set", "as": "lines", "value": [] },
    {
      "operation": "forEach", "id": "check-inventory", "items": "input.items", "as": "line",
      "steps": [
        { "operation": "find", "resource": "product.schema.iwc", "where": { "id": "=line.id", "active": true }, "as": "product", "required": true, "status": 404, "error": "=concat('A product in your cart is no longer available')" },
        { "operation": "require", "condition": "product.stock >= line.quantity", "status": 409, "error": "=concat('Only ', product.stock, ' left of ', product.name)" },
        { "operation": "set", "as": "lines", "append": { "productId": "=product.id", "name": "=product.name", "unitPrice": "=product.price", "quantity": "=line.quantity" } }
      ]
    },
    { "operation": "set", "id": "calculate-total", "as": "subtotal", "value": "round(sum(lines, unitPrice * quantity), 2)" },
    { "operation": "set", "as": "shipping", "value": "if(subtotal >= 150, 0, 12)" },
    { "operation": "set", "as": "total", "value": "round(subtotal + shipping, 2)" },
    {
      "operation": "create", "id": "create-order", "resource": "order.schema.iwc", "as": "order",
      "values": {
        "items": "=lines", "subtotal": "=subtotal", "shipping": "=shipping", "total": "=total", "status": "pending",
        "shipName": "=input.shipName", "address": "=input.address", "city": "=input.city", "postalCode": "=input.postalCode"
      }
    },
    { "operation": "call", "id": "process-payment", "service": "payment.service.iwc", "method": "charge", "input": { "amount": "=total", "currency": "USD", "reference": "=order.id" }, "as": "payment", "status": 402, "error": "Your payment was declined; you have not been charged" },
    {
      "operation": "forEach", "id": "update-inventory", "items": "lines", "as": "l",
      "steps": [
        { "operation": "update", "resource": "product.schema.iwc", "where": { "id": "=l.productId", "stock": { "gte": "=l.quantity" } }, "increment": { "stock": "=-l.quantity" }, "expect": 1, "error": "Stock changed while you were checking out; please try again" }
      ]
    },
    { "operation": "transition", "workflow": "order.workflow.iwc", "transition": "pay", "value": "=order.id" },
    { "operation": "update", "resource": "order.schema.iwc", "where": { "id": "=order.id" }, "values": { "paymentRef": "=payment.id" } },
    { "operation": "emit", "event": "order-placed.event.iwc", "payload": { "orderId": "=order.id", "email": "=user.email", "total": "=total" } },
    { "operation": "return", "value": { "orderId": "=order.id", "total": "=total", "status": "paid" } }
  ]
}

Read it top to bottom:

  1. For each line, find the product (it must still be active) and require enough stock.
  2. Compute the subtotal, shipping and total from database prices.
  3. create the order, then call the payment service. A declined payment fails the step with 402, and because the action is "transaction": true, the order is rolled back.
  4. Decrement stock with a guarded update: where stock >= quantity plus expect: 1, so two customers can never buy the last item twice.
  5. Move the order through the workflow, store the payment reference, emit an event and return.

Step 6: Workflow, event and job

workflows/order.workflow.iwc
{
  "name": "OrderFlow",
  "resource": "order.schema.iwc",
  "field": "status",
  "initial": "pending",
  "states": ["pending", "paid", "shipped", "delivered", "cancelled"],
  "transitions": [
    { "name": "pay", "from": ["pending"], "to": "paid" },
    { "name": "ship", "from": ["paid"], "to": "shipped", "roles": ["admin"], "action": "notify-shipped.action.iwc" },
    { "name": "deliver", "from": ["shipped"], "to": "delivered", "roles": ["admin"] },
    { "name": "cancel", "from": ["pending", "paid"], "to": "cancelled", "roles": ["admin"] }
  ]
}
events/order-placed.event.iwc
{
  "name": "OrderPlaced",
  "payload": {
    "orderId": { "type": "string", "required": true },
    "email": { "type": "email", "required": true },
    "total": { "type": "number", "required": true }
  },
  "handlers": [{ "job": "order-email.job.iwc" }],
  "broadcast": true
}
jobs/order-email.job.iwc
{
  "name": "OrderEmail",
  "action": "send-order-email.action.iwc",
  "retries": 3,
  "timeout": "30s"
}

The ship transition is admin-only and exposed by an API (api/ship-order.api.iwc), which is what the admin's "Mark shipped" button calls. The OrderPlaced event queues the email job inside the checkout transaction, so a failed checkout never sends an email.

Step 7: Payments

The store uses a simulated payment service (orders over $5,000 are declined so you can see the failure path):

services/payment.service.iwc
{
  "name": "Payments",
  "provider": "simulated",
  "methods": {
    "charge": {
      "respond": { "id": "=concat('pay_', input.reference)", "status": "succeeded", "amount": "=input.amount", "currency": "=input.currency" },
      "fail": "input.amount > 5000"
    }
  }
}

Production refuses to start with it. Replace it with a real provider as shown in Tutorial 12.

Step 8: Try it

  1. Add products to the cart and open /cart; change quantities.
  2. Check out: you are sent to sign in and brought back. Use casey@lumen.test / casey-pass-123.
  3. Open /account/orders to see the order.
  4. Sign in as admin@lumen.test / admin-pass-123, open /admin, and mark the order shipped.

Make it yours

  • Edit app.iwc theme tokens and styles/ for your brand.
  • Replace the seed products in database/product.schema.iwc or add them through the admin form.
  • Install real providers: iwc add @iwc/payments, iwc add @iwc/mail, iwc add @iwc/shipping.

Troubleshooting

ProblemFix
"Only 2 left of …"The checkout's stock check; reduce the quantity.
"Your payment was declined"The simulated service declines totals over $5,000.
Changes to seed products don't appearSeeds go into empty tables only; delete .iwc/ to reset.

Next: Implement payments.

Source: apps/marketing/content/docs/tutorials/ecommerce.md