{"record":{"id":"9a80dee66bd9a591","repo":"toeverything/AFFiNE","slug":"subscription-has-been-canceled","errorCode":"subscription_has_been_canceled","errorMessage":"Your subscription has already been canceled.","messagePattern":"Your subscription has already been canceled\\.","errorType":"exception","errorClass":"SubscriptionHasBeenCanceled","httpStatus":403,"severity":"warning","filePath":"packages/backend/server/src/plugins/payment/service.ts","lineNumber":183,"sourceCode":"    const subscription = await manager.getActiveSubscription(identity);\n\n    if (!subscription) {\n      throw new SubscriptionNotExists({ plan: identity.plan });\n    }\n\n    // IAP read-only: RevenueCat-managed subscriptions cannot be modified on web\n    if (subscription.provider === 'revenuecat') {\n      throw new ManagedByAppStoreOrPlay();\n    }\n\n    if (!subscription.stripeSubscriptionId) {\n      throw new CantUpdateOnetimePaymentSubscription(\n        'Onetime payment subscription cannot be canceled.'\n      );\n    }\n\n    if (subscription.canceledAt) {\n      throw new SubscriptionHasBeenCanceled();\n    }\n\n    // update the subscription in db optimistically\n    const newSubscription = manager.cancelSubscription(subscription);\n\n    // should release the schedule first\n    if (subscription.stripeScheduleId) {\n      const manager = await this.scheduleManager.fromSchedule(\n        subscription.stripeScheduleId\n      );\n      await manager.cancel(idempotencyKey);\n    } else {\n      // let customer contact support if they want to cancel immediately\n      // see https://stripe.com/docs/billing/subscriptions/cancel\n      await this.stripe.subscriptions.update(\n        subscription.stripeSubscriptionId,\n        { cancel_at_period_end: true },\n        { idempotencyKey }","sourceCodeStart":165,"sourceCodeEnd":201,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/plugins/payment/service.ts#L165-L201","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nawait svc.cancelSubscription(identity);\n\n// after\ntry {\n  await svc.cancelSubscription(identity, undefined, idempotencyKey);\n} catch (e) {\n  if (gqlErrorCode(e) === 'subscription_has_been_canceled') {\n    return refreshSubscriptions(); // converge on server truth\n  }\n  throw e;\n}","handlingStrategy":"try-catch","validationCode":"if (subscription.canceledAt) {\n  showAlreadyCanceledState();\n} else {\n  await svc.cancelSubscription(identity, idempotencyKey);\n}","typeGuard":"function isAlreadyCanceled(s: { canceledAt: Date | string | null }): boolean {\n  return s.canceledAt !== null;\n}","tryCatchPattern":"catch (e) { if (gqlErrorCode(e) === 'subscription_has_been_canceled') { await refreshSubscriptions(); markCanceledInUI(); return; } throw e; }","preventionTips":["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."],"tags":["payment","subscription","cancel","duplicate-request","state-machine"],"backgroundTag":"invalid-subscription-state","analyzedSha":"b4c8548c09da21b2898443559a5b846f0ccf5dd8","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}