Verificar y restaurar: derechos vigentes, firma y recuperación de compras
Una transacción es una afirmación sobre el mundo —esta persona pagó por esto— y toda afirmación necesita una cadena de confianza que la respalde. `StoreKit 2` entrega transacciones firmadas criptográficamente y verificadas por el dispositivo, expone el conjunto de derechos vigentes como una secuencia asíncrona y reduce la restauración a algo casi trivial. Esta lección explica qué garantiza exactamente esa firma, qué no garantiza, y cómo construir una capa de derechos que sea correcta tanto sin red como frente a un atacante.
Todo sistema de pagos acaba tropezando con la misma pregunta epistemológica: cómo sabe un programa que alguien pagó. La respuesta ingenua —guardar un booleano cuando la compra tenga éxito— falla ante el primer cambio de dispositivo, ante la primera reinstalación y ante el primer usuario con ganas de editar un archivo de preferencias. La respuesta correcta invierte la relación: la app no recuerda las compras, las consulta, y la fuente de verdad es un conjunto de transacciones firmadas por Apple que el dispositivo puede validar por sí solo. Ese giro tiene consecuencias arquitectónicas notables, porque convierte los derechos en una función derivada de un estado externo en lugar de en un estado propio que hay que mantener sincronizado. Y tiene una consecuencia de producto todavía mayor: cuando los derechos se consultan, restaurar deja de ser una operación y pasa a ser el comportamiento normal del sistema.
- Recorrer
Transaction.currentEntitlementsy derivar de ahí el estado de acceso de la app. - Interpretar
VerificationResulty decidir con criterio qué hacer ante una transacción no verificada. - Distinguir restaurar, sincronizar con
AppStore.syncy las tres razones por las que un derecho desaparece. - Situar correctamente la verificación en dispositivo frente a la verificación en servidor y saber cuándo hace falta la segunda.
Los derechos vigentes como fuente de verdad
Transaction.currentEntitlements es una secuencia asíncrona que emite, para cada producto, la transacción vigente que otorga un derecho activo en este momento. Contiene los no consumibles comprados, las suscripciones auto renovables que siguen pagadas y las no renovables cuyo plazo no ha vencido; no contiene los consumibles, porque un saldo no es un derecho vigente sino un estado que administras tú, ni las suscripciones caducadas, ni las compras reembolsadas o revocadas. Consultarla en el arranque y cada vez que llegue un evento produce el estado de acceso completo sin haber guardado nada.
@MainActor
final class Derechos: ObservableObject {
@Published private(set) var activos: Set<String> = []
func recalcular() async {
var vigentes: Set<String> = []
for await verificacion in Transaction.currentEntitlements {
guard case .verified(let transaccion) = verificacion else { continue }
guard transaccion.revocationDate == nil else { continue }
vigentes.insert(transaccion.productID)
}
activos = vigentes
}
var tieneAccesoPro: Bool {
!activos.isDisjoint(with: ["pro.mensual", "pro.anual", "pro.vitalicio"])
}
}
Ese fragmento contiene una decisión de diseño que conviene explicitar: la app no pregunta si el usuario compró el plan mensual, pregunta si tiene acceso profesional. Traducir de identificadores de producto a capacidades en un único punto es lo que permite añadir un plan nuevo, cambiar precios o introducir una edición vitalicia sin tocar ninguna vista. Las integraciones que dispersan comparaciones de identificadores por la interfaz descubren el problema cuando marketing pide una promoción y hay que modificar catorce archivos.
La comprobación de revocationDate cubre el caso incómodo del reembolso: Apple puede devolver el dinero de una compra ya entregada, y cuando lo hace la transacción sigue existiendo pero marcada como revocada. Ignorar ese campo produce usuarios con acceso permanente y coste cero, que es una forma peculiar de generosidad involuntaria.
La tentación de guardar el resultado en disco para no volver a consultar es fuerte y casi siempre equivocada. currentEntitlements responde desde una caché local del propio sistema, funciona sin red y es rápido. Recalcular en cada arranque, en cada evento de Transaction.updates y al volver del segundo plano cuesta milisegundos y elimina de golpe las incoherencias entre lo que tu app cree y lo que la tienda sabe.
Qué significa exactamente la firma
Cada transacción llega envuelta en un VerificationResult, que es un tipo con dos casos: verificada, con la transacción dentro, y no verificada, con la transacción y el motivo del fallo. Bajo el capó, la transacción viaja como un objeto firmado en formato JWS cuya cadena de certificados se remonta a una autoridad de Apple, y el dispositivo comprueba esa cadena localmente. La consecuencia es notable: la verificación no necesita red ni servidor propio, que es precisamente lo que hacía tan penosa la generación anterior, en la que el recibo era un blob opaco que había que enviar a un servicio de Apple para descifrarlo.
Conviene ser preciso sobre el alcance de esa garantía, porque se suele exagerar en ambas direcciones. La firma demuestra que el contenido de la transacción fue emitido por Apple y no ha sido alterado, y que corresponde a este identificador de aplicación. No demuestra que el proceso que la está leyendo sea tu binario legítimo, ni impide que un dispositivo comprometido intercepte la biblioteca antes de que tu código la consulte. Dicho de otro modo: la verificación resuelve la falsificación de datos, no el control del entorno de ejecución. Cualquier defensa contra lo segundo pertenece a otra categoría de esfuerzo y tiene rendimientos rápidamente decrecientes.
Verificada
Firma válida y cadena de confianza intacta. Es el camino normal y el único sobre el que se debe conceder acceso sin más consideraciones.
No verificada
Firma inválida, revocada o de otro identificador de aplicación. Registra el caso, no concedas el derecho y no acuses al usuario: casi siempre es un entorno raro, no un fraude.
Revocada
Transacción reembolsada o retirada por Apple. Existe, es válida y sin embargo no otorga nada: comprobar revocationDate es obligatorio.
La pregunta práctica es qué hacer ante el caso no verificado, y la respuesta razonable depende del valor en juego. Para una app de consumo, ignorar la transacción y seguir es correcto: el porcentaje de usuarios afectados es minúsculo y la fricción de un mensaje de error alarmante hace más daño que el fraude evitado. Para un producto donde cada derecho vale mucho dinero, el patrón adecuado es verificar en servidor: la app envía el identificador de la transacción a tu backend y este lo consulta contra la API de servidor de la App Store, obteniendo la verdad de la fuente y no de un dispositivo que podría estar comprometido. El criterio es económico, no ideológico: se invierte en verificación remota cuando el coste esperado del fraude supera el coste de construirla y mantenerla.
Existe además una comprobación de nivel superior que muchas apps ignoran: AppTransaction, que describe la compra de la app misma —cuándo se adquirió, con qué versión y desde qué tienda— y que es la pieza necesaria para implementar correctamente esquemas del tipo quien compró antes de tal versión conserva las funciones antiguas gratis.
// Derechos heredados: quien instalo antes de la version de pago conserva el acceso
func compraroriginalEsAnteriorA(_ version: Int) async -> Bool {
guard let resultado = try? await AppTransaction.shared,
case .verified(let appTransaccion) = resultado,
let original = Int(appTransaccion.originalAppVersion) else { return false }
return original < version
}
Ese fragmento resuelve un problema clásico de las apps que cambian de modelo: cómo no traicionar a quien pagó bajo las reglas anteriores. La versión original de compra es un dato firmado por Apple, no una preferencia local que el usuario pueda fabricar, y por tanto se puede confiar en ella para conceder derechos heredados sin necesidad de mantener una lista de compradores en ningún servidor.
Queda por comentar la relación entre currentEntitlements y sus dos parientes, que se confunden a menudo. Transaction.all devuelve el histórico completo, incluidos consumibles, transacciones caducadas y revocadas, y sirve para contabilidad y soporte, jamás para decidir accesos. Transaction.latest devuelve la última transacción de un producto concreto, esté vigente o no, y es útil para saber cuándo caducó algo o si alguna vez se compró. La regla que evita el noventa por ciento de los errores es mecánica: para conceder acceso, siempre currentEntitlements; para contar historia, las otras dos.
Restaurar sin drama
En la generación anterior, restaurar era una operación explícita, lenta y frecuentemente rota, y la App Store exigía un botón visible para invocarla. Con el modelo actual la restauración ocurre sola: en cuanto el usuario inicia sesión con su cuenta de Apple, currentEntitlements devuelve sus derechos en el dispositivo nuevo sin que nadie pulse nada. El botón sigue siendo obligatorio en la interfaz, pero su papel ha cambiado por completo: ya no reconstruye el estado, sino que fuerza una sincronización y, sobre todo, provoca una autenticación cuando el sistema tiene una caché desactualizada o el usuario está en otra cuenta.
func restaurar() async {
do {
try await AppStore.sync() // pide autenticacion al usuario
await derechos.recalcular()
} catch {
// El usuario cancelo la autenticacion: no es un fallo que reportar
}
}
Dos advertencias sobre AppStore.sync. La primera es que solicita credenciales, de modo que llamarla en el arranque o de forma preventiva produce un diálogo de contraseña sin motivo aparente que el usuario interpreta como sospechoso; se invoca únicamente desde una acción explícita. La segunda es que su efecto no es mágico: si el usuario no compró nada con esa cuenta, no aparecerá nada, y la interfaz debe distinguir con claridad entre no se encontraron compras y hubo un problema al consultar.
flowchart TD
a[Arranque de la app] --> b[Escuchar Transaction updates]
b --> c[Recorrer currentEntitlements]
c --> d{Transaccion verificada y sin revocacion}
d -->|si| e[Anadir capacidad al conjunto activo]
d -->|no| f[Registrar y no conceder]
e --> g[La interfaz observa capacidades no productos]
h[Boton restaurar] --> i[AppStore sync con autenticacion] --> cLa interfaz de restauración tiene además una obligación que se olvida con frecuencia: comunicar el resultado. Un botón que al pulsarse no produce ningún cambio visible es indistinguible de un botón roto, y el usuario que acaba de estrenar teléfono y no ve volver sus compras escribirá al soporte en menos de un minuto. Basta con tres estados —consultando, se restauraron tantas compras, no se encontró ninguna compra con esta cuenta— para eliminar por completo esa categoría de incidencias.
Cuando alguien afirma haber pagado y no tener acceso, las causas posibles son pocas y se descartan en orden: está usando otra cuenta de Apple, la compra fue reembolsada, la suscripción caducó, o el derecho existe y tu capa de traducción no lo reconoce. Una pantalla de diagnóstico oculta que enumere los identificadores devueltos por currentEntitlements convierte una conversación de soporte de varios días en una captura de pantalla.
Queda por nombrar la tercera vía de desaparición de un derecho, la que más desconcierta al soporte: no es la caducidad ni el reembolso, es el cambio de cuenta de Apple. Las compras pertenecen a la cuenta que pagó, no a la persona ni al dispositivo, y un usuario que inicia sesión con otra cuenta pierde legítimamente el acceso aunque el teléfono sea el mismo. Las apps que además tienen cuenta propia viven una tensión permanente entre dos identidades que no coinciden, y la única forma de resolverla sin contradicciones consiste en decidir explícitamente cuál manda: si el derecho es de la cuenta de Apple, tu servidor debe respetarlo; si es de tu cuenta, tu servidor lleva el registro y StoreKit solo aporta la prueba de pago inicial.
Hay un patrón general escondido en todo esto y vale la pena extraerlo, porque quien lo comprende deja de escribir una clase entera de errores para siempre. La diferencia entre guardar un booleano cuando alguien compra y consultar los derechos vigentes cada vez es la diferencia entre estado replicado y estado derivado, y esa distinción decide la mayoría de los problemas de coherencia en el software. Un estado replicado es una copia local de una verdad que vive en otro sitio, y toda copia contrae la misma deuda: hay que actualizarla cuando el original cambia, y como el original puede cambiar sin avisarte —una renovación mientras la app está cerrada, un reembolso tramitado por teléfono, un cambio de cuenta, una compra hecha en otro dispositivo—, la deuda se paga en forma de un mecanismo de invalidación que siempre tiene agujeros. Un estado derivado, en cambio, no puede estar desactualizado, porque no existe hasta que alguien pregunta: es una función pura sobre la mejor información disponible, y su corrección no depende de haber gestionado bien todos los eventos del pasado. La razón por la que este patrón funciona tan bien aquí es que el sistema operativo ya mantiene una caché local, verificable y disponible sin red, de modo que el argumento habitual contra derivar —cuesta demasiado consultar— simplemente no aplica. Y la razón por la que se generaliza es que la misma estructura reaparece en todas partes: permisos que se cachean, sesiones que se copian, banderas de configuración remota que se guardan al arrancar, disponibilidad de funciones que se decide una vez y se congela. En cada uno de esos casos la pregunta que separa a un diseño robusto de uno frágil es la misma: estoy guardando una copia de algo que vive fuera de mi control. Si la respuesta es sí, ya sabes dónde estarán tus incidencias.
Transaction.currentEntitlements da los derechos vigentes sin red y sin servidor; los consumibles no salen ahí. Traduce identificadores de producto a capacidades en un solo lugar. La firma JWS prueba que el dato viene de Apple y no está alterado, nada más; comprueba siempre revocationDate. Verifica en servidor solo si el valor en juego lo justifica. AppStore.sync pide autenticación y por eso solo se invoca desde un botón explícito, que sigue siendo obligatorio aunque la restauración ya ocurra sola.
- Construye un tipo de capacidades independiente de los identificadores de producto y haz que ninguna vista mencione un identificador.
- Recalcula los derechos en el arranque, en cada evento de
Transaction.updatesy al volver del segundo plano; mide cuánto tarda de verdad. - Simula un reembolso en el entorno de pruebas y comprueba que el acceso desaparece gracias a
revocationDate. - Implementa el botón de restaurar con
AppStore.syncy distingue en la interfaz entre no había compras y no se pudo consultar. - Documenta qué ocurre en tu app cuando la cuenta de Apple y la cuenta propia del usuario no coinciden, y decide por escrito cuál de las dos manda.