Press ESC to close

Fallos de pago en suscripciones (billing issues) con StoreKit

Una de las causas más silenciosas de pérdida de ingresos en una app de suscripción no es que el cliente cancele, sino que le falle el cobro: una tarjeta caducada, sin saldo o bloqueada por el banco. Es lo que se llama churn involuntario, y el cliente normalmente ni se entera de que ha dejado de pagar.

La buena noticia: App Store tiene un sistema automático para recuperar esos pagos. En esta entrada lo vemos de forma visual y resumida, y centrándonos en lo que tu app puede leer desde el propio dispositivo con StoreKit: cómo detectar el fallo, qué es el periodo de gracia y, sobre todo, cuándo debes cortar el acceso y cuándo no.

Enfoque de esta entrada

Toda esta información (estado de la suscripción, si está en reintento, cuándo acaba la gracia, etc.) se puede consultar de dos formas: a nivel de app (StoreKit, en el dispositivo) y a nivel de servidor (App Store Server API y notificaciones server-to-server). Aquí nos quedamos a nivel de app, que es lo que necesitas si no tienes infraestructura de servidor. Más adelante hay una tabla con la equivalencia entre ambos mundos.

1. El problema: churn involuntario

Dos formas de perder a un suscriptor

Churn voluntario

El cliente decide irse
  • Desactiva la renovación a propósito.
  • En el dispositivo, willAutoRenew pasa a false.
  • Se combate con ofertas de retención y win-back.

Churn involuntario

Falla el cobro
  • Quiere seguir, pero el pago no pasa (tarjeta caducada, sin fondos).
  • El estado de la suscripción cambia a reintento o periodo de gracia.
  • Se combate con los reintentos y el periodo de gracia configurado en App Store Connect.

El churn involuntario es, en buena medida, recuperable de forma automática: el sistema de reintentos de App Store recupera más del 77% de las renovaciones fallidas a lo largo de 60 días, y más del 80% de esas recuperaciones ocurren en los primeros 16 días.

2. Las dos defensas de App Store

Cuando un cobro de renovación falla, App Store no da por terminada la suscripción de inmediato: pone en marcha hasta dos mecanismos para recuperar el cobro.

Billing Retry (reintento)

Automático y siempre activo. App Store reintenta el cobro durante un máximo de 60 días.

  • No requiere configuración.
  • Mientras dura, el estado es .inBillingRetryPeriod.
  • Si recupera, vuelve a .subscribed; si se agota, pasa a .expired.

Billing Grace Period (periodo de gracia)

Opcional (se activa en App Store Connect). Mantiene el acceso al servicio mientras se reintenta el cobro.

  • Mientras dura, el estado es .inGracePeriod.
  • El cliente sigue usando la app durante el reintento.
  • Reduce la fricción y mejora la recuperación.

Cuánto dura el periodo de gracia

Se activa una sola vez en App Store Connect (Suscripciones, sección Billing Grace Period, botón Set Up Billing Grace Period) y se aplica a todas las suscripciones auto-renovables de la app: no se puede configurar por producto. Eliges una duración de 3, 16 o 28 días, y la que se aplica de verdad depende de la duración de la suscripción:

Duración elegidaSuscripción semanalMensual y anual
3 días3 días3 días
16 días6 días16 días
28 días6 días28 días

Resumido: 3 o 6 días para suscripciones semanales, y 3, 16 o 28 días para mensuales o más largas. Las semanales se topan en 6 días para que la gracia nunca sea más larga que la propia suscripción.

El periodo gracia se asigna en el momento del error de cobro y no se puede alterar una vez asignada a un usuario. Además: tu elección solo se aplica en producción; en sandbox la duración la marca la cadencia de renovación acelerada de la cuenta de prueba.

3. Una idea clave: App Store no corta tu servicio

Un malentendido habitual: App Store no desactiva las funciones de tu app cuando falla un pago. Lo que hace es gestionar el estado de la suscripción (reintentar el cobro, marcarla en gracia, en reintento o expirada) y exponértelo. Conceder o revocar el acceso es decisión de tu app, leyendo ese estado.

Lo que hace App Store

  • Reintenta el cobro.
  • Marca el estado (gracia / reintento / expirada).
  • Mantiene o retira el entitlement según ese estado.

Lo que hace tu app

  • Lee el estado con StoreKit.
  • Concede o corta el acceso a tu contenido.
  • Avisa al cliente si conviene.

4. Qué ocurre, paso a paso

Recorrido típico de un fallo de pago con periodo de gracia activado, visto desde el estado de la suscripción en el dispositivo (RenewalState):

De fallo de cobro a recuperación (o expiración)
1.
Llega la fecha de renovación y el cobro falla.
Tarjeta caducada, sin saldo, rechazo del banco...
2.
El estado pasa a .inGracePeriod.
Mantienes el acceso hasta gracePeriodExpirationDate. El cliente no nota nada.
3.
App Store reintenta el cobro en segundo plano durante días.
4(a).
Éxito: el estado vuelve a .subscribed.
Se conserva el ciclo de facturación. Fin feliz, sin interrupción.
4(b).
Si la gracia termina sin cobro: el estado pasa a .inBillingRetryPeriod.
Ahora sí cortas el acceso; el reintento continúa sin servicio.
5.
Si se agotan los 60 días de reintentos: el estado pasa a .expired.
Con expirationReason == .billingError. Aquí encaja una campaña de win-back.

Sin periodo de gracia la línea es más corta: del fallo se pasa directamente a .inBillingRetryPeriod (cortando el acceso), y de ahí a .subscribed (recuperación) o a .expired (fin).

5. Leerlo desde la app

El punto de partida es Product.SubscriptionInfo.Status, que obtienes con product.subscription?.status. Cada estado trae tres cosas: el state (un RenewalState), la transaction y la renewalInfo.

RenewalState: los cinco estados

.subscribed
Al día, con acceso.
.expired
Terminada, sin acceso. Mira expirationReason para saber por qué.
.inBillingRetryPeriod
Reintentando el cobro sin periodo de gracia. Normalmente sin acceso.
.inGracePeriod
Reintentando el cobro pero con acceso mantenido.
.revoked
Reembolso o revocación de Family Sharing.

Campos de RenewalInfo a vigilar

Campo (en el dispositivo)Para qué
isInBillingRetryIndica si la suscripción está en reintento de cobro. Útil para detectar el riesgo y decidir si avisar al cliente.
gracePeriodExpirationDateFecha hasta la que se debe mantener el acceso durante la gracia. Si no es nil, estás en periodo de gracia.
willAutoRenewSi la renovación sigue activada. Ayuda a distinguir churn voluntario (la desactivó) de involuntario (falló el pago).
expirationReasonEn una suscripción ya expirada, el motivo: .billingError, .autoRenewDisabled, .didNotConsentToPriceIncrease, .productUnavailable...

En código

guard let statuses = try await product.subscription?.status else { return }

for status in statuses {
    guard case .verified(let renewalInfo) = status.renewalInfo else { continue }

    switch status.state {
    case .subscribed:
        // al día: concede acceso
    case .inGracePeriod:
        // falló el cobro pero mantienes acceso hasta gracePeriodExpirationDate
    case .inBillingRetryPeriod:
         // reintentando sin gracia: normalmente corta el acceso
    case .expired:
        // terminada; renewalInfo.expirationReason == .billingError, etc.
    case .revoked:
        // reembolso o Family Sharing
    }
}

Para el control de acceso en sí, recorre Transaction.currentEntitlements: durante .inGracePeriod la transacción sigue ahí, así que el acceso continúa sin que tengas que hacer nada especial.

6. Lo mismo, a nivel de servidor

Por si en el futuro montas infraestructura de servidor, esta es la equivalencia. No necesitas nada de esto para lo anterior: con la app basta. Solo cambian los nombres y el canal.

Qué quieres saberEn el dispositivo (StoreKit)En el servidor
Estado de la suscripciónRenewalState (.inGracePeriod...)campo status (4 = gracia, 3 = reintento)
Está reintentando el cobroisInBillingRetryisInBillingRetryPeriod
Hasta cuándo dura la graciagracePeriodExpirationDategracePeriodExpiresDate
Motivo de expiraciónexpirationReasonexpirationIntent
El cobro falló o se recuperócambio en RenewalStatenotificaciones DID_FAIL_TO_RENEW / DID_RECOVER

7. Avisar al cliente sin sacarlo de la app

StoreKit incluye una API de mensajes del sistema, Message, que se gestiona desde el cliente. Cuando hay un problema de facturación, App Store prepara un mensaje con motivo billingIssue; tú decides cuándo presentar esa hoja del sistema, que invita al cliente a corregir su método de pago sin abandonar tu app.

import StoreKit

// Escucha los mensajes del App Store (configuralo al arrancar la app)
for await message in Message.messages {
    // Solo nos interesa el problema de facturacion; el resto se pueden diferir
    guard message.reason == .billingIssue else { continue }

    // UIKit: indica que la app esta lista para mostrarlo
    guard let windowScene = view.window?.windowScene else { continue }
    try? message.display(in: windowScene)

    // SwiftUI: presentalo con @Environment(\.displayStoreKitMessage) (DisplayMessageAction)
}

Escuchar no basta: si no llamas a display(in:) (UIKit) o a DisplayMessageAction (SwiftUI) tras recibir el mensaje, App Store lo suprime. Ese es justo el mecanismo para diferirlo hasta un momento oportuno. Otros motivos de Message.Reason son .priceIncreaseConsent (subida de precio), .generic y .winBackOffer. Y presentarlo no garantiza que se muestre: si el cliente ya resolvió el problema, no aparecerá nada.

8. Probarlo en sandbox

Cómo simular un fallo de pago

En el dispositivo de prueba, dentro de los ajustes de la cuenta de Sandbox, desactiva "Allow Purchases & Renewals" para forzar un fallo de renovación y disparar el reintento. Verás cómo el estado de la suscripción cambia a .inGracePeriod o .inBillingRetryPeriod sin tocar nada de servidor. Recuerda que la duración del periodo de gracia en sandbox la marca la cadencia de renovación acelerada de la cuenta, no las duraciones de producción.

9. Errores que conviene evitar

No hagas

  • Cortar el acceso cuando el estado es .inGracePeriod.
  • Asumir que App Store deshabilita tus funciones por ti.
  • Decidir el acceso sin mirar el RenewalState.
  • Confundir los nombres del cliente (isInBillingRetry) con los del servidor (isInBillingRetryPeriod).

Sí haz

  • Activar el periodo de gracia salvo que tengas una razón para no hacerlo.
  • Conceder acceso con currentEntitlements y afinar el mensaje con el RenewalState.
  • Avisar con la hoja billingIssue cuando detectes el problema.
  • Gestionar estados desconocidos sin romper (Apple añade nuevos cada año).
  • Reservar el win-back para .expired, no para el periodo de reintento.

10. Conclusión

El fallo de pago no es el final de la relación con el suscriptor: es un bache que App Store intenta resolver por ti. Tu trabajo es no estorbar. Recuerda tres cosas: App Store gestiona el estado, pero el acceso lo concede tu app leyendo RenewalState y currentEntitlements; activa el periodo de gracia para no penalizar al cliente por algo que no decidió; y distingue las rutas, mantén durante .inGracePeriod, recupera al volver a .subscribed y corta solo en reintento sin gracia o en .expired. Pruébalo de principio a fin en el sandbox desactivando "Allow Purchases & Renewals", y deja el win-back para cuando la suscripción haya expirado de verdad.

Bibliografía y fuentes consultadas

Reflexiones de alguien que disfruta investigando y conectando temas; no es asesoría legal.