SubHub.
Control de suscripciones
SubHub salió de Focus Mode sin backend nuevo: es un cliente vertical sobre la misma tabla, la misma cuenta y el mismo derecho premium. Diseñé y construí la app entera —producto, interfaz e integración— alrededor de una pregunta que la suite no contestaba de un vistazo: cuánto se te va al mes en cosas que se pagan solas.

Contexto
SubHub reúne las suscripciones y los gastos recurrentes en una pantalla y dice cuánto suman al mes. Arriba hay un solo número y debajo la lista por períodos: lo mensual, lo anual, lo archivado. No es una app de finanzas al uso — no se conecta a ningún banco, no lee movimientos y no pide un dato de pago. Las suscripciones las escribe quien la usa, que son cuatro.
Y no empezó de cero. Las recurrentes ya vivían en la base de Focus Mode, dentro de su módulo de finanzas; SubHub es un cliente vertical de esa misma tabla que solo enseña recurrentes. Misma base, misma cuenta —las dos apps entran con Apple, así que el usuario es el mismo— y el mismo derecho premium, que mantiene el backend de Focus Mode: quien ya paga allí entra pagando aquí sin hacer nada. Una app nueva en la tienda que no costó un backend nuevo.
Sin conexión bancaria, sin anuncios y sin perfiles publicitarios. Lo que la app no recoge no hay que protegerlo después.
El reto
Una lista de suscripciones la escribe cualquiera; lo que cuesta es que los números no mientan. Cuatro problemas: que cinco períodos distintos —semanal, mensual, trimestral, semestral, anual— se lean como una sola cifra comparable; que agrupar no invente dinero cuando lo que hay dentro se pasa del techo; que la lista se reconozca de un vistazo sin pedirle un logo a la red cada vez que se abre; y que el derecho premium sea del usuario y no de la app, para que quien ya paga Focus Mode no pague dos veces.
Un número, no doce
Cada fila sabe lo que cuesta al mes, y el total de arriba suma esas equivalencias, no los importes tal como se cobran. Lo semanal llega al mes multiplicando por 52 y dividiendo entre 12, no por cuatro: cuatro semanas por mes son once meses al año, y el error se acumula justo en lo que más veces se paga. Debajo, cada período tiene su sección y lo archivado se queda a la vista sin contar en el total.

/// Lo que cuesta al mes, venga como venga. El total de arriba suma esto,
/// no los importes tal como se cobran.
var monthlyEstimateMinor: Int {
let amount = Double(amount_minor)
let monthly: Double
switch frequency {
case .weekly: monthly = amount * 52.0 / 12.0 // no × 4: serían 11 meses
case .monthly: monthly = amount
case .quarterly: monthly = amount / 3.0
case .semiannual: monthly = amount / 6.0
case .annual: monthly = amount / 12.0
}
return Int(monthly.rounded())
}Un techo que no miente
Un grupo es una fila más con hijas colgando, y la jerarquía se queda en un nivel con dos reglas duras: dentro de un grupo todas comparten moneda y período. Sumar euros con dólares, o un anual con un mensual, da un número que no es de nadie. El techo lo pone quien crea el grupo, y el coste efectivo es el mayor de los dos: lo que cubre o lo que suman sus hijas. Así el total de arriba nunca sale por debajo de lo que de verdad se paga, aunque el presupuesto se haya quedado corto.

/// Un grupo cuesta lo que cubre, o lo que suman sus hijas si se pasan.
func effectiveMonthlyMinor(_ expense: RecurringExpense) -> Int {
let childrenMonthly = activeChildren(of: expense)
.reduce(0) { $0 + $1.monthlyEstimateMinor }
return max(expense.monthlyEstimateMinor, childrenMonthly)
}
/// Coste mensual POR MONEDA, de mayor a menor. Nunca uno solo:
/// sumar euros con dólares da un número que no es de nadie.
var monthlyTotals: [(currency: String, totalMinor: Int)] {
Dictionary(grouping: activeTopLevel, by: \.currency_code)
.map { (currency: $0.key,
totalMinor: $0.value.reduce(0) { $0 + effectiveMonthlyMinor($1) }) }
.sorted { $0.totalMinor > $1.totalMinor }
}El aviso de que las hijas se pasan trae al lado sus dos salidas —subir el techo una vez, o ponerlo en automático—, porque un aviso sin salida obliga a ir a buscarla. Lo que no hace es bloquear: cuánto se gasta lo decide quien usa la app.
Las marcas
Al escribir el nombre, la app sugiere marca, logo, color y —cuando la dirección es estable y no depende del país— el enlace para gestionar la cuenta, que es el que hace falta el día que uno quiere darse de baja. Debajo hay dos catálogos: uno curado a mano, de 71 marcas con sus alias, que es el que tolera «Netflix Premium» o «spotify familiar»; y encima las 3.613 de Simple Icons, empaquetadas en el binario para elegirlas a mano. Se cargan de un JSON y no de un literal de Swift —un array de ese tamaño hace sufrir al compilador sin ganar nada—, la primera vez que se abre el selector y no al arrancar, y el índice de búsqueda se normaliza una sola vez: normalizar 3.613 títulos en cada pulsación de tecla se nota.
El color de cada marca lo manda Simple Icons y no el hex escrito a mano en el catálogo curado: discrepaban en 18 de 63, así que la misma marca salía de un color cuando la sugería el nombre y de otro cuando se elegía en la rejilla. Los hex a mano solo sobreviven donde no hay logo. Y ese color no se queda en el icono: tiñe la rejilla de apariencia entera, así que el editor de una suscripción se ve del color de su marca.


Arquitectura y stack
SwiftUI y @Observable para todo, con el SDK de Supabase como única dependencia: auth con Apple, PostgREST y edge functions. El modelo es una lista plana de recurrentes y la jerarquía se deriva del identificador del padre al leerla, así que no hay dos estructuras que mantener en sincronía. La copia en disco se reescribe sola con cada cambio de la lista —desde el observador de la propiedad, no desde los seis sitios que la mutan— y vive en la carpeta de cachés del sistema, con un directorio por usuario: si el sistema la borra no se pierde nada, y en un dispositivo compartido cambiar de cuenta no enseña las suscripciones de la anterior.
La app abría vacía y con un indicador puesto hasta que contestaba el servidor, aunque las suscripciones fueran las mismas de ayer. Ahora el primer render ya lleva la lista, leída del disco de forma síncrona, y el indicador solo sale cuando de verdad no hay nada que enseñar.
El premium tampoco lo decide el cliente. La app se lo pregunta a una edge function y guarda la respuesta; si no hay red, la respuesta es «no lo sé» y se conserva lo último que dijo el servidor. Degradar eso a «no eres premium» sería ponerle un muro a quien está pagando la primera vez que abre la app en un avión.
/// El derecho es del usuario, no de la app: vive en la cuenta de Supabase
/// que SubHub y Focus Mode comparten, y lo mantienen las edge functions
/// que ya verifican las compras. Aquí solo hay que leerlo.
static var cachedIsPremium: Bool {
UserDefaults.standard.bool(forKey: cacheKey)
}
/// `nil` cuando no se ha podido preguntar; el llamante conserva lo que tenía.
@discardableResult
static func fetchStatus() async -> Bool? {
do {
let status: SubscriptionStatus = try await supabase.functions.invoke(
"apple-subscription-status"
)
UserDefaults.standard.set(status.isPremium, forKey: cacheKey)
return status.isPremium
} catch {
return nil // sin red no se degrada a "no premium"
}
}
Nada de la interfaz lleva un color escrito a mano. Los grises, los fondos agrupados y los separadores los pone el sistema, y el verde de marca tiene su propia versión para cada tema, así que la app cambia con el iPhone sin una sola condición en el código.


Entregables
- ✓Coste mensual equivalente para cinco períodos
- ✓Un total por moneda, sin mezclarlas
- ✓Grupos de un nivel, con techo y aviso al pasarse
- ✓Total del grupo automático o puesto a mano
- ✓Sugerencia de marca a partir del nombre (71 curadas)
- ✓3.613 logos empaquetados, buscables sin red
- ✓Enlace guardado para darse de baja
- ✓Archivar sin perder el historial
- ✓Selección múltiple: agrupar, sacar y archivar en masa
- ✓Español e inglés desde un solo catálogo de textos
- ✓Sign in with Apple y borrado de cuenta
- ✓Apertura instantánea desde la caché en disco
Cómo creció
- Jerarquía en la base
Padre e hija sobre la tabla que ya existía, con la validación en el servidor y el total del padre mantenido por trigger.
- Agrupar desde la app
Selección múltiple, crear el grupo con lo marcado, sacar sin borrar y elegir si el total va en automático o a mano.
- Las marcas
Primero la sugerencia por el nombre con 71 marcas curadas; después las 3.613 de Simple Icons, empaquetadas y buscables.
- El lenguaje visual de Focus Mode
Portado entero: la misma fila de acciones, los mismos rellenos y el mismo acento. Dos apps de la misma casa no se diseñan dos veces.
- Cuenta y sello
Login con Apple, perfil, borrado de cuenta que dice qué se pierde, y un «Powered by Focus Mode» discreto que acompaña sin competir.
- Español e inglés
Todo el texto al catálogo y ninguno en el código: los plurales los cuenta el sistema y las fechas siguen al idioma del iPhone.
- Abrir con datos
Caché en disco por usuario. El indicador de carga deja de ser lo primero que se ve.
Aprendizajes
Un producto nuevo no siempre necesita una plataforma nueva: la tabla, la cuenta y el cobro ya estaban, y lo que faltaba era una app que contestara una sola pregunta. En una app que habla de dinero, la honestidad de los números es la funcionalidad — el grupo que cuesta el mayor de dos cifras, el total que no mezcla monedas y el aviso que no bloquea salen todos de la misma regla: la app informa, no decide. Y lo que se puede empaquetar se empaqueta: 3.613 logos dentro del binario hacen que la lista se lea de un vistazo el primer día, sin red, sin permisos y sin una petición por fila.





