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
- After a successful cancel, immediately mark the row canceled in local state and disable the Cancel control.
- Make client cancel idempotent: on this code, refresh subscriptions and treat the plan as already canceled instead of surfacing an error.
- 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
- Send the Idempotency-Key header on cancel so retries converge.
- Update local state immediately after a successful cancel and disable the button.
- Debounce double clicks on subscription action buttons.
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
- subscription_has_not_been_canceled
- subscription_not_exists
- cant_update_onetime_payment_subscription
- subscription_expired
- workspace_id_required_to_update_team_subscription
AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18).
Data as JSON: /api/errors/9a80dee66bd9a591.
Report an issue: GitHub.