toeverything/AFFiNE · warning · SubscriptionHasNotBeenCanceled

subscription_has_not_been_canceled

subscription_has_not_been_canceled

Error message

Your subscription has not been canceled.

What it means

Thrown by resumeSubscription when subscription.canceledAt is null. Resume is only valid for a subscription that was previously canceled (scheduled to end); calling it on an active subscription is a state-machine violation.

Source

Thrown at packages/backend/server/src/plugins/payment/service.ts:228

    idempotencyKey?: string
  ): Promise<Subscription> {
    this.assertSubscriptionIdentity(identity);

    const manager = this.select(identity.plan);

    const subscription = await manager.getActiveSubscription(identity);

    if (!subscription) {
      throw new SubscriptionNotExists({ plan: identity.plan });
    }

    // 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

View on GitHub (pinned to b4c8548c09)

Solutions

  1. Only render/enable Resume when canceledAt is set on the subscription row.
  2. After any cancel/resume success or subscription webhook, refresh the subscription list before allowing the next action.
  3. Treat this code on retry as success-equivalent: reload state instead of erroring.

Example fix

// before
{sub.status === 'active' && <ResumeButton onClick={() => resume(identity)} />}

// after
{sub.canceledAt && sub.status === 'active' && (
  <ResumeButton onClick={() => resume(identity)} />
)}
Defensive patterns

Strategy: validation

Validate before calling

if (!subscription.canceledAt) {
  showActiveState(); // nothing to resume
} else {
  await svc.resumeSubscription(identity, idempotencyKey);
}

Type guard

function isCanceledSub(s: { canceledAt: Date | string | null }): s is { canceledAt: Date } {
  return s.canceledAt !== null;
}

Try / catch

catch (e) { if (gqlErrorCode(e) === 'subscription_has_not_been_canceled') { await refreshSubscriptions(); return; } throw e; }

Prevention

When it happens

Trigger: User clicks Resume on an active (never canceled) subscription; retry race where resume already succeeded (canceledAt cleared) and the client re-sends; stale UI still showing a canceled badge after a webhook flipped the row back to active.

Common situations: Billing list not refreshed after cancel/resume transitions; optimistic client state out of sync with server; duplicate submissions from double clicks.

Related errors


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