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.stripeScheduleIdView on GitHub (pinned to b4c8548c09)
Solutions
- Only render/enable Resume when canceledAt is set on the subscription row.
- After any cancel/resume success or subscription webhook, refresh the subscription list before allowing the next action.
- 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
- Render Resume only when canceledAt is set.
- Refresh subscriptions after every lifecycle mutation and webhook event.
- Treat this code on retry as success-equivalent: reload and continue.
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
- subscription_has_been_canceled
- subscription_expired
- 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/b9dc961b0d9ca638.
Report an issue: GitHub.