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 productrenewal— auto-renew succeeded or price-change confirmedgrace— billing retry startedrecovered— payment recovered after gracecancel— user disabled auto-renewrefund— provider-initiated refundexpire— natural end of billing periodgrant— admin-side manual grant, stacks on existing expiry
Transition rules
- Last writer wins on expiry. A later
expires_atalways beats an earlier one for the same entitlement. Out-of-order renewals do not shrink the window. - Grace preserves existing expiry. A
graceevent does not overwriteexpires_at— the original period is still what the user gets. - Cancel keeps access. A cancel event flips the status to
cancelledbut keepsexpires_at, so the user retains access until the billing period ends. - Grant stacks. An admin grant on an already-active subscription extends
expires_atforward 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.