Skip to main content

Subscription & billing lifecycle

Run a subscription through its trial, recurring billing periods, price changes, and cancellation without losing its state while it waits.

Product requirements

The Flow starts with a customer and a subscription plan. The plan supplies a trial duration, billing-period duration, maximum number of billing periods, and current charge amount. It must:

  1. Store the customer and initialize the billing counter.
  2. Send a welcome email, then wait for the trial Timer to fire before billing begins.
  3. Wait for each billing-period Timer, charge the current period, and repeat until the configured number of periods is reached.
  4. Accept a charge-amount update while the trial or a billing Timer is still pending. Persist the new amount so the next charge uses it.
  5. Accept a cancellation at any time. Send the cancellation email and complete the whole Flow, including sibling Steps that are still waiting.
  6. End the subscription after its final billing period and notify the customer.
  7. Return the stored plan through a Describe RPC while the Flow is active.

The Flow waits on two durable condition types: Timers for the trial and each billing period, and Channel messages for cancellation and charge updates. A Flow can stay in either wait for days or months; restarting a Worker does not restart the trial or lose a message that was already accepted.

Potential failures need product decisions:

  • A welcome email, charge, or final notification can fail after the external provider has already accepted it. Those operations must be idempotent, because a retried Step can call the provider again.
  • A price update must contain exactly one amount. An invalid or unexpected message causes the update Step to fail instead of silently choosing an amount.
  • Cancellation can race with an expiring billing Timer. The payment provider and cancellation policy must be safe for that race, including any refund or entitlement decision.
  • Publishing after cancellation or normal completion cannot reopen the Flow. Clients should treat that as a terminal result and start a new subscription when appropriate.

Flow design

This interactive definition graph is generated from the runnable Python Flow.

Definition graph

SubscriptionFlow

Valid

python · examples/python/dex_examples/products/subscription/subscription_flow.py

The initializer fans out into three long-lived branches:

InitializeTrialChargeCurrentBill

InitializeCancel

InitializeUpdateChargeAmount

Trial owns the trial Timer and hands off to the billing loop. ChargeCurrentBill increments the durable billing-period Attribute before its next Timer and either charges the period or force-completes once the plan is over. Cancel and UpdateChargeAmount remain active alongside that path, each waiting for one Channel message at a time.

This combines several design patterns:

  • Fan-out concurrency starts billing, cancellation, and price-control responsibilities independently from one initializer.
  • Durable Timer loop represents a recurring billing schedule as a self-transition, rather than a process that must remain in memory.
  • Event-driven control plane uses Channels for customer-initiated cancellation and operational price changes.
  • Durable state keeps the customer and billing counter in Attributes, so each branch reads the current plan rather than retaining mutable Worker memory.
  • Force completion makes cancellation and end-of-subscription terminal decisions that stop sibling waits.

The Rust example has the same durable state, billing loop, and control branch. Its public update and cancel RPCs publish commands to its internal Channel, while the other SDK examples publish directly to Channels.

Core implementation

Each tab contains the runnable Flow implementation: its signature, Step topology, persistence schema, and read RPC. The linked examples also contain the request model, dependency service, HTTP controller, and end-to-end test.

class SubscriptionFlow(Flow[Customer]):
billing_period_number = Attribute("billing-period-number", int)
customer_details = Attribute("customer", Customer)
cancel_subscription = Channel[None]("cancel-subscription", type(None))
update_charge_amount = Channel("update-charge-amount", int)

def __init__(self, service: MyDependencyService) -> None:
self.service = service
self.charge_current_bill = ChargeCurrentBill(
service,
self.customer_details,
self.billing_period_number,
)
self.trial = Trial(
service,
self.customer_details,
self.billing_period_number,
self.charge_current_bill,
)
self.cancel = Cancel(
service,
self.customer_details,
self.cancel_subscription,
)
self.update_charge_amount_step = UpdateChargeAmount(
self.customer_details,
self.update_charge_amount,
)
self.initialize = Initialize(
self.customer_details,
self.trial,
self.cancel,
self.update_charge_amount_step,
)

def get_steps(self) -> StepList[Customer]:
return StepList.start_step(self.initialize).other_steps(
self.trial,
self.charge_current_bill,
self.cancel,
self.update_charge_amount_step,
)

def get_persistence_schema(self) -> PersistenceSchema:
return PersistenceSchema.of(
self.billing_period_number,
self.customer_details,
self.cancel_subscription,
self.update_charge_amount,
)

@rpc
def describe(self, context: Context) -> RPCResult[Subscription]:
return RPCResult(self.customer_details.get(context).subscription)

Example: examples/python/dex_examples/products/subscription/subscription_flow.py

Demo

The completed Go run below stored the subscription, fanned out to Trial, Cancel, and UpdateChargeAmount, applied a price update, then completed through cancellation. Completed nodes are completed Step executions. Waiting nodes show the durable condition that was still active when cancellation closed the Flow.

Open the image to inspect it at its native resolution.

Completed SubscriptionFlow Step execution graph