overleaf/overleaf · error · DuplicateAddOnError

Subscription already has add-on

Error message

Subscription already has add-on

What it means

PaymentProviderEntities.getRequestForAddOnPurchase builds a Recurly subscription-change request to purchase an add-on. Before building it, it checks this.hasAddOn(code); if the add-on already exists on the subscription, a DuplicateAddOnError is thrown with the subscriptionId and addOnCode, because Recurly cannot add an add-on that is already present.

Source

Thrown at services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs:219

    })

    return changeRequest
  }

  /**
   * Purchase an add-on on this subscription
   *
   * @param {string} code
   * @param {number} [quantity]
   * @param {number} [unitPrice]
   * @return {PaymentProviderSubscriptionChangeRequest} - the change request to send to
   * Recurly
   *
   * @throws {DuplicateAddOnError} if the add-on is already present on the subscription
   */
  getRequestForAddOnPurchase(code, quantity = 1, unitPrice) {
    if (this.hasAddOn(code)) {
      throw new DuplicateAddOnError('Subscription already has add-on', {
        subscriptionId: this.id,
        addOnCode: code,
      })
    }

    const baseAddOns = this.#getBaseAddOnsForChangeRequest('now')
    const addOnUpdates = baseAddOns.map(addOn => addOn.toAddOnUpdate())
    addOnUpdates.push(
      new PaymentProviderSubscriptionAddOnUpdate({ code, quantity, unitPrice })
    )
    return new PaymentProviderSubscriptionChangeRequest({
      subscription: this,
      timeframe: 'now',
      addOnUpdates,
    })
  }

  /**

View on GitHub (pinned to 28ad3b03b7)

Solutions

  1. Check this.hasAddOn(code) (or the equivalent entitlement) before calling getRequestForAddOnPurchase
  2. If the add-on exists, use getRequestForAddOnUpdate to change its quantity instead of purchasing
  3. Catch DuplicateAddOnError and treat it as a no-op / idempotent success
  4. Re-fetch the subscription entity before building the change request so pending changes are reflected

Example fix

// before
const request = subscription.getRequestForAddOnPurchase('additional-license', 1)
// after
const request = subscription.hasAddOn('additional-license')
  ? subscription.getRequestForAddOnUpdate('additional-license', newQuantity)
  : subscription.getRequestForAddOnPurchase('additional-license', 1)
Defensive patterns

Strategy: try-catch

Validate before calling

if (subscription.hasAddOn('additional-license')) {
  // use update path instead of purchase
}

Type guard

function canPurchaseAddOn(subscription, code) {
  return typeof code === 'string' && !subscription.hasAddOn(code)
}

Try / catch

try {
  const request = subscription.getRequestForAddOnPurchase(code, qty)
} catch (err) {
  if (err instanceof DuplicateAddOnError) {
    return { status: 'already-owned', subscriptionId: err.info.subscriptionId }
  }
  throw err
}

Prevention

When it happens

Trigger: Calling getRequestForAddOnPurchase(code) for an add-on code already attached to the subscription (or included in the pending change's add-on set), e.g. re-submitting a purchase request or attempting to add 'additional-license' when the user already has it.

Common situations: Double-submission of an upgrade UI form; logic that adds seats via the add-on path when the add-on already exists; syncing state where a prior pending change already added the add-on; code paths that don't check current entitlements before upgrading.

Related errors


AI-assisted analysis of overleaf/overleaf@28ad3b03b7 (2026-09-03). Data as JSON: /api/errors/72259d96c1c5f240. Report an issue: GitHub.