toeverything/AFFiNE · error · CantUpdateOnetimePaymentSubscription

cant_update_onetime_payment_subscription

cant_update_onetime_payment_subscription

Error message

Onetime payment subscription cannot be canceled.

What it means

Thrown by cancelSubscription when the active subscription has no stripeSubscriptionId. Such rows are onetime/lifetime purchases (e.g. Lifetime recurring or onetime variant), which have no recurring Stripe subscription to cancel, so cancellation is meaningless and rejected.

Source

Thrown at packages/backend/server/src/plugins/payment/service.ts:177

    identity: z.infer<typeof SubscriptionIdentity>,
    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.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 {

View on GitHub (pinned to b4c8548c09)

Solutions

  1. Hide the Cancel action when the subscription has no stripeSubscriptionId (or recurring is 'lifetime' / variant onetime).
  2. For refunds on onetime purchases, use the refund/invoice path, not cancelSubscription.
  3. Copy guidance to users: lifetime access does not renew, so there is nothing to cancel.

Example fix

// before
{sub.status === 'active' && <CancelButton onClick={() => cancel(identity)} />}

// after
{sub.status === 'active' && sub.stripeSubscriptionId && (
  <CancelButton onClick={() => cancel(identity)} />
)}
Defensive patterns

Strategy: validation

Validate before calling

const cancellable = Boolean(subscription.stripeSubscriptionId);
if (!cancellable) throw new Error('Onetime/lifetime purchases cannot be canceled');
await svc.cancelSubscription(identity, idempotencyKey);

Type guard

function isRecurringSub(s: { stripeSubscriptionId: string | null }): s is { stripeSubscriptionId: string } {
  return typeof s.stripeSubscriptionId === 'string' && s.stripeSubscriptionId.length > 0;
}

Try / catch

catch (e) { if (gqlErrorCode(e) === 'cant_update_onetime_payment_subscription') { hideCancelAction(); showOnetimeNotice(); return; } throw e; }

Prevention

When it happens

Trigger: Calling cancelSubscription on a Lifetime Pro purchase or any onetime-variant checkout result; refund flows mistakenly routed through cancel.

Common situations: Pricing page offers both monthly and lifetime; UI reuses the subscription settings component and shows Cancel for lifetime rows; test data created from onetime payments.

Related errors


AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18). Data as JSON: /api/errors/c3ff1b3ea320bae2. Report an issue: GitHub.