Skip to main content
Traducción. Autoritativo: fs-loy-0001-programs-and-currencies.md.

Contexto

Un programa es el contenedor de todo lo demás en el módulo: reglas, monedas, niveles y catálogo de recompensas cuelgan de él. Nada más en loyalty se puede construir antes de que exista. La decisión que da forma a esta feature es ADR-021: multi-programa es una capacidad de primera clase en F1, superando la postura anterior de dejarlo solo en el esquema. Un holding con dos marcas, una empresa con un programa B2C junto a uno B2B para distribuidores, o una campaña estacional que no debe contaminar el principal son situaciones corrientes, y un cliente que pregunta en la primera reunión comercial no debería escuchar “después”. La cantidad de programas es además una dimensión honesta de plan: el plan base incluye uno, y necesitar un segundo es una razón concreta para subir de tier. El costo de esquema ya estaba pagado — program_id iba a estar en todas las tablas desde la primera migración de cualquier forma. Lo que ADR-021 recupera es superficie de consola y de API, que solo se encarece a medida que se acumulan pantallas. La segunda decisión es la moneda dual de ADR-011: los puntos canjeables y los de estatus son monedas separadas y jamás se mezclan. Es el modelo aerolínea —millas que gastas versus millas que te califican para un nivel— y confundirlas es la forma más común de que un programa se vuelva imposible de razonar.

Alcance (normativo)

  • loyalty.programs: una fila por programa, con tenant_id, nombre puesto por el tenant e is_default.
  • CRUD completo: crear, renombrar, activar y desactivar, sujeto al entitlement del tenant.
  • Creación automática del programa por defecto al aprovisionar un tenant.
  • loyalty.point_currencies: al menos dos por programa, una de cada tipo.
  • loyalty.point_currency_kinds: catálogo paramétrico, semillas is_system redeemable y status.
  • program_id presente y NOT NULL en toda tabla de loyalty creada por este o cualquier FS posterior.
  • Helper de resolución: dado un contexto de tenant sin programa explícito, resolver el por defecto.
  • Verificación de entitlement sobre la cantidad de programas, bloqueando en vez de facturar overage.
  • Endpoints de gestión para programas y monedas.

Fuera de alcance (normativo)

  • Eliminar un programa. Los programas se desactivan, nunca se borran: sus filas del libro, niveles y canjes deben seguir resolviendo para siempre.
  • Agregación entre programas en reportería. Cada programa reporta sobre sí mismo (ADR-021). Agregar abre preguntas —de quién son los puntos, en qué moneda, con qué escalera de niveles— que merecen su propia decisión.
  • Conversión de monedas entre programas o entre monedas de puntos. No está planificado.
  • Acreditar o gastar cualquier cosa — eso es FS-LOY-0002 en adelante.
  • Definiciones de niveles, que referencian monedas pero pertenecen a FS-LOY-0010.

Comportamiento (normativo)

  1. El aprovisionamiento de un tenant crea exactamente un programa con is_default = true y sus dos monedas is_system. Ocurre en la misma transacción que la creación del tenant: un tenant sin programa es un estado inválido, no un estado a reparar después.
  2. Un tenant tiene exactamente un programa con is_default = true, garantizado por un índice único parcial. Esto se mantiene incluso cuando se habilite multi-programa.
  3. Toda escritura en cualquier tabla de loyalty resuelve program_id antes de tocar la base. No hay fallback de “null significa por defecto” en la capa de datos: la resolución ocurre una vez, en el borde. Un request que omite el programa resuelve el por defecto del tenant, de modo que una integración de un solo programa nunca tiene que saber que el concepto existe.
  4. point_currency_kinds no es extensible por tenant (DEC-B4): el tipo gobierna lógica del sistema, así que un tipo nuevo es un cambio de código y de spec, nunca un insert. La API rechaza tipos desconocidos con UNSUPPORTED_CODE.
  5. Las monedas de un programa no se pueden eliminar una vez que alguna transacción del libro las referencia. Se desactivan (is_active = false), nunca se borran.
  6. Crear un programa más allá del entitlement del tenant se bloquea con un error tipado que nombra el límite, nunca se factura como overage. Una capacidad que el tenant elige adoptar lleva tope duro; el precio por overage es para el consumo que genera el comportamiento de sus clientes (ADR-021).
  7. Las políticas de expiración y de ventana de devolución son propias de cada programa. Dos programas de un mismo tenant pueden correr reglas completamente distintas — para eso son dos.
  8. PROHIBIDO: una tabla de loyalty sin program_id · una sola moneda sirviendo a la vez como canjeable y de estatus · puntos, niveles o recompensas cruzando la frontera de un programa.

Datos (normativo)

Ninguna de las dos se particiona — son pequeñas y regulares (standards/data.md §4). point_currency_kinds se cachea y nunca debe unirse en un camino caliente; sus filas is_system generan el union type TS/Zod que consume packages/core.

API (normativo)

programs/current se mantiene como atajo al por defecto, para que una integración con un solo programa nunca maneje un id. program_id es un parámetro normal y documentado en todo lo demás.

Eventos (normativo)

loyalty.program.created cuando un tenant crea uno más allá del por defecto. La creación del programa por defecto no emite nada — es parte del aprovisionamiento del tenant y queda cubierta por la auditoría de ese flujo.

Criterios de aceptación (normativo)

  1. Aprovisionar un tenant nuevo crea exactamente un programa y exactamente dos monedas, en la misma transacción. Revertir la creación del tenant no deja programas huérfanos.
  2. Intentar insertar un segundo programa con is_default = true para el mismo tenant falla contra el índice único parcial — probado por un test que espera la violación de constraint.
  3. Test de denegación RLS: una consulta bajo el contexto del tenant A devuelve cero filas del programa del tenant B.
  4. Las semillas is_system generan un union TS que contiene exactamente 'redeemable' | 'status', y packages/core compila contra él.
  5. Enviar un tipo de moneda desconocido a cualquier endpoint devuelve UNSUPPORTED_CODE, no un 500 ni un insert silencioso.
  6. Crear un programa más allá del entitlement se rechaza con un error tipado que nombra el límite, y no se escribe ninguna fila.
  7. Un request que omite program_id resuelve el por defecto; el mismo request con id explícito direcciona ese programa. Ambos probados sobre el conjunto de endpoints del módulo.
  8. Dos programas de un mismo tenant mantienen saldos completamente independientes: acumular en uno deja el saldo del otro sin cambios.
  9. Negativo: ninguna operación mueve puntos, un nivel ni una recompensa entre programas.

Ejecución

Un solo slice, arquetipo comando síncrono. Se entrega como parte de TS-001 (migración fundacional): definiciones Drizzle en database/postgres/src/schema/loyalty/, semillas en database/postgres/seeds/, el hook de aprovisionamiento junto a la creación del tenant, y los dos endpoints de lectura en backend/api. Sin worker, sin cola, sin cron.

Preguntas abiertas

Ninguna pendiente. Toda pregunta que llevaba este spec quedó respondida en el registro consolidado (../../../../design/open-questions-v1.md, v1.1) y se incorporó a las secciones normativas de arriba. Abierta por la misma decisión:

Changelog

Registro de entrega

Aún no implementado.