effect-tea-persistent
    Preparing search index...

    A subscription

    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.

    /**
    * 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([])
    }))
    })
    })