Concepts

State machine

Every subscription goes through a single state machine regardless of provider. We normalize each provider-specific event into a neutral shape, apply the transition in a serialized per-user context, then persist the canonical result and invalidate any stale read caches.

States

      trial ──┐
              ├─▶ active ──┐
      new ────┘            ├─▶ grace ──┬─▶ active (payment recovered)
                           │            └─▶ expired
                           ├─▶ cancelled (still entitled until expires_at)
                           ├─▶ refunded (terminal)
                           └─▶ expired (natural end)

Event types

  • purchase — first buy of a product
  • renewal — auto-renew succeeded or price-change confirmed
  • grace — billing retry started
  • recovered — payment recovered after grace
  • cancel — user disabled auto-renew
  • refund — provider-initiated refund
  • expire — natural end of billing period
  • grant — admin-side manual grant, stacks on existing expiry

Transition rules

  • Last writer wins on expiry. A later expires_at always beats an earlier one for the same entitlement. Out-of-order renewals do not shrink the window.
  • Grace preserves existing expiry. A grace event does not overwrite expires_at — the original period is still what the user gets.
  • Cancel keeps access. A cancel event flips the status to cancelled but keeps expires_at, so the user retains access until the billing period ends.
  • Grant stacks. An admin grant on an already-active subscription extends expires_at forward by the grant duration.
  • Refund and expire are terminal. They never bounce back to active unless a new purchase event arrives for the same user and entitlement.

Idempotency

Every normalized event carries a provider-derived idempotency key. stubkit enforces uniqueness on that key so re-delivery — common for Apple App Store Server Notifications and Google Play Real-time Developer Notifications — is a silent no-op. Your state never double-counts.