toeverything/AFFiNE · warning · SubscriptionHasBeenCanceled

subscription_has_been_canceled

subscription_has_been_canceled

Error message

Your subscription has already been canceled.

What it means

Thrown by cancelSubscription when subscription.canceledAt is already set, i.e. the subscription was canceled before (scheduled to end at period end) and another cancel request arrives. Double-cancel is treated as a state error rather than a no-op.

Source

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

    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.stripeSubscriptionId) {
      throw new CantUpdateOnetimePaymentSubscription(
        'Onetime payment subscription cannot be canceled.'
      );
    }

    if (subscription.canceledAt) {
      throw new SubscriptionHasBeenCanceled();
    }

    // update the subscription in db optimistically
    const newSubscription = manager.cancelSubscription(subscription);

    // should release the schedule first
    if (subscription.stripeScheduleId) {
      const manager = await this.scheduleManager.fromSchedule(
        subscription.stripeScheduleId
      );
      await manager.cancel(idempotencyKey);
    } else {
      // let customer contact support if they want to cancel immediately
      // see https://stripe.com/docs/billing/subscriptions/cancel
      await this.stripe.subscriptions.update(
        subscription.stripeSubscriptionId,
        { cancel_at_period_end: true },
        { idempotencyKey }

View on GitHub (pinned to b4c8548c09)

Solutions

  1. After a successful cancel, immediately mark the row canceled in local state and disable the Cancel control.
  2. Make client cancel idempotent: on this code, refresh subscriptions and treat the plan as already canceled instead of surfacing an error.
  3. Send the Idempotency-Key header on cancel calls so genuine network retries converge on one operation.

Example fix

// before
await svc.cancelSubscription(identity);

// after
try {
  await svc.cancelSubscription(identity, undefined, idempotencyKey);
} catch (e) {
  if (gqlErrorCode(e) === 'subscription_has_been_canceled') {
    return refreshSubscriptions(); // converge on server truth
  }
  throw e;
}
Defensive patterns

Strategy: try-catch

Validate before calling

if (subscription.canceledAt) {
  showAlreadyCanceledState();
} else {
  await svc.cancelSubscription(identity, idempotencyKey);
}

Type guard

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

Try / catch

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

Prevention

When it happens

Trigger: User clicks Cancel twice (double click, or two tabs); retry of an idempotent-key-less cancel after the first succeeded; UI state not updated after the first cancel so the button stays enabled.

Common situations: Flaky network triggers client retry after the server already committed; webhook and user action both try to cancel; back-end job and manual action race.

Related errors


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