{"record":{"id":"87540f4e3f66ebb6","repo":"hcengineering/platform","slug":"failed-to-cancel-subscription-providersubscripti-87540f","errorCode":null,"errorMessage":"Failed to cancel subscription ${providerSubscriptionId}","messagePattern":"Failed to cancel subscription (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"services/payment/pod-payment/src/providers/stripe/provider.ts","lineNumber":277,"sourceCode":"\n      ctx.info('Stripe subscription reconciliation completed', {\n        stripeActiveCount: stripeActiveSubscriptions.length,\n        ourActiveCount: ourActiveSubscriptions.length,\n        upsertedCount: upsertCount,\n        staleUpdatedCount: staleCount\n      })\n    } catch (err) {\n      ctx.error('Stripe subscription reconciliation failed', { err })\n      throw err\n    }\n  }\n\n  async cancelSubscription (ctx: MeasureContext, providerSubscriptionId: string): Promise<SubscriptionData> {\n    const stripeSubscription = await this.stripe.cancelSubscription(ctx, providerSubscriptionId)\n    const subscriptionData = transformStripeSubscriptionToData(ctx, stripeSubscription)\n\n    if (subscriptionData == null) {\n      throw new Error(`Failed to cancel subscription ${providerSubscriptionId}`)\n    }\n\n    return subscriptionData\n  }\n\n  async uncancelSubscription (ctx: MeasureContext, providerSubscriptionId: string): Promise<SubscriptionData> {\n    const stripeSubscription = await this.stripe.uncancelSubscription(ctx, providerSubscriptionId)\n    const subscriptionData = transformStripeSubscriptionToData(ctx, stripeSubscription)\n    if (subscriptionData == null) {\n      throw new Error(`Failed to uncancel subscription ${providerSubscriptionId}`)\n    }\n\n    return subscriptionData\n  }\n\n  async updateSubscriptionPlan (\n    ctx: MeasureContext,\n    subscriptionId: string,","sourceCodeStart":259,"sourceCodeEnd":295,"githubUrl":"https://github.com/hcengineering/platform/blob/63e28dc96483967b2fc21c881b3f1023c1de7718/services/payment/pod-payment/src/providers/stripe/provider.ts#L259-L295","documentation":"After cancelSubscription successfully calls Stripe to cancel the subscription, the raw Stripe.Subscription is converted via transformStripeSubscriptionToData. If that transform returns null (the subscription is in a state the mapper does not recognize / cannot represent), cancelSubscription throws this error even though the Stripe-side cancellation already happened. It signals 'cancel succeeded at Stripe but result could not be mapped to SubscriptionData'.","triggerScenarios":"Calling cancelSubscription with a providerSubscriptionId whose Stripe subscription is in an unexpected status (e.g. already canceled/incomplete_expired) such that transformStripeSubscriptionToData returns null; a subscription object missing fields the mapper requires.","commonSituations":"Cancelling the same subscription twice (second call hits an already-canceled subscription the mapper skips); Stripe-side changes via dashboard producing statuses the transform doesn't handle; stale subscription IDs after test-mode/live-mode switching.","solutions":["Verify the providerSubscriptionId points to an active Stripe subscription before cancelling","Check transformStripeSubscriptionToData in providers/stripe/utils.ts and extend it to handle the status that returns null (e.g. 'canceled')","Check in Stripe dashboard/logs what status the subscription actually had at cancel time","Treat the operation idempotently: if the subscription is already canceled, fetch and return its data instead of throwing"],"exampleFix":"// before\nconst subscriptionData = transformStripeSubscriptionToData(ctx, stripeSubscription)\nif (subscriptionData == null) {\n  throw new Error(`Failed to cancel subscription ${providerSubscriptionId}`)\n}\n// after\nconst subscriptionData =\n  transformStripeSubscriptionToData(ctx, stripeSubscription) ?? {\n    providerSubscriptionId,\n    status: 'canceled' // fallback for already-canceled subs the mapper skips\n  } as unknown as SubscriptionData","handlingStrategy":"try-catch","validationCode":"// check current subscription state before cancelling\nconst sub = await paymentProvider.getSubscription(ctx, providerSubscriptionId)\nif (sub == null || sub.status === 'canceled') {\n  return sub // already canceled; nothing to do\n}","typeGuard":"function isSubscriptionData(x: SubscriptionData | null | undefined): x is SubscriptionData {\n  return x != null && typeof x.providerSubscriptionId === 'string'\n}","tryCatchPattern":"try {\n  return await paymentProvider.cancelSubscription(ctx, providerSubscriptionId)\n} catch (err) {\n  if ((err as Error).message.startsWith('Failed to cancel subscription')) {\n    // Stripe-side cancel may already have happened; verify actual state before retrying\n    const current = await paymentProvider.getSubscription(ctx, providerSubscriptionId)\n    if (current?.status === 'canceled') return current\n  }\n  throw err\n}","preventionTips":["Make cancel operations idempotent in callers (check status first, tolerate already-canceled)","Keep transformStripeSubscriptionToData covering every Stripe subscription status you can encounter","Avoid double-click/duplicate cancel requests from the UI (disable button while in flight)","Watch Stripe dashboard for subscriptions canceled outside the app"],"tags":["stripe","billing","data-transform","idempotency"],"backgroundTag":"stripe-subscription-transform-null","analyzedSha":"63e28dc96483967b2fc21c881b3f1023c1de7718","analyzedAt":"2026-08-29T15:21:27.377Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}