{"record":{"id":"b9dc961b0d9ca638","repo":"toeverything/AFFiNE","slug":"subscription-has-not-been-canceled","errorCode":"subscription_has_not_been_canceled","errorMessage":"Your subscription has not been canceled.","messagePattern":"Your subscription has not been canceled\\.","errorType":"exception","errorClass":"SubscriptionHasNotBeenCanceled","httpStatus":403,"severity":"warning","filePath":"packages/backend/server/src/plugins/payment/service.ts","lineNumber":228,"sourceCode":"    idempotencyKey?: string\n  ): Promise<Subscription> {\n    this.assertSubscriptionIdentity(identity);\n\n    const manager = this.select(identity.plan);\n\n    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.canceledAt) {\n      throw new SubscriptionHasNotBeenCanceled();\n    }\n\n    if (!subscription.stripeSubscriptionId || !subscription.end) {\n      throw new CantUpdateOnetimePaymentSubscription(\n        'Onetime payment subscription cannot be resumed.'\n      );\n    }\n\n    if (subscription.end < new Date()) {\n      throw new SubscriptionExpired();\n    }\n\n    // update the subscription in db optimistically\n    const newSubscription = await manager.resumeSubscription(subscription);\n\n    if (subscription.stripeScheduleId) {\n      const manager = await this.scheduleManager.fromSchedule(\n        subscription.stripeScheduleId","sourceCodeStart":210,"sourceCodeEnd":246,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/plugins/payment/service.ts#L210-L246","documentation":"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.","triggerScenarios":"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.","commonSituations":"Billing list not refreshed after cancel/resume transitions; optimistic client state out of sync with server; duplicate submissions from double clicks.","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."],"exampleFix":"// before\n{sub.status === 'active' && <ResumeButton onClick={() => resume(identity)} />}\n\n// after\n{sub.canceledAt && sub.status === 'active' && (\n  <ResumeButton onClick={() => resume(identity)} />\n)}","handlingStrategy":"validation","validationCode":"if (!subscription.canceledAt) {\n  showActiveState(); // nothing to resume\n} else {\n  await svc.resumeSubscription(identity, idempotencyKey);\n}","typeGuard":"function isCanceledSub(s: { canceledAt: Date | string | null }): s is { canceledAt: Date } {\n  return s.canceledAt !== null;\n}","tryCatchPattern":"catch (e) { if (gqlErrorCode(e) === 'subscription_has_not_been_canceled') { await refreshSubscriptions(); return; } throw e; }","preventionTips":["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."],"tags":["payment","subscription","resume","state-machine","duplicate-request"],"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-14T00:17:10.932Z"}