A subscription billed every month, in advance, for as long as the customer keeps it. It shows a timer that repeats: the state of each period holds the timer of the next renewal, so the timer is armed again by the step that pays the period. A period is paid from the account's credit when the credit covers the price, and charged otherwise. A declined charge is tried again behind a timer, and the state counts the attempts, so the third decline cancels the subscription. A change of plan or of payment method during a charge applies from the next one.
subscription.ts: the program.scenarios.test.ts: the subscription's scenarios, rehearsed, a restart among them.subscription.test.ts: the subscription's properties, checked for generated states./**
* A subscription billed every month, in advance, for as long as the customer keeps it. A period is paid from
* the account's credit when it covers the price, and charged otherwise. A declined charge is retried a few
* times before the subscription is cancelled.
*/
import * as Context from 'effect/Context'
import * as DateTime from 'effect/DateTime'
import * as Effect from 'effect/Effect'
import * as Match from 'effect/Match'
import * as Schema from 'effect/Schema'
import {
defect,
type IdempotencyKey,
noWork,
type Origin,
operation,
program,
reject,
type Rejected,
timer,
} from '@fgaudo/effect-tea-persistent/program'
const Plan = Schema.Struct({ name: Schema.String, priceInCents: Schema.Int })
type Plan = typeof Plan.Type
// The state stays the same size forever: it holds the present, and counts the past.
const Account = Schema.Struct({
customer: Schema.String,
payment: Schema.String,
plan: Plan,
// Applied at the next renewal.
nextPlan: Schema.NullOr(Plan),
creditInCents: Schema.Int,
// Periods end on the day of the month the subscription started, counted from it: adding a month to the end
// of the last period would move a start on the 31st to the 28th, and keep it there.
startedAt: Schema.DateTimeUtcFromMillis,
paidPeriods: Schema.Int,
cancelAtPeriodEnd: Schema.Boolean,
})
type Account = typeof Account.Type
const State = Schema.Union([
// Paid until the end of the period.
Schema.TaggedStruct('Active', { account: Account }),
// Charging the next period. Only the charge's completion leaves it: a cancellation meanwhile is remembered.
// What the charge uses is fixed when it starts: a charge does not change while it runs (decision 0018), so a
// new payment method meanwhile is used from the next one.
Schema.TaggedStruct('Charging', {
account: Account,
attempt: Schema.Int,
charge: Schema.Struct({ payment: Schema.String, totalInCents: Schema.Int }),
}),
Schema.TaggedStruct('PastDue', { account: Account, attempt: Schema.Int, retryAt: Schema.DateTimeUtcFromMillis }),
Schema.TaggedStruct('Cancelled', { account: Account, reason: Schema.Literals(['ByCustomer', 'Unpaid']) }),
])
export type State = typeof State.Type
const Flags = Schema.Struct({ customer: Schema.String, payment: Schema.String, plan: Plan })
type Flags = typeof Flags.Type
const Inbound = Schema.Union([
Schema.TaggedStruct('PlanChangeRequested', { plan: Plan }),
Schema.TaggedStruct('PaymentMethodUpdated', { payment: Schema.String }),
Schema.TaggedStruct('CancelRequested', {}),
Schema.TaggedStruct('ResumeRequested', {}),
Schema.TaggedStruct('CreditGranted', { amountInCents: Schema.Int }),
])
type Inbound = typeof Inbound.Type
const Internal = Schema.Union([
Schema.TaggedStruct('ChargeSucceeded', {}),
Schema.TaggedStruct('ChargeDeclined', {}),
Schema.TaggedStruct('RenewalDue', {}),
Schema.TaggedStruct('RetryDue', {}),
])
type Internal = typeof Internal.Type
const Sender = Schema.Union([
Schema.TaggedStruct('Customer', { id: Schema.String }),
Schema.TaggedStruct('Support', {}),
])
export type Sender = typeof Sender.Type
const Projection = Schema.Struct({
status: Schema.Literals(['Active', 'Charging', 'PastDue', 'Cancelled']),
plan: Plan,
nextPlan: Schema.NullOr(Plan),
// In milliseconds: the projection is JSON (decision 0139).
paidUntil: Schema.Number,
creditInCents: Schema.Int,
canChangePlan: Schema.Boolean,
canUpdatePayment: Schema.Boolean,
canCancel: Schema.Boolean,
canResume: Schema.Boolean,
// Support's action, beside the customer's: a support console reads the same projection.
canGrantCredit: Schema.Boolean,
})
export type Projection = typeof Projection.Type
export class Payments extends Context.Service<Payments, {
readonly charge: (
request: Readonly<{ payment: string; totalInCents: number; idempotencyKey: IdempotencyKey }>,
) => Effect.Effect<string, 'Declined'>
}>()('Payments') {}
const maxAttempts = 3
const retryDelay = { days: 2 }
const paidUntil = (account: Account) => DateTime.add(account.startedAt, { months: account.paidPeriods })
const isCustomer = (from: Origin<Sender>, account: Account) =>
from._tag === 'Caller' && from.sender._tag === 'Customer' && from.sender.id === account.customer
const isSupport = (from: Origin<Sender>) => from._tag === 'Caller' && from.sender._tag === 'Support'
const Operation = Schema.TaggedUnion({
Charge: { payment: Schema.String, totalInCents: Schema.Int },
})
type Operation = typeof Operation.Type
// A charge of the account's plan, with its payment method as they are now.
const charging = (account: Account, attempt: number): State => ({
_tag: 'Charging',
account,
attempt,
charge: { payment: account.payment, totalInCents: account.plan.priceInCents },
})
const charge = Effect.fn('Charge')(
function*({ payment, totalInCents }: Extract<Operation, { _tag: 'Charge' }>, idempotencyKey: IdempotencyKey) {
const payments = yield* Payments
return yield* payments.charge({ payment, totalInCents, idempotencyKey })
},
Effect.match({
onSuccess: (): Internal => ({ _tag: 'ChargeSucceeded' }),
onFailure: (): Internal => ({ _tag: 'ChargeDeclined' }),
}),
)
// The next period, from the credit when it covers the price. Staying `Active` keeps the `renewal` key: its
// timer has fired, so it is armed again at the end of the new period (decision 0008).
const renew = (account: Account): State => {
const plan = account.nextPlan ?? account.plan
const next = { ...account, plan, nextPlan: null }
return next.cancelAtPeriodEnd
? { _tag: 'Cancelled', account: next, reason: 'ByCustomer' }
: next.creditInCents >= plan.priceInCents
? {
_tag: 'Active',
account: { ...next, creditInCents: next.creditInCents - plan.priceInCents, paidPeriods: next.paidPeriods + 1 },
}
: charging(next, 1)
}
export const subscription = program({
state: State,
flags: Flags,
inbound: Inbound,
internal: Internal,
operation: Operation,
sender: Sender,
projection: Projection,
})({
name: 'Subscription',
keys: ['renewal', 'charge', 'retry'],
// The first period is charged at once.
init: ({ customer, payment, plan }, at) =>
charging({
customer,
payment,
plan,
nextPlan: null,
creditInCents: 0,
startedAt: at,
paidPeriods: 0,
cancelAtPeriodEnd: false,
}, 1),
update: (message, { at, from }) => (state) =>
Match.value(message).pipe(
Match.withReturnType<State | Rejected>(),
Match.tagsExhaustive({
PlanChangeRequested: ({ plan }) =>
state._tag !== 'Cancelled' && isCustomer(from, state.account)
? { ...state, account: { ...state.account, nextPlan: plan } }
: reject,
// A charge under way goes on with the method it started with, even after a crash: the new one is used
// from the next.
PaymentMethodUpdated: ({ payment }) =>
!isCustomer(from, state.account)
? reject
: state._tag === 'PastDue'
? charging({ ...state.account, payment }, state.attempt + 1)
: state._tag === 'Active' || state._tag === 'Charging'
? { ...state, account: { ...state.account, payment } }
: reject,
CancelRequested: () =>
!isCustomer(from, state.account)
? reject
: state._tag === 'PastDue'
? { _tag: 'Cancelled', account: state.account, reason: 'ByCustomer' }
: (state._tag === 'Active' || state._tag === 'Charging') && !state.account.cancelAtPeriodEnd
? { ...state, account: { ...state.account, cancelAtPeriodEnd: true } }
: reject,
ResumeRequested: () =>
(state._tag === 'Active' || state._tag === 'Charging') && state.account.cancelAtPeriodEnd &&
isCustomer(from, state.account)
? { ...state, account: { ...state.account, cancelAtPeriodEnd: false } }
: reject,
CreditGranted: ({ amountInCents }) =>
state._tag !== 'Cancelled' && amountInCents > 0 && isSupport(from)
? { ...state, account: { ...state.account, creditInCents: state.account.creditInCents + amountInCents } }
: reject,
ChargeSucceeded: () =>
state._tag === 'Charging'
? { _tag: 'Active', account: { ...state.account, paidPeriods: state.account.paidPeriods + 1 } }
: defect('a charge completed while nothing was charging'),
ChargeDeclined: () =>
state._tag !== 'Charging'
? defect('a charge was declined while nothing was charging')
: state.account.cancelAtPeriodEnd
? { _tag: 'Cancelled', account: state.account, reason: 'ByCustomer' }
: state.attempt >= maxAttempts
? { _tag: 'Cancelled', account: state.account, reason: 'Unpaid' }
: {
_tag: 'PastDue',
account: state.account,
attempt: state.attempt,
retryAt: DateTime.add(at, retryDelay),
},
RenewalDue: () =>
state._tag === 'Active'
? renew(state.account)
: defect('a renewal came due outside an active period'),
RetryDue: () =>
state._tag === 'PastDue'
? charging(state.account, state.attempt + 1)
: defect('a retry came due while nothing was past due'),
}),
),
work: (state) =>
Match.valueTags(state, {
Active: ({ account }) => ({ renewal: timer(paidUntil(account), { _tag: 'RenewalDue' }) }),
Charging: ({ charge }) => ({ charge: Operation.cases.Charge.make(charge) }),
PastDue: ({ retryAt }) => ({ retry: timer(retryAt, { _tag: 'RetryDue' }) }),
Cancelled: () => noWork,
}),
operations: { Charge: operation(charge) },
project: (state) => {
const open = state._tag !== 'Cancelled'
const renewing = state._tag === 'Active' || state._tag === 'Charging'
return {
status: state._tag,
plan: state.account.plan,
nextPlan: state.account.nextPlan,
paidUntil: DateTime.toEpochMillis(paidUntil(state.account)),
creditInCents: state.account.creditInCents,
canChangePlan: open,
canUpdatePayment: open,
canCancel: state._tag === 'PastDue' || (renewing && !state.account.cancelAtPeriodEnd),
canResume: renewing && state.account.cancelAtPeriodEnd,
canGrantCredit: open,
}
},
})
/**
* The subscription's scenarios, rehearsed.
*/
import { describe, expect, test } from 'bun:test'
import * as DateTime from 'effect/DateTime'
import * as Effect from 'effect/Effect'
import { accepted, rehearse, rejected } from '@fgaudo/effect-tea-persistent/testing'
import { type Sender, subscription } from './subscription.ts'
const startedAt = DateTime.makeUnsafe('2026-01-31T10:00:00Z')
/** A time as the projection gives it: its milliseconds (decision 0139). */
const millis = (iso: string) => DateTime.toEpochMillis(DateTime.makeUnsafe(iso))
const start = {
flags: { customer: 'ada', payment: 'card-1', plan: { name: 'Basic', priceInCents: 900 } },
at: startedAt,
}
const ada: Sender = { _tag: 'Customer', id: 'ada' }
const support: Sender = { _tag: 'Support' }
describe('subscription', () => {
test('each month is charged, and ends on the day the subscription started', () =>
Effect.runPromise(rehearse(
subscription,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('charge', { _tag: 'ChargeSucceeded' })
expect((yield* rehearsal.projection).paidUntil).toBe(millis('2026-02-28T10:00:00Z'))
yield* rehearsal.fire('renewal')
yield* rehearsal.answer('charge', { _tag: 'ChargeSucceeded' })
expect((yield* rehearsal.projection).paidUntil).toBe(millis('2026-03-31T10:00:00Z'))
}),
)))
// Time passes by a duration, and the renewal fires at its time within it, not before (decision 0187).
test('the renewal comes due when its period has passed', () =>
Effect.runPromise(rehearse(
subscription,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('charge', { _tag: 'ChargeSucceeded' })
yield* rehearsal.advance('27 days')
expect(yield* rehearsal.underWay('charge')).toBeNull()
yield* rehearsal.advance('1 day')
expect(yield* rehearsal.underWay('charge')).not.toBeNull()
}),
)))
test('a period paid from the credit arms the next renewal', () =>
Effect.runPromise(rehearse(
subscription,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('charge', { _tag: 'ChargeSucceeded' })
yield* rehearsal.send({ _tag: 'CreditGranted', amountInCents: 2_000 }, { sender: support })
const first = yield* rehearsal.pending('renewal')
expect(yield* rehearsal.fire(first)).toBe('Delivered')
// The state stayed `Active`, with the same key: the timer fired, so the key is new work.
const second = yield* rehearsal.pending('renewal')
expect(second.idempotencyKey).not.toBe(first.idempotencyKey)
expect(yield* rehearsal.fire(first)).toBe('Dropped')
expect(yield* rehearsal.projection).toMatchObject({ status: 'Active', creditInCents: 1_100 })
}),
)))
test('a plan change applies at the next renewal', () =>
Effect.runPromise(rehearse(
subscription,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('charge', { _tag: 'ChargeSucceeded' })
yield* rehearsal.send({ _tag: 'PlanChangeRequested', plan: { name: 'Pro', priceInCents: 1_900 } }, { sender: ada })
expect((yield* rehearsal.projection).plan.name).toBe('Basic')
yield* rehearsal.fire('renewal')
expect(yield* rehearsal.projection).toMatchObject({ plan: { name: 'Pro' }, nextPlan: null })
}),
)))
test('a charge declined three times cancels the subscription', () =>
Effect.runPromise(rehearse(
subscription,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('charge', { _tag: 'ChargeDeclined' })
yield* rehearsal.fire('retry')
yield* rehearsal.answer('charge', { _tag: 'ChargeDeclined' })
yield* rehearsal.fire('retry')
yield* rehearsal.answer('charge', { _tag: 'ChargeDeclined' })
expect(yield* rehearsal.state).toMatchObject({ _tag: 'Cancelled', reason: 'Unpaid' })
}),
)))
test('a new payment method is charged at once when past due', () =>
Effect.runPromise(rehearse(
subscription,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('charge', { _tag: 'ChargeDeclined' })
const retry = yield* rehearsal.pending('retry')
expect(yield* rehearsal.send({ _tag: 'PaymentMethodUpdated', payment: 'card-2' }, { sender: ada })).toEqual(accepted)
expect(yield* rehearsal.fire(retry)).toBe('Dropped')
yield* rehearsal.answer('charge', { _tag: 'ChargeSucceeded' })
expect((yield* rehearsal.state)._tag).toBe('Active')
}),
)))
// The charge restarted after a crash is the one that started, under the same idempotency key (decision 0018).
test('a new payment method during a charge is used from the next one, even after a crash', () =>
Effect.runPromise(rehearse(
subscription,
start,
Effect.fnUntraced(function*(rehearsal) {
const before = yield* rehearsal.pending('charge')
expect(before.work).toMatchObject({ _tag: 'Charge', payment: 'card-1', totalInCents: 900 })
expect(yield* rehearsal.send({ _tag: 'PaymentMethodUpdated', payment: 'card-2' }, { sender: ada })).toEqual(accepted)
yield* rehearsal.restart
expect(yield* rehearsal.pending('charge')).toEqual(before)
expect(yield* rehearsal.projection).toMatchObject({ status: 'Charging' })
}),
)))
test('a cancellation during a charge applies when the charge ends', () =>
Effect.runPromise(rehearse(
subscription,
start,
Effect.fnUntraced(function*(rehearsal) {
expect(yield* rehearsal.send({ _tag: 'CancelRequested' }, { sender: ada })).toEqual(accepted)
yield* rehearsal.answer('charge', { _tag: 'ChargeSucceeded' })
// The period is paid: the subscription ends with it.
expect(yield* rehearsal.projection).toMatchObject({ status: 'Active', canResume: true })
yield* rehearsal.fire('renewal')
expect(yield* rehearsal.state).toMatchObject({ _tag: 'Cancelled', reason: 'ByCustomer' })
}),
)))
test('the renewal survives a restart', () =>
Effect.runPromise(rehearse(
subscription,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('charge', { _tag: 'ChargeSucceeded' })
const before = yield* rehearsal.pending('renewal')
yield* rehearsal.restart
const after = yield* rehearsal.pending('renewal')
expect(after.idempotencyKey).toBe(before.idempotencyKey)
expect(yield* rehearsal.fire(after)).toBe('Delivered')
}),
)))
test('only support grants credit', () =>
Effect.runPromise(rehearse(
subscription,
start,
Effect.fnUntraced(function*(rehearsal) {
expect(yield* rehearsal.send({ _tag: 'CreditGranted', amountInCents: 2_000 }, { sender: ada })).toMatchObject(rejected)
}),
)))
})
/**
* The subscription's properties, for generated states.
*/
import { describe, expect, test } from 'bun:test'
import * as DateTime from 'effect/DateTime'
import fc from 'fast-check'
import { projectionViolations } from '@fgaudo/effect-tea-persistent/testing'
import { type Sender, type State, subscription } from './subscription.ts'
const at = DateTime.makeUnsafe('2026-10-30T10:00:00Z')
const time = fc.date({ min: new Date('2026-01-01'), max: new Date('2027-01-01'), noInvalidDate: true })
.map(DateTime.fromDateUnsafe)
const plan = fc.record({ name: fc.constantFrom('Basic', 'Pro'), priceInCents: fc.nat(10_000) })
const account = fc.record({
customer: fc.constantFrom('ada', 'bob'),
payment: fc.constantFrom('card-1', 'card-2'),
plan,
nextPlan: fc.option(plan, { nil: null }),
creditInCents: fc.nat(10_000),
startedAt: time,
paidPeriods: fc.nat(24),
cancelAtPeriodEnd: fc.boolean(),
})
const attempt = fc.integer({ min: 1, max: 3 })
// Every case of the subscription's states, with any data.
const state: fc.Arbitrary<State> = fc.oneof(
fc.record({ _tag: fc.constant('Active'), account }),
fc.record({
_tag: fc.constant('Charging'),
account,
attempt,
charge: fc.record({ payment: fc.string(), totalInCents: fc.nat(10_000) }),
}),
fc.record({ _tag: fc.constant('PastDue'), account, attempt, retryAt: time }),
fc.record({ _tag: fc.constant('Cancelled'), account, reason: fc.constantFrom('ByCustomer', 'Unpaid') }),
)
describe('subscription', () => {
// The projection shows the customer's actions and support's. Each message is sent by both, and only one may send it.
test('the projection allows what update accepts, all of it, to each sender', () => {
const customerMay = (sender: Sender, allowed: boolean) => sender._tag === 'Customer' && allowed
const check = projectionViolations(subscription, {
PlanChangeRequested: (projection, _, sender) => customerMay(sender, projection.canChangePlan),
PaymentMethodUpdated: (projection, _, sender) => customerMay(sender, projection.canUpdatePayment),
CancelRequested: (projection, _, sender) => customerMay(sender, projection.canCancel),
ResumeRequested: (projection, _, sender) => customerMay(sender, projection.canResume),
CreditGranted: (projection, _, sender) => sender._tag === 'Support' && projection.canGrantCredit,
})
const support: Sender = { _tag: 'Support' }
fc.assert(fc.property(state, (state) => {
const customer: Sender = { _tag: 'Customer', id: state.account.customer }
expect(check(state, [customer, support].flatMap((sender) => [
{ message: { _tag: 'PlanChangeRequested', plan: { name: 'Pro', priceInCents: 1_900 } }, sender },
{ message: { _tag: 'PaymentMethodUpdated', payment: 'card-3' }, sender },
{ message: { _tag: 'CancelRequested' }, sender },
{ message: { _tag: 'ResumeRequested' }, sender },
{ message: { _tag: 'CreditGranted', amountInCents: 2_000 }, sender },
]), at)).toEqual([])
}))
})
})