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
accountstream - 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: stringState
# 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: 0Actions
# 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_refIdempotency
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.