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
- Hide the Cancel action when the subscription has no stripeSubscriptionId (or recurring is 'lifetime' / variant onetime).
- For refunds on onetime purchases, use the refund/invoice path, not cancelSubscription.
- 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
- Hide cancel/resume for lifetime and onetime-variant purchases.
- Route refund requests for onetime payments through the refund flow.
- Include recurring vs lifetime in the subscription UI model so the branch is explicit.
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
- subscription_not_exists
- subscription_has_been_canceled
- workspace_id_required_to_update_team_subscription
- unsupported_subscription_plan
- managed_by_app_store_or_play
AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18).
Data as JSON: /api/errors/c3ff1b3ea320bae2.
Report an issue: GitHub.