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

  1. Compare subscription.end with the current time client-side and swap Resume for a checkout/re-subscribe CTA once passed.
  2. Hide expired canceled rows from the resume list; surface them as expired entitlements only.
  3. 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

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


AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18). Data as JSON: /api/errors/d0c55c34c358ae60. Report an issue: GitHub.