toeverything/AFFiNE · error · SubscriptionExpired
subscription_expired
subscription_expired
Error message
Your subscription has expired.
What it means
Thrown by resumeSubscription when subscription.end is in the past. A canceled subscription can only be resumed before its scheduled end date; once end has passed, the subscription is terminated and the user must start a new checkout instead.
Source
Thrown at packages/backend/server/src/plugins/payment/service.ts:238
}
// IAP read-only: RevenueCat-managed subscriptions cannot be modified on web
if (subscription.provider === 'revenuecat') {
throw new ManagedByAppStoreOrPlay();
}
if (!subscription.canceledAt) {
throw new SubscriptionHasNotBeenCanceled();
}
if (!subscription.stripeSubscriptionId || !subscription.end) {
throw new CantUpdateOnetimePaymentSubscription(
'Onetime payment subscription cannot be resumed.'
);
}
if (subscription.end < new Date()) {
throw new SubscriptionExpired();
}
// update the subscription in db optimistically
const newSubscription = await manager.resumeSubscription(subscription);
if (subscription.stripeScheduleId) {
const manager = await this.scheduleManager.fromSchedule(
subscription.stripeScheduleId
);
await manager.resume(idempotencyKey);
} else {
await this.stripe.subscriptions.update(
subscription.stripeSubscriptionId,
{ cancel_at_period_end: false },
{ idempotencyKey }
);
}
View on GitHub (pinned to b4c8548c09)
Solutions
- Compare subscription.end with the current time client-side and swap Resume for a checkout/re-subscribe CTA once passed.
- Hide expired canceled rows from the resume list; surface them as expired entitlements only.
- On catching this code, route the user directly to the checkout flow for the same plan.
Example fix
// before
if (sub.canceledAt) await svc.resumeSubscription(identity);
// after
if (sub.canceledAt && sub.end && sub.end > new Date()) {
await svc.resumeSubscription(identity);
} else {
router.push(`/checkout?plan=${identity.plan}`);
} Defensive patterns
Strategy: validation
Validate before calling
const now = Date.now();
const withinWindow = subscription.end ? new Date(subscription.end).getTime() > now : false;
if (!withinWindow) throw new Error('Resume window closed; start a new checkout');
await svc.resumeSubscription(identity, idempotencyKey); Type guard
function isInResumeWindow(s: { end: Date | string | null }): boolean {
return s.end !== null && new Date(s.end).getTime() > Date.now();
} Try / catch
catch (e) { if (gqlErrorCode(e) === 'subscription_expired') { routeToCheckout(identity.plan); notify('Offer expired, please re-subscribe.'); return; } throw e; } Prevention
- Compare subscription.end with server time or a synced clock client-side before enabling Resume.
- Hide expired canceled rows; show a re-subscribe CTA instead.
- On catching subscription_expired, never retry resume; route to checkout.
When it happens
Trigger: User returns after the billing period ended and clicks Resume on a stale canceled row; clock/timezone bug in the client showing Resume past the deadline; delayed webhook left the row visible after expiration.
Common situations: Billing page left open across the period boundary; 'cancel at period end' with the user coming back weeks later; server clock skew in self-hosted deployments.
Related errors
- subscription_has_not_been_canceled
- subscription_has_been_canceled
- workspace_id_required_to_update_team_subscription
- unsupported_subscription_plan
- subscription_not_exists
AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18).
Data as JSON: /api/errors/d0c55c34c358ae60.
Report an issue: GitHub.