Define subscription plan capabilities

codex/redisenio-dominio-musical
dev 4 weeks ago
parent b6b35c8ed7
commit 3518da8b00

@ -69,13 +69,17 @@ entregar varios archivos mediante relaciones específicas cuando se publiquen.
## Suscripciones
`plan_suscripcion` define código, nombre, nivel, precio, periodicidad,
prestaciones y referencia de precio externa. `suscripcion` registra el periodo
`plan_suscripcion` define código, nombre, nivel, precio y periodicidad. Sus
capacidades operativas —publicidad, calidad de escucha, cupo y calidad de
descargas, y acceso a versiones— son columnas estructuradas; `prestaciones`
queda para ventajas editoriales adicionales y no para tomar decisiones de
acceso. También conserva la referencia de precio externa. `suscripcion` registra el periodo
contratado por cada cuenta y apunta opcionalmente al plan. La relación es
opcional para conservar las concesiones manuales históricas.
La pasarela ya entiende líneas recurrentes, aunque la contratación pública de
planes se incorporará cuando se definan los niveles y sus prestaciones.
Los niveles públicos iniciales son Follower, Friend y VIP. La contratación y la
aplicación efectiva de publicidad, calidades y cupos se conectarán sobre estas
capacidades, sin comparar nombres ni textos comerciales.
## Extensión prevista

@ -0,0 +1,36 @@
ALTER TABLE "plan_suscripcion" ADD COLUMN "incluye_publicidad" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD COLUMN "calidad_escucha" text DEFAULT 'media' NOT NULL;--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD COLUMN "descargas_incluidas" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD COLUMN "calidad_descarga" text;--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD COLUMN "acceso_versiones" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD CONSTRAINT "plan_suscripcion_calidad_escucha_conocida" CHECK ("plan_suscripcion"."calidad_escucha" in ('media', 'alta'));--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD CONSTRAINT "plan_suscripcion_calidad_descarga_conocida" CHECK ("plan_suscripcion"."calidad_descarga" is null or "plan_suscripcion"."calidad_descarga" in ('media', 'alta'));--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD CONSTRAINT "plan_suscripcion_descargas_no_negativas" CHECK ("plan_suscripcion"."descargas_incluidas" >= 0);--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD CONSTRAINT "plan_suscripcion_calidad_solo_con_descargas" CHECK ("plan_suscripcion"."descargas_incluidas" > 0 or "plan_suscripcion"."calidad_descarga" is null);
--> statement-breakpoint
INSERT INTO "plan_suscripcion" (
"id", "codigo", "nombre", "nivel", "precio_centimos", "moneda", "periodicidad", "intervalos",
"incluye_publicidad", "calidad_escucha", "descargas_incluidas", "calidad_descarga",
"acceso_versiones", "prestaciones", "estado"
) VALUES
('09224788-1924-4401-aea2-e2494ebc3802', 'follower', 'Follower', 1, 0, 'eur', 'ano', 1,
true, 'media', 0, null, false, '[]'::jsonb, 'disponible'),
('9e6aa2c3-0474-4712-8153-225f6a05af5a', 'friend', 'Friend', 2, 550, 'eur', 'ano', 1,
false, 'alta', 30, 'media', false, '[]'::jsonb, 'disponible'),
('2570efc6-a739-40cf-88bd-b006e9654dd0', 'vip', 'VIP', 3, 990, 'eur', 'ano', 1,
false, 'alta', 100, null, true, '[]'::jsonb, 'disponible')
ON CONFLICT ("codigo") DO UPDATE SET
"nombre" = EXCLUDED."nombre",
"nivel" = EXCLUDED."nivel",
"precio_centimos" = EXCLUDED."precio_centimos",
"moneda" = EXCLUDED."moneda",
"periodicidad" = EXCLUDED."periodicidad",
"intervalos" = EXCLUDED."intervalos",
"incluye_publicidad" = EXCLUDED."incluye_publicidad",
"calidad_escucha" = EXCLUDED."calidad_escucha",
"descargas_incluidas" = EXCLUDED."descargas_incluidas",
"calidad_descarga" = EXCLUDED."calidad_descarga",
"acceso_versiones" = EXCLUDED."acceso_versiones",
"prestaciones" = EXCLUDED."prestaciones",
"estado" = EXCLUDED."estado",
"actualizado_en" = now();

File diff suppressed because it is too large Load Diff

@ -225,6 +225,13 @@
"when": 1789249772950,
"tag": "0031_comercio_extensible",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1789255031254,
"tag": "0032_bent_hydra",
"breakpoints": true
}
]
}

@ -7,6 +7,13 @@ test.describe('Suscripciones', () => {
await page.goto('/suscripciones');
await expect(page.getByRole('heading', { level: 1, name: 'Suscripciones' })).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'Follower' })).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'Friend' })).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'VIP' })).toBeVisible();
await expect(page.getByText('Escucha con publicidad')).toBeVisible();
await expect(page.getByText('30 descargas en calidad media')).toBeVisible();
await expect(page.getByText('100 descargas', { exact: true })).toBeVisible();
await expect(page.getByText('Acceso a versiones')).toBeVisible();
await expect(
page.getByRole('heading', { level: 2, name: 'Suscripción y compra no son lo mismo' })
).toBeVisible();

@ -254,6 +254,18 @@ await rechaza(
(id, codigo, nombre, nivel, precio_centimos, periodicidad, intervalos)
values ('ps2', 'zz-roto', 'Roto', 2, 500, 'mes', 0)`
);
await rechaza(
'un plan no admite un número negativo de descargas',
`insert into plan_suscripcion
(id, codigo, nombre, nivel, precio_centimos, periodicidad, descargas_incluidas)
values ('ps3', 'zz-descargas', 'Roto', 2, 500, 'mes', -1)`
);
await rechaza(
'un plan sin descargas no declara calidad de descarga',
`insert into plan_suscripcion
(id, codigo, nombre, nivel, precio_centimos, periodicidad, calidad_descarga)
values ('ps4', 'zz-calidad', 'Roto', 2, 500, 'mes', 'media')`
);
await permite(
'un libro disponible se puede añadir al carrito con cantidad',
`insert into producto

@ -7,6 +7,7 @@
import {
bigint,
boolean,
check,
index,
integer,
@ -18,6 +19,7 @@ import {
import { sql } from 'drizzle-orm';
import { ahora, id, instante } from './comun';
import { ESTADOS_PLAN_SUSCRIPCION, PERIODICIDADES } from '../../../comercio';
import { CALIDADES_AUDIO_SUSCRIPCION } from '../../../suscripciones';
/* -------------------------------------------------------------------------
* Cuentas
@ -76,6 +78,13 @@ export const planSuscripcion = pgTable(
moneda: text('moneda').notNull().default('eur'),
periodicidad: text('periodicidad', { enum: PERIODICIDADES }).notNull(),
intervalos: integer('intervalos').notNull().default(1),
incluyePublicidad: boolean('incluye_publicidad').notNull().default(false),
calidadEscucha: text('calidad_escucha', { enum: CALIDADES_AUDIO_SUSCRIPCION })
.notNull()
.default('media'),
descargasIncluidas: integer('descargas_incluidas').notNull().default(0),
calidadDescarga: text('calidad_descarga', { enum: CALIDADES_AUDIO_SUSCRIPCION }),
accesoVersiones: boolean('acceso_versiones').notNull().default(false),
referenciaPrecioExterna: text('referencia_precio_externa').unique(),
prestaciones: jsonb('prestaciones').notNull().default([]).$type<string[]>(),
estado: text('estado', { enum: ESTADOS_PLAN_SUSCRIPCION }).notNull().default('borrador'),
@ -95,6 +104,19 @@ export const planSuscripcion = pgTable(
check('plan_suscripcion_nivel_positivo', sql`${t.nivel} > 0`),
check('plan_suscripcion_precio_no_negativo', sql`${t.precioCentimos} >= 0`),
check('plan_suscripcion_intervalos_positivos', sql`${t.intervalos} > 0`),
check(
'plan_suscripcion_calidad_escucha_conocida',
sql`${t.calidadEscucha} in ('media', 'alta')`
),
check(
'plan_suscripcion_calidad_descarga_conocida',
sql`${t.calidadDescarga} is null or ${t.calidadDescarga} in ('media', 'alta')`
),
check('plan_suscripcion_descargas_no_negativas', sql`${t.descargasIncluidas} >= 0`),
check(
'plan_suscripcion_calidad_solo_con_descargas',
sql`${t.descargasIncluidas} > 0 or ${t.calidadDescarga} is null`
),
check('plan_suscripcion_moneda_iso', sql`${t.moneda} ~ '^[a-z]{3}$'`),
check('plan_suscripcion_codigo_no_vacio', sql`btrim(${t.codigo}) <> ''`),
check('plan_suscripcion_nombre_no_vacio', sql`btrim(${t.nombre}) <> ''`),

@ -239,16 +239,24 @@ describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => {
it('publica únicamente los planes disponibles y no expone la referencia de Stripe', async () => {
await cliente.query(
`INSERT INTO plan_suscripcion
(id,codigo,nombre,nivel,precio_centimos,periodicidad,prestaciones,estado,referencia_precio_externa)
(id,codigo,nombre,nivel,precio_centimos,periodicidad,incluye_publicidad,calidad_escucha,
descargas_incluidas,calidad_descarga,acceso_versiones,prestaciones,estado,referencia_precio_externa)
VALUES
('sp1','apoyo','Apoyo',1,500,'mes','["Catálogo exclusivo"]','disponible','price_privado'),
('sp2','borrador','Borrador',2,900,'mes','[]','borrador',null)`
('sp1','apoyo','Apoyo',1,500,'mes',false,'alta',30,'media',true,
'["Catálogo exclusivo"]','disponible','price_privado'),
('sp2','borrador','Borrador',2,900,'mes',false,'media',0,null,false,
'[]','borrador',null)`
);
const planes = await planesDeSuscripcionPublicos();
expect(planes).toHaveLength(1);
expect(planes[0]).toMatchObject({
codigo: 'apoyo',
nombre: 'Apoyo',
incluyePublicidad: false,
calidadEscucha: 'alta',
descargasIncluidas: 30,
calidadDescarga: 'media',
accesoVersiones: true,
prestaciones: ['Catálogo exclusivo']
});
expect(planes[0]).not.toHaveProperty('referenciaPrecioExterna');

@ -20,6 +20,11 @@ export async function planesDeSuscripcionPublicos() {
moneda: planSuscripcion.moneda,
periodicidad: planSuscripcion.periodicidad,
intervalos: planSuscripcion.intervalos,
incluyePublicidad: planSuscripcion.incluyePublicidad,
calidadEscucha: planSuscripcion.calidadEscucha,
descargasIncluidas: planSuscripcion.descargasIncluidas,
calidadDescarga: planSuscripcion.calidadDescarga,
accesoVersiones: planSuscripcion.accesoVersiones,
prestaciones: planSuscripcion.prestaciones
})
.from(planSuscripcion)

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { periodoDeSuscripcion } from './suscripciones';
import { periodoDeSuscripcion, prestacionesDePlan } from './suscripciones';
describe('periodoDeSuscripcion', () => {
it('describe una renovación simple', () => {
@ -12,3 +12,37 @@ describe('periodoDeSuscripcion', () => {
expect(periodoDeSuscripcion('semana', 2)).toBe('cada 2 semanas');
});
});
describe('prestacionesDePlan', () => {
it('describe las capacidades estructuradas sin depender del código del plan', () => {
expect(
prestacionesDePlan({
incluyePublicidad: false,
calidadEscucha: 'alta',
descargasIncluidas: 30,
calidadDescarga: 'media',
accesoVersiones: false,
prestaciones: []
})
).toEqual(['Calidad de escucha alta', 'Sin publicidad', '30 descargas en calidad media']);
});
it('añade acceso a versiones y conserva prestaciones editoriales no duplicadas', () => {
expect(
prestacionesDePlan({
incluyePublicidad: false,
calidadEscucha: 'alta',
descargasIncluidas: 100,
calidadDescarga: null,
accesoVersiones: true,
prestaciones: ['Acceso anticipado', 'Acceso a versiones', ' ']
})
).toEqual([
'Calidad de escucha alta',
'Sin publicidad',
'100 descargas',
'Acceso a versiones',
'Acceso anticipado'
]);
});
});

@ -1,5 +1,17 @@
import type { Periodicidad } from './comercio';
export const CALIDADES_AUDIO_SUSCRIPCION = ['media', 'alta'] as const;
export type CalidadAudioSuscripcion = (typeof CALIDADES_AUDIO_SUSCRIPCION)[number];
export interface CapacidadesPlanSuscripcion {
incluyePublicidad: boolean;
calidadEscucha: CalidadAudioSuscripcion;
descargasIncluidas: number;
calidadDescarga: CalidadAudioSuscripcion | null;
accesoVersiones: boolean;
prestaciones: readonly string[];
}
const PERIODOS: Record<Periodicidad, { singular: string; plural: string }> = {
dia: { singular: 'día', plural: 'días' },
semana: { singular: 'semana', plural: 'semanas' },
@ -12,3 +24,28 @@ export function periodoDeSuscripcion(periodicidad: Periodicidad, intervalos: num
const periodo = PERIODOS[periodicidad];
return intervalos === 1 ? `al ${periodo.singular}` : `cada ${intervalos} ${periodo.plural}`;
}
/** Convierte las capacidades aplicables por el sistema en textos para la ficha pública. */
export function prestacionesDePlan(plan: CapacidadesPlanSuscripcion): string[] {
const prestaciones = [
`Calidad de escucha ${plan.calidadEscucha}`,
plan.incluyePublicidad ? 'Escucha con publicidad' : 'Sin publicidad'
];
if (plan.descargasIncluidas > 0) {
prestaciones.push(
`${plan.descargasIncluidas} descargas${
plan.calidadDescarga ? ` en calidad ${plan.calidadDescarga}` : ''
}`
);
}
if (plan.accesoVersiones) prestaciones.push('Acceso a versiones');
for (const prestacion of plan.prestaciones) {
const texto = prestacion.trim();
if (texto && !prestaciones.includes(texto)) prestaciones.push(texto);
}
return prestaciones;
}

@ -2,7 +2,7 @@
import { resolve } from '$app/paths';
import { pedirContacto } from '$lib/contacto-modal.svelte';
import Meta from '$lib/components/Meta.svelte';
import { periodoDeSuscripcion } from '$lib/suscripciones';
import { periodoDeSuscripcion, prestacionesDePlan } from '$lib/suscripciones';
import { formatearPrecio } from '$lib/tienda';
let { data } = $props();
@ -32,6 +32,7 @@
<ul class="rejilla">
{#each data.planes as plan (plan.id)}
{@const prestaciones = prestacionesDePlan(plan)}
<li>
<article class="plan">
<div class="cabecera-plan">
@ -45,11 +46,12 @@
<p class="precio">
<strong>{formatearPrecio(plan.precioCentimos, 'es-ES', plan.moneda)}</strong>
<span>{periodoDeSuscripcion(plan.periodicidad, plan.intervalos)}</span>
{#if plan.precioCentimos === 0}<span class="gratis">Gratis</span>{/if}
</p>
{#if plan.prestaciones.length > 0}
{#if prestaciones.length > 0}
<ul class="prestaciones" aria-label="Incluye">
{#each plan.prestaciones as prestacion (prestacion)}
{#each prestaciones as prestacion (prestacion)}
<li>{prestacion}</li>
{/each}
</ul>
@ -204,6 +206,16 @@
font-size: var(--fs-sm);
}
.precio .gratis {
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, var(--c-accent) 14%, transparent);
color: var(--c-accent);
font-size: var(--fs-xs);
font-weight: 700;
text-transform: uppercase;
}
.prestaciones {
display: grid;
gap: var(--sp-2);

Loading…
Cancel
Save

Powered by TurnKey Linux.