Skip to content

Order-to-Cash Tutorial

Status: Draft Audience: App Developers — first-time FormSpec users Prerequisites: Read FormSpec Overview and Core Basic Spec

This tutorial walks you through building a mini Order-to-Cash application with FormSpec, step by step. By the end, you will have: a working order management system with sequential order numbers, payment gateway integration, a transactional journal entry on payment, and email/WA notifications — all from declarative manifests with minimal code.


1. Requirements (FR1–FR10)

Our Order-to-Cash flow: customer checkout → pay via payment gateway → after successful payment: PDF receipt generated, receipt emailed, WA notification sent, accounting journal automatically created, admin dashboard shows live payments.

#RequirementWhy it's hard without conventions
FR1Sequential order numbers (ORD-2026-000123), no duplicates under concurrencyClassic race condition
FR2Payment gateway (Midtrans/Xendit) with simulation mode in devNo standard pattern for external integrations
FR3Webhook may be sent multiple times — must not process twicePersistent idempotency, not cache
FR4After payment, journal entry must exist — no loss, no duplicatesTransactional outbox, not fire-and-forget
FR5Email + WA may be delayed, must not silently failReliable background jobs with retry
FR6Dashboard live payment tickerLoss acceptable if admin is offline
FR7Membership tier discount cached, invalidated on rule changeVolatile cache + manual invalidation
FR8Number prefix & notification template per workspace, no redeployDynamic config
FR9All steps logged with structure, one correlation IDConsistent observability
FR10PDF receipt stored, re-downloadableObject storage, not container filesystem

2. Project Structure

Create the project:

bash
formspec new tokoku

This generates a standard FormSpec project structure (Core §5):

tokoku/
  formspec.yaml                    # kind: App (root manifest)
  modules/
    billing/                    # kind: Module
      module.yaml
      entities/
      services/
      scripts/
    gl/                         # kind: Module (double-entry accounting)
      module.yaml
      entities/
    notifications/              # kind: Module
      module.yaml
      subscriptions/
  impl/                         # Go source — build-time only
  config/                       # kind: Config

3. Step 1 — App Manifest

Create formspec.yaml:

yaml
apiVersion: formspec.dev/v1
kind: App
metadata:
  name: tokoku
  description: Mini order-to-cash
spec:
  version: 1.0.0
  vendor: acme-corp
  modules: [billing, gl, notifications]

This declares our app composes three modules. No publishes or consumes — this app doesn't offer cross-app interfaces and the payment gateway is an external integration (wrapped as a Service), not another FormSpec app.


4. Step 2 — Customer & Order Entities

First, the customer entity the order will reference. Create modules/billing/entities/customer.yaml:

yaml
apiVersion: formspec.dev/v1
kind: Entity
metadata:
  name: customer
  module: billing
  description: Customer master record
spec:
  version: v1
  characteristics: [master]
  fields:
    - { name: name,           type: string,  rules: [required] }
    - { name: email,          type: string }
    - { name: is_blacklisted, type: boolean, default: false }

Now the order itself. Create modules/billing/entities/order.yaml:

yaml
apiVersion: formspec.dev/v1
kind: Entity
metadata:
  name: order
  module: billing
  description: Customer order from checkout to completed
spec:
  version: v1
  characteristics: [transaction]

  fields:
    # FR1 — Sequential number with natural_key_rule
    - name: number
      type: string
      natural_key: true
      immutable: true
      unique: true
      index: true
      natural_key_rule:
        strategy: sequence
        format: "{prefix}-{year}-{seq:06d}"
        prefix: { config: billing.order_number_prefix, default: "ORD" }
        reset: yearly

    - name: customer_id
      type: relation
      relation: { type: belongs_to, resource: customer }

    # Child items — ordered line items without own identity
    - name: items
      type: child
      child:
        storage: jsonb
        sequence_field: line_number
        fields:
          - { name: line_number, type: integer, immutable: true }
          - { name: product_id,  type: uuid, rules: [required] }
          - { name: quantity,    type: integer, rules: [required, positive] }
          - { name: price,       type: decimal, rules: [required, positive] }

    - name: total
      type: decimal
      rules: [required, positive]

    - name: member_tier
      type: enum
      enum_values: [regular, silver, gold]

    - name: status
      type: enum
      enum_values: [draft, awaiting_payment, paid, fulfilled, void]
      index: true

    - name: gateway_reference
      type: string

    - name: paid_at
      type: datetime

  state_machine:
    field: status
    initial: draft
    transitions:
      - { from: draft,            to: awaiting_payment, via: checkout,
          guard: "len(resource.items) > 0 and resource.total > 0" }
      - { from: awaiting_payment, to: paid,             via: mark-paid }
      - { from: [draft, awaiting_payment], to: void,    via: void }

  actions:
    - name: update
      conditions:
        - script: "resource.status == 'draft'"
          message: "Checked-out orders cannot be edited — use 'void'"

    - name: delete
      disabled: true              # Transaction records must keep audit trail

    - name: checkout
      description: Generate order number & create payment session
      required_permission: orders.checkout       # → billing.orders.checkout
      audit: true
      conditions:
        - script: "not customer.load(resource.customer_id).is_blacklisted"
          message: "Customer is blacklisted, cannot checkout"
      uses:
        # payment-gateway is a Service (defined in Step 5)
        resources: [payment-gateway.create-session, customer.find]
        config: { read: [billing.order_number_prefix] }
        primitives: [lock]
      impl: { type: script_ref, ref: billing/order_checkout }

    - name: mark-paid
      description: Transition to paid — called by payment gateway webhook
      required_permission: orders.mark-paid
      idempotent: true            # FR3 — framework-enforced
      idempotency_key: { from: param, field: event_id }
      audit: true
      emits: paid
      params:
        validate:
          - { field: gateway_reference, rules: [required] }
      impl: { type: script_ref, ref: billing/order_mark_paid }

  events:
    - name: paid
      description: Order successfully paid
      publish:
        durable: true             # FR4 — mandatory for financial events
      payload:
        fields: [id, number, total, customer_id, paid_at]
      deliver:
        - channel: audit_log
        - channel: websocket
          target: { scope: tenant }               # FR6 — dashboard ticker
        - { channel: queue, job: generate-receipt }        # FR10 + FR7
        - { channel: queue, job: send-receipt-email }      # FR5 — receipt = billing promise
        - channel: reliable_event                 # FR4 — journal, no loss
          target: { resource: gl.journal-entry, action: create }   # defined in Step 6
          retry: { max: 10, backoff: exponential, initial_delay_ms: 1000 }
          # failed-event is the built-in formspec.core dead-letter entity
          # (Core Basic §22) — nothing to define in this tutorial
          dead_letter: { resource: failed-event, action: create }
          idempotency_key: "order.paid.{id}"

What's happening here:

  • FR1 (sequential numbers): natural_key_rule with strategy: sequence and ctx.next_key() — the framework handles locking, reset periods, and formatting. No MAX()+1 ever.
  • FR3 (idempotency): idempotent: true on the mark-paid action. The framework maintains an idempotency store and replays the original response on duplicates.
  • FR4 (reliable events): publish.durable: true + deliver.reliable_event. The contract requires the order paid event (outbox write) and the entity mutation to be atomic — same DB transaction (Core Basic §3, §7). The outbox worker delivers idempotently. Implementation status: the reference jsonb-persist implementation does not yet wrap the outbox write in the mutation's transaction — see jsonb-persist gap notes §3; until that lands, a crash between commit and enqueue can drop the event.
  • The state machine allows draft → awaiting_payment via checkout, awaiting_payment → paid via mark-paid. All other transitions are blocked.
  • update is restricted to draft status only. delete is disabled for audit integrity.

5. Step 3 — Checkout Script

Create modules/billing/scripts/order_checkout.star:

python
def execute(resource, params, ctx):
    # FR1 — Natural key: ctx.next_key reads the field's natural_key_rule,
    # handles locking, reset, and formatting. Never MAX()+1 manually.
    number = ctx.next_key("number")
    resource.set("number", number).save()

    # FR2 — Gateway called ONLY via the declared Service wrapper (defined in Step 5).
    # In dev: auto-routes to mockup. In prod: real connector.
    session = payment_gateway.call("create-session", {
        "order_id": resource.id,
        "amount":   resource.total,
    })

    ctx.log.info("order.checkout", {"order_id": resource.id, "number": number})
    return ok({"payment_url": session.payment_url})

6. Step 4 — Mark-Paid Script

Create modules/billing/scripts/order_mark_paid.star:

python
def execute(resource, params, ctx):
    # FR3 is already handled BEFORE this line runs:
    # the framework rejects duplicate event_ids and replays the original response.
    resource.set("gateway_reference", params.gateway_reference)
    resource.set("paid_at", ctx.now())
    resource.save()   # Transitions awaiting_payment→paid AND writes the "paid"
                      # event to the outbox — ONE DB transaction per the contract
                      # (answers the second Scenario A bug: non-atomic
                      # status+journal). NOTE: the reference implementation does
                      # not yet honor this atomicity — see jsonb-persist gap notes.
    return ok()

That's 3 lines of business logic. All consequences of payment (journal, receipt, email, dashboard) are in the deliver block — declarative, auditable, and never forgotten.


7. Step 5 — Payment Gateway Service

Create modules/billing/services/payment-gateway.yaml:

yaml
apiVersion: formspec.dev/v1
kind: Service
metadata:
  name: payment-gateway
  module: billing
  description: Wrapper for Midtrans/Xendit (mockup in dev)
spec:
  version: v1
  actions:
    - name: create-session
      required_permission: payment-gateway.create-session
      impl: { type: native, ref: "PaymentGateway.CreateSession" }

    - name: webhook
      description: Callback from gateway — verify, then forward to order
      required_permission: payment-gateway.webhook
      idempotent: true
      idempotency_key: { from: param, field: event_id }
      uses:
        resources: [order.mark-paid]
      impl: { type: native, ref: "PaymentGateway.Webhook" }

Because this is a kind: Service, it automatically gets auth, permission enforcement, audit, and workspace isolation — no way to "forget to secure" an external integration.


8. Step 6 — Journal Entry Entity (Receiver)

Create modules/gl/entities/journal-entry.yaml:

yaml
apiVersion: formspec.dev/v1
kind: Entity
metadata:
  name: journal-entry
  module: gl
spec:
  version: v1
  characteristics: [transaction]
  fields:
    - { name: source,     type: string, immutable: true, index: true }
    - { name: source_id,  type: uuid,   immutable: true, index: true }
    - { name: amount,     type: decimal, rules: [required] }
    - { name: entry_date, type: date,    rules: [required] }
  actions:
    - name: create
      required_permission: journal-entries.create
      idempotent: true      # Outbox worker may retry — duplicates rejected here too
      # §11.3: idempotent requires a key source. The outbox worker passes the
      # publisher's delivery key ("order.paid.{id}") as the source_ref param.
      idempotency_key: { from: param, field: source_ref }
      params:
        validate:
          - { field: source_ref, rules: [required] }
      audit: true

The outbox worker calls create synchronously with idempotency checks on both sides — the journal never vanishes and never duplicates, even on retry.


9. Step 7 — WA Notification via Subscription (D35)

Real scenario: the system has been running for months. Someone wants WA notifications on order payment. order.yaml is not touched — especially if billing is a signed marketplace module that can't be edited.

Create modules/notifications/subscriptions/wa-on-order-paid.yaml:

yaml
apiVersion: formspec.dev/v1
kind: Subscription
metadata:
  name: wa-on-order-paid
  module: notifications
spec:
  on: { resource: billing.order, event: paid }
  deliver:
    - { channel: queue, job: send-wa-notification }

The dividing line (D35): journal stays in order's deliver because it's a billing promise (FR4). WA is notifications module's concern — Subscription. formspec describe entity billing.order shows the merged fan-out (publisher deliver + all Subscriptions).


10. Step 8 — Run It

bash
formspec dev

This starts the complete local environment: Postgres, Valkey, Mailpit, MinIO, formspec-control (dev/relaxed policy), and formspec-resource with hot reload.

  • Visit http://localhost:8080/_admin — the admin panel is derived automatically from your entities
  • Create a customer, create an order, checkout → the payment gateway auto-routes to the mockup in dev
  • Trigger the mock webhook → order transitions to paid → journal entry created, receipt PDF generated, email sent

11. What Just Happened — Concept Map

RequirementFormSpec ConstructWhy It Works
FR1 — Sequential numbersnatural_key_rule + ctx.next_key()Locking, reset, formatting handled by framework
FR2 — Payment gatewaykind: ServiceExternal integration wrapped; mockup in dev via config
FR3 — Webhook idempotencyidempotent: trueFramework-enforced store + response replay
FR4 — Reliable journalpublish.durable + deliver.reliable_eventOutbox in same DB transaction, sync delivery with idempotency
FR5 — Email reliabilitypublisher deliver channel: queueBackground job with retry — email is a billing promise, so it lives in order's deliver
FR5 — WA reliabilitykind: Subscription (Step 7)Same queue-with-retry guarantee, added later without touching order.yaml
FR6 — Live dashboarddeliver channel: websocketNon-durable — loss acceptable
FR7 — Cached discountctx.cache + manual invalidationCache is volatile by contract — a discount rule change explicitly invalidates, so stale tiers never linger
FR8 — Config per workspacekind: Config + ctx.config()Values are read per workspace at runtime — prefix and templates change without redeploy
FR9 — Structured loggingctx.log — tenant/request/user auto-injectedOne correlation ID follows the whole flow — nothing to wire by hand
FR10 — PDF storagectx.storage.write()Object storage, not filesystem

Next Steps

  • Read the Order-to-Cash Companion for the deep technical analysis, including the "without FormSpec vs with FormSpec" comparison
  • Explore Core Extended for Workflow (approval chains), Webhook signature verification, and Mockup environment binding
  • See Entity Extension to learn how to add custom fields to marketplace modules without forking

Standar terbuka (CC0) dengan implementasi referensi.