ExamplesBilling Events

Billing Events

This example shows how to model subscription billing state in Causet — tracking plan changes, payments, and access status.


Design decisions

  • Billing events come from an external payment processor (Stripe, etc.) via webhooks
  • Causet tracks billing state as entity state on the account stream
  • Idempotency is critical — webhook retries must not double-charge or double-credit
  • All state is derivable from the event history for auditing

Events

# events/billing.events.causet
events:
  SUBSCRIPTION_CREATED:
    state: account
    entity_expr: event.account_id
    payload:
      account_id:   string
      plan_id:      string
      started_at:   string
      external_ref: string    # Stripe subscription ID
 
  SUBSCRIPTION_UPGRADED:
    state: account
    entity_expr: event.account_id
    payload:
      account_id: string
      old_plan_id: string
      new_plan_id: string
 
  PAYMENT_SUCCEEDED:
    state: account
    entity_expr: event.account_id
    payload:
      account_id:    string
      amount_cents:  int
      currency:      string
      period_end:    string
      external_ref:  string    # Stripe payment intent ID
 
  PAYMENT_FAILED:
    state: account
    entity_expr: event.account_id
    payload:
      account_id:    string
      failure_code:  string
      external_ref:  string
 
  SUBSCRIPTION_CANCELLED:
    state: account
    entity_expr: event.account_id
    payload:
      account_id:     string
      cancellation_reason: string
      access_until:   string

State

# states/account.state.causet
state:
  account:
    entity_key: account_id
    fields:
      - name: plan_id
        type: string
        default: ""
      - name: subscription_status
        type: string
        default: "none"
      - name: access_until
        type: string
        default: ""
      - name: total_paid_cents
        type: int
        default: 0
      - name: payment_failure_count
        type: int
        default: 0

Actions

# actions/billing.actions.causet
actions:
  RECORD_PAYMENT_SUCCEEDED:
    state: account
    input:
      account_id:    { type: string,  required: true }
      amount_cents:  { type: integer, required: true }
      currency:      { type: string,  required: true }
      period_end:    { type: string,  required: true }
      external_ref:  { type: string,  required: true }
    core:
      rules:
        - name: update_payment_state
          when: {}
          then:
            - op: add
              path: /total_paid_cents
              value: intent.amount_cents
            - op: set
              path: /subscription_status
              value: "active"
            - op: set
              path: /access_until
              value: intent.period_end
            - op: set
              path: /payment_failure_count
              value: 0
    side_effects:
      rules:
        - name: emit_payment
          then:
            - op: emit
              event_type: PAYMENT_SUCCEEDED
              payload:
                account_id:   intent.account_id
                amount_cents: intent.amount_cents
                currency:     intent.currency
                period_end:   intent.period_end
                external_ref: intent.external_ref
 
  RECORD_PAYMENT_FAILED:
    state: account
    input:
      account_id:   { type: string, required: true }
      failure_code: { type: string, required: true }
      external_ref: { type: string, required: true }
    core:
      rules:
        - name: increment_failure_count
          when: {}
          then:
            - op: add
              path: /payment_failure_count
              value: 1
        - name: suspend_after_three_failures
          when: { expr: "entity.payment_failure_count >= 3" }
          then:
            - op: set
              path: /subscription_status
              value: "suspended"
    side_effects:
      rules:
        - name: emit_failed
          then:
            - op: emit
              event_type: PAYMENT_FAILED
              payload:
                account_id:   intent.account_id
                failure_code: intent.failure_code
                external_ref: intent.external_ref

Idempotency

Webhook retries are a reality with payment processors. Use external_ref (Stripe’s unique IDs) as part of a dedupe check in preflight:

preflight:
  rules:
    - name: check_not_already_processed
      when: {}
      then:
        - op: if
          expr: "LOOKUP_FIELD('payment_log_stream', intent.external_ref, 'processed') == true"
          then:
            - op: reject
              code: ALREADY_PROCESSED
              message: "Payment already recorded"

Or use the external_ref as part of a natural primary key in projections — UPSERT handles duplicates naturally.


Projection

projections:
  account_billing_status:
    source_events: [SUBSCRIPTION_CREATED, PAYMENT_SUCCEEDED, PAYMENT_FAILED, SUBSCRIPTION_CANCELLED]
    target:
      table: account_billing_status
      primary_key: [account_id]
    fields:
      account_id:          TEXT
      plan_id:             TEXT
      subscription_status: TEXT
      total_paid_cents:    BIGINT
      access_until:        TEXT
      last_payment_at:     BIGINT
      payment_failure_count: BIGINT
      updated_at:          BIGINT
    derive:
      account_id:  event.account_id
      updated_at:  event.ts
    mutations:
      SUBSCRIPTION_CREATED:  { op: upsert }
      PAYMENT_SUCCEEDED:     { op: upsert }
      PAYMENT_FAILED:        { op: upsert }
      SUBSCRIPTION_CANCELLED: { op: upsert }
    indexes:
      - columns: [subscription_status]
 
  payment_history:
    source_events: [PAYMENT_SUCCEEDED, PAYMENT_FAILED]
    target:
      table: payment_history
      primary_key: [external_ref]
    fields:
      external_ref:  TEXT
      account_id:    TEXT
      amount_cents:  BIGINT
      currency:      TEXT
      status:        TEXT
      failure_code:  TEXT
      occurred_at:   BIGINT
    derive:
      external_ref: event.external_ref
      account_id:   event.account_id
      occurred_at:  event.ts
    mutations:
      PAYMENT_SUCCEEDED: { op: upsert }
      PAYMENT_FAILED:    { op: upsert }
    indexes:
      - columns: [account_id]

Audit value

Every billing state change has a corresponding event in ledger_events. The entire billing history is:

  • Immutable and append-only
  • Traceable back to specific external references (Stripe IDs)
  • Replayable for auditing or rebuilding state
  • Available for dispute resolution

This is significantly more auditable than mutable billing tables with a separate audit log.