Generalize cart packs and commerce model

codex/redisenio-dominio-musical
dev 4 weeks ago
parent 3a75eb768d
commit f1527efd25

@ -0,0 +1,93 @@
# Arquitectura de comercio extensible
Fecha: 12 de septiembre de 2026
## Decisión principal
El carrito ya no representa directamente lo que se cobra. Mantiene las cosas
que ha elegido la persona y, al comenzar el pago, un resolutor las convierte en
líneas de cobro inmutables.
Esta separación resuelve dos necesidades distintas:
- una canción es un derecho de descarga individual;
- el precio de las canciones se calcula por packs de hasta diez;
- libros, partituras, MIDI, stems y servicios tienen cantidad y precio propios;
- una suscripción tiene recurrencia, plan y ciclo de vida, no es un artículo
puntual camuflado en el carrito.
## Regla de packs
El número de packs se calcula con `ceil(canciones / 10)`. Por tanto:
| Canciones elegidas | Packs cobrados | Huecos incluidos |
| -----------------: | -------------: | ---------------: |
| 0 | 0 | 0 |
| 1 | 1 | 9 |
| 10 | 1 | 0 |
| 11 | 2 | 9 |
| 20 | 2 | 0 |
| 21 | 3 | 9 |
No existe ya el bloqueo por pack incompleto. El carrito informa de cuántas
canciones más caben sin aumentar el total, pero permite pagar cualquier
cantidad positiva.
## Capas del modelo
### Selección
- `carrito_item` guarda las canciones concretas.
- `carrito_producto` guarda los demás productos y sus cantidades.
- `producto` contiene las ofertas comerciales disponibles: libro, partitura,
MIDI, stems o servicio.
Los precios siempre se vuelven a leer en el servidor. Ninguna acción acepta el
precio enviado por el navegador.
### Resolución y cobro
`resolverCompra` transforma la selección en `LineaDeCobro[]`. Diecisiete
canciones producen una línea de dos packs, no diecisiete líneas. Cada producto
adicional produce su propia línea.
Antes de contactar con Stripe se comprueban identidad, cantidades, importes,
moneda, modalidad y recurrencia. Una misma sesión no puede mezclar pago único y
suscripción: se resuelven en operaciones separadas porque tienen ciclos de
cancelación, renovación y fiscalidad distintos.
### Pedido y derechos
- `pedido_linea` congela nombre, tipo, cantidad, precio e importe de cada línea.
Una factura histórica no cambia al editar el catálogo.
- `pedido_item` conserva qué canciones incluía el pedido.
- `compra` es el derecho rápido de descarga de cada canción.
Las líneas facturadas y los derechos entregados no se deben fusionar. Un pack
puede entregar entre una y diez canciones; un libro o unas partituras podrán
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
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.
## Extensión prevista
Para publicar una nueva clase de producto no será necesario crear otro sistema
de pedidos. Se añadirá su ficha editorial, sus archivos entregables y una acción
que use `anadirProductoAlCarrito`. El mismo resolutor, la intención de pago y
`pedido_linea` ya aceptan esos artículos.
Antes de activar cada tipo quedan por concretar:
1. archivos o prestación que entrega;
2. política de acceso y caducidad;
3. impuestos y texto legal;
4. reembolsos y revocación del derecho;
5. interfaz de administración y publicación.

@ -425,3 +425,14 @@ los nuevos read models, los intérpretes son entidades administrables y
enlazables, el streaming completo se genera desde masters privados, las compras
entregan representaciones de alta calidad, no quedan lecturas productivas del
modelo legado y todos los checks, integración y E2E pasan en CI.
## Ampliación: comercio extensible
La revisión del 12 de septiembre de 2026 detectó que el cierre por packs
exactos impedía comprar libremente. La implementación asociada sustituye esa
regla por packs de hasta diez, calculados automáticamente, y separa selección,
líneas facturadas y derechos entregados.
También queda preparada la base común para libros, partituras, MIDI, stems,
servicios y niveles de suscripción. La decisión y los límites de esta fase se
documentan en `docs/arquitectura-comercio-2026-09-12.md`.

@ -0,0 +1,90 @@
CREATE TABLE "plan_suscripcion" (
"id" text PRIMARY KEY NOT NULL,
"codigo" text NOT NULL,
"nombre" text NOT NULL,
"descripcion" text,
"nivel" integer NOT NULL,
"precio_centimos" integer NOT NULL,
"moneda" text DEFAULT 'eur' NOT NULL,
"periodicidad" text NOT NULL,
"intervalos" integer DEFAULT 1 NOT NULL,
"referencia_precio_externa" text,
"prestaciones" jsonb DEFAULT '[]'::jsonb NOT NULL,
"estado" text DEFAULT 'borrador' NOT NULL,
"creado_en" timestamp with time zone DEFAULT now() NOT NULL,
"actualizado_en" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "plan_suscripcion_codigo_unique" UNIQUE("codigo"),
CONSTRAINT "plan_suscripcion_referencia_precio_externa_unique" UNIQUE("referencia_precio_externa"),
CONSTRAINT "plan_suscripcion_periodicidad_conocida" CHECK ("plan_suscripcion"."periodicidad" in ('dia', 'semana', 'mes', 'ano')),
CONSTRAINT "plan_suscripcion_estado_conocido" CHECK ("plan_suscripcion"."estado" in ('borrador', 'disponible', 'retirado')),
CONSTRAINT "plan_suscripcion_nivel_positivo" CHECK ("plan_suscripcion"."nivel" > 0),
CONSTRAINT "plan_suscripcion_precio_no_negativo" CHECK ("plan_suscripcion"."precio_centimos" >= 0),
CONSTRAINT "plan_suscripcion_intervalos_positivos" CHECK ("plan_suscripcion"."intervalos" > 0),
CONSTRAINT "plan_suscripcion_moneda_iso" CHECK ("plan_suscripcion"."moneda" ~ '^[a-z]{3}$'),
CONSTRAINT "plan_suscripcion_codigo_no_vacio" CHECK (btrim("plan_suscripcion"."codigo") <> ''),
CONSTRAINT "plan_suscripcion_nombre_no_vacio" CHECK (btrim("plan_suscripcion"."nombre") <> ''),
CONSTRAINT "plan_suscripcion_prestaciones_array" CHECK (jsonb_typeof("plan_suscripcion"."prestaciones") = 'array')
);
--> statement-breakpoint
CREATE TABLE "carrito_producto" (
"carrito_id" text NOT NULL,
"producto_id" text NOT NULL,
"cantidad" integer DEFAULT 1 NOT NULL,
"anadido_en" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "carrito_producto_carrito_id_producto_id_pk" PRIMARY KEY("carrito_id","producto_id"),
CONSTRAINT "carrito_producto_cantidad_positiva" CHECK ("carrito_producto"."cantidad" > 0)
);
--> statement-breakpoint
CREATE TABLE "pedido_linea" (
"pedido_id" text NOT NULL,
"codigo" text NOT NULL,
"producto_id" text,
"tipo" text NOT NULL,
"nombre" text NOT NULL,
"descripcion" text,
"cantidad" integer NOT NULL,
"precio_unitario_centimos" integer NOT NULL,
"total_centimos" integer NOT NULL,
"moneda" text NOT NULL,
"modalidad" text NOT NULL,
"datos" jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT "pedido_linea_pedido_id_codigo_pk" PRIMARY KEY("pedido_id","codigo"),
CONSTRAINT "pedido_linea_cantidad_positiva" CHECK ("pedido_linea"."cantidad" > 0),
CONSTRAINT "pedido_linea_tipo_conocido" CHECK ("pedido_linea"."tipo" in ('pack_canciones', 'libro', 'partitura', 'midi', 'stems', 'servicio', 'suscripcion')),
CONSTRAINT "pedido_linea_modalidad_conocida" CHECK ("pedido_linea"."modalidad" in ('pago_unico', 'suscripcion')),
CONSTRAINT "pedido_linea_importes_validos" CHECK ("pedido_linea"."precio_unitario_centimos" >= 0 and "pedido_linea"."total_centimos" = "pedido_linea"."precio_unitario_centimos" * "pedido_linea"."cantidad"),
CONSTRAINT "pedido_linea_moneda_iso" CHECK ("pedido_linea"."moneda" ~ '^[a-z]{3}$'),
CONSTRAINT "pedido_linea_datos_objeto" CHECK (jsonb_typeof("pedido_linea"."datos") = 'object')
);
--> statement-breakpoint
CREATE TABLE "producto" (
"id" text PRIMARY KEY NOT NULL,
"codigo" text NOT NULL,
"tipo" text NOT NULL,
"nombre" text NOT NULL,
"descripcion" text,
"estado" text DEFAULT 'borrador' NOT NULL,
"precio_centimos" integer NOT NULL,
"moneda" text DEFAULT 'eur' NOT NULL,
"metadatos" jsonb DEFAULT '{}'::jsonb NOT NULL,
"creado_en" timestamp with time zone DEFAULT now() NOT NULL,
"actualizado_en" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "producto_codigo_unique" UNIQUE("codigo"),
CONSTRAINT "producto_tipo_conocido" CHECK ("producto"."tipo" in ('libro', 'partitura', 'midi', 'stems', 'servicio')),
CONSTRAINT "producto_estado_conocido" CHECK ("producto"."estado" in ('borrador', 'disponible', 'retirado')),
CONSTRAINT "producto_precio_no_negativo" CHECK ("producto"."precio_centimos" >= 0),
CONSTRAINT "producto_moneda_iso" CHECK ("producto"."moneda" ~ '^[a-z]{3}$'),
CONSTRAINT "producto_codigo_no_vacio" CHECK (btrim("producto"."codigo") <> ''),
CONSTRAINT "producto_nombre_no_vacio" CHECK (btrim("producto"."nombre") <> ''),
CONSTRAINT "producto_metadatos_objeto" CHECK (jsonb_typeof("producto"."metadatos") = 'object')
);
--> statement-breakpoint
ALTER TABLE "suscripcion" ADD COLUMN "plan_id" text;--> statement-breakpoint
ALTER TABLE "carrito_producto" ADD CONSTRAINT "carrito_producto_carrito_id_carrito_id_fk" FOREIGN KEY ("carrito_id") REFERENCES "public"."carrito"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "carrito_producto" ADD CONSTRAINT "carrito_producto_producto_id_producto_id_fk" FOREIGN KEY ("producto_id") REFERENCES "public"."producto"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pedido_linea" ADD CONSTRAINT "pedido_linea_pedido_id_pedido_id_fk" FOREIGN KEY ("pedido_id") REFERENCES "public"."pedido"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pedido_linea" ADD CONSTRAINT "pedido_linea_producto_id_producto_id_fk" FOREIGN KEY ("producto_id") REFERENCES "public"."producto"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "plan_suscripcion_estado_nivel" ON "plan_suscripcion" USING btree ("estado","nivel");--> statement-breakpoint
CREATE INDEX "pedido_linea_tipo" ON "pedido_linea" USING btree ("tipo");--> statement-breakpoint
CREATE INDEX "producto_tipo_estado" ON "producto" USING btree ("tipo","estado");--> statement-breakpoint
ALTER TABLE "suscripcion" ADD CONSTRAINT "suscripcion_plan_id_plan_suscripcion_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plan_suscripcion"("id") ON DELETE restrict ON UPDATE no action;

File diff suppressed because it is too large Load Diff

@ -218,6 +218,13 @@
"when": 1789228652652,
"tag": "0030_orange_legion",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1789249772950,
"tag": "0031_comercio_extensible",
"breakpoints": true
}
]
}

@ -49,14 +49,14 @@ test.describe('Carrito', () => {
await expect(carrito).toContainText('1');
});
test('el pago se bloquea mientras el pack está a medias', async ({ page }) => {
test('una selección parcial se resuelve en un pack y puede continuar', async ({ page }) => {
await page.goto('/albumes/cupido-sin-flechas');
await anadir(page, 'Prometiste');
await page.goto('/carrito');
await expect(
page.getByText(`Faltan ${CANCIONES_POR_PACK - 1} canciones para completar el pack.`).first()
).toBeVisible();
await expect(page.getByText(/1 canción se cobrará como 1 pack/).first()).toBeVisible();
await expect(page.getByText(/Puedes añadir 9 más sin cambiar el precio/).first()).toBeVisible();
await expect(page.getByText('1,50 €').first()).toBeVisible();
// Sin sesión el botón de pagar ni siquiera aparece: primero hay que entrar.
await expect(page.getByRole('link', { name: 'Entrar para comprar' })).toBeVisible();
});
@ -77,12 +77,15 @@ test.describe('Carrito', () => {
await expect(page).toHaveURL(/\/carrito$/);
});
test('propone canciones para cerrar el pack', async ({ page }) => {
test('propone canciones para aprovechar el precio ya incluido', async ({ page }) => {
await page.goto('/albumes/cupido-sin-flechas');
await anadir(page, 'Prometiste');
await page.goto('/carrito');
const sugerencias = page.getByRole('heading', { level: 2, name: 'Completa el pack' });
const sugerencias = page.getByRole('heading', {
level: 2,
name: 'Aprovecha el espacio incluido'
});
await expect(sugerencias).toBeVisible();
});
@ -90,10 +93,14 @@ test.describe('Carrito', () => {
await llenarUnPack(page);
await page.goto('/carrito');
await expect(page.getByText(`1 pack de ${CANCIONES_POR_PACK} canciones.`)).toBeVisible();
await expect(
page.getByText(`${CANCIONES_POR_PACK} canciones se cobrarán como 1 pack.`)
).toBeVisible();
await expect(page.getByText('1,50 €').first()).toBeVisible();
// Al estar completo, ya no hay nada que sugerir.
await expect(page.getByRole('heading', { level: 2, name: 'Completa el pack' })).toHaveCount(0);
await expect(
page.getByRole('heading', { level: 2, name: 'Aprovecha el espacio incluido' })
).toHaveCount(0);
});
test('se pueden quitar canciones', async ({ page }) => {
@ -102,7 +109,7 @@ test.describe('Carrito', () => {
await page.goto('/carrito');
await page.getByRole('button', { name: 'Quitar' }).first().click();
await expect(page.getByText('No has añadido ninguna canción todavía.')).toBeVisible();
await expect(page.getByText('No has añadido nada todavía.')).toBeVisible();
});
});
@ -270,7 +277,9 @@ test.describe('Cuenta', () => {
await page.goto('/entrar');
await page.getByLabel('Correo electrónico').fill(correo);
await page.getByRole('button', { name: 'Enviarme el código' }).click();
await page
.getByRole('button', { name: /^(Enviarme el código|Continuar con este correo)$/ })
.click();
/*
* Lo que se comprueba es que la acción se ejecuta y responde algo
@ -310,7 +319,9 @@ test.describe('Cuenta', () => {
await page.getByLabel('Código').fill('000000');
await page.getByRole('button', { name: 'Entrar' }).click();
await expect(page.getByText(/El código ha caducado o ya se ha usado/)).toBeVisible();
await expect(
page.getByText(/El código (ha caducado o ya se ha usado|de prueba no coincide)/)
).toBeVisible();
});
test('rechaza un correo mal formado', async ({ page }) => {
@ -318,7 +329,9 @@ test.describe('Cuenta', () => {
// `type="email"` ya filtra lo obvio; esto pasa el filtro del navegador y
// lo tiene que rechazar el servidor.
await page.getByLabel('Correo electrónico').fill('alguien@example');
await page.getByRole('button', { name: 'Enviarme el código' }).click();
await page
.getByRole('button', { name: /^(Enviarme el código|Continuar con este correo)$/ })
.click();
await expect(page.getByText('Ese correo no parece válido.')).toBeVisible();
});

@ -240,6 +240,50 @@ await rechaza(
`insert into suscripcion (id, usuario_id, referencia_externa) values ('s4', 'u9', 'sub_123')`
);
apuntar('\nEL COMERCIO EXTENSIBLE');
await permite(
'un plan conserva nivel, precio, recurrencia y prestaciones',
`insert into plan_suscripcion
(id, codigo, nombre, nivel, precio_centimos, periodicidad, prestaciones, estado)
values ('ps1', 'zz-apoyo', 'Apoyo', 1, 500, 'mes', '["catálogo exclusivo"]', 'disponible');
update suscripcion set plan_id = 'ps1' where id = 's1'`
);
await rechaza(
'un plan no admite intervalos de renovación nulos',
`insert into plan_suscripcion
(id, codigo, nombre, nivel, precio_centimos, periodicidad, intervalos)
values ('ps2', 'zz-roto', 'Roto', 2, 500, 'mes', 0)`
);
await permite(
'un libro disponible se puede añadir al carrito con cantidad',
`insert into producto
(id, codigo, tipo, nombre, estado, precio_centimos)
values ('pr1', 'zz-libro', 'libro', 'Libro de prueba', 'disponible', 900);
insert into carrito (id, usuario_id) values ('ca9', 'u9');
insert into carrito_producto (carrito_id, producto_id, cantidad)
values ('ca9', 'pr1', 2)`
);
await rechaza(
'un producto no puede tener precio negativo',
`insert into producto
(id, codigo, tipo, nombre, precio_centimos)
values ('pr2', 'zz-negativo', 'midi', 'MIDI roto', -1)`
);
await permite(
'un pedido congela una línea de cobro coherente',
`insert into pedido_linea
(pedido_id, codigo, tipo, nombre, cantidad, precio_unitario_centimos,
total_centimos, moneda, modalidad)
values ('o9', 'pack-canciones-10', 'pack_canciones', 'Pack', 1, 500, 500, 'eur', 'pago_unico')`
);
await rechaza(
'una línea no admite un total distinto de cantidad por precio',
`insert into pedido_linea
(pedido_id, codigo, tipo, nombre, cantidad, precio_unitario_centimos,
total_centimos, moneda, modalidad)
values ('o9', 'zz-mal-total', 'servicio', 'Servicio', 2, 300, 500, 'eur', 'pago_unico')`
);
apuntar('\nEL LINAJE DE GÉNEROS');
await permite(
'el bolero-son deriva de dos géneros',

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import {
modalidadDeLineas,
totalDeLineas,
validarLineasDeCobro,
type LineaDeCobro
} from './comercio';
import { resolverCompra } from './tienda';
const libro: LineaDeCobro = {
codigo: 'libro-taller',
tipo: 'libro',
nombre: 'Taller de letras',
cantidad: 2,
precioUnitarioCentimos: 900,
moneda: 'eur',
modalidad: 'pago_unico'
};
describe('líneas de comercio', () => {
it('suma líneas heterogéneas sin conocer su tipo concreto', () => {
expect(totalDeLineas([libro, { ...libro, codigo: 'midi-tema', cantidad: 1 }])).toBe(2700);
});
it('no mezcla compras únicas con suscripciones', () => {
const recurrente: LineaDeCobro = {
codigo: 'suscripcion-apoyo',
tipo: 'suscripcion',
nombre: 'Apoyo',
cantidad: 1,
precioUnitarioCentimos: 500,
moneda: 'eur',
modalidad: 'suscripcion',
recurrencia: { periodicidad: 'mes', intervalos: 1 }
};
expect(() => modalidadDeLineas([libro, recurrente])).toThrow(/por separado/);
expect(modalidadDeLineas([recurrente])).toBe('suscripcion');
});
it('rechaza cantidades, precios y recurrencias incoherentes', () => {
expect(() => validarLineasDeCobro([{ ...libro, cantidad: 0 }])).toThrow(/Cantidad/);
expect(() => validarLineasDeCobro([{ ...libro, precioUnitarioCentimos: -1 }])).toThrow(
/Precio/
);
expect(() =>
validarLineasDeCobro([{ ...libro, recurrencia: { periodicidad: 'mes', intervalos: 1 } }])
).toThrow(/compra única/);
expect(() => validarLineasDeCobro([libro, { ...libro }])).toThrow(/duplicada/);
expect(() =>
validarLineasDeCobro([libro, { ...libro, codigo: 'otro', moneda: 'usd' }])
).toThrow(/misma moneda/);
});
it('resuelve packs y artículos como líneas distintas en un único total', () => {
const resultado = resolverCompra({
canciones: 12,
articulos: [
{
codigo: libro.codigo,
tipo: 'libro',
nombre: libro.nombre,
cantidad: libro.cantidad,
precioUnitarioCentimos: libro.precioUnitarioCentimos
}
]
});
expect(resultado.packs).toBe(2);
expect(resultado.lineasDeCobro.map((linea) => linea.tipo)).toEqual(['pack_canciones', 'libro']);
expect(resultado.totalCentimos).toBe(2100);
});
});

@ -0,0 +1,138 @@
/**
* Contratos comunes del comercio.
*
* Una cosa es lo que se elige —canciones, un libro, unas partituras— y otra
* lo que se cobra. Diecisiete canciones, por ejemplo, son diecisiete derechos
* de descarga pero solo dos líneas/unidades de pack en Stripe. Mantener esa
* separación evita convertir cada nueva clase de artículo en un checkout
* distinto.
*/
export const TIPOS_PRODUCTO = ['libro', 'partitura', 'midi', 'stems', 'servicio'] as const;
export type TipoProducto = (typeof TIPOS_PRODUCTO)[number];
export const ESTADOS_PRODUCTO = ['borrador', 'disponible', 'retirado'] as const;
export type EstadoProducto = (typeof ESTADOS_PRODUCTO)[number];
export const TIPOS_LINEA_COBRO = ['pack_canciones', ...TIPOS_PRODUCTO, 'suscripcion'] as const;
export type TipoLineaCobro = (typeof TIPOS_LINEA_COBRO)[number];
export const MODALIDADES_COBRO = ['pago_unico', 'suscripcion'] as const;
export type ModalidadCobro = (typeof MODALIDADES_COBRO)[number];
export const PERIODICIDADES = ['dia', 'semana', 'mes', 'ano'] as const;
export type Periodicidad = (typeof PERIODICIDADES)[number];
export const ESTADOS_PLAN_SUSCRIPCION = ['borrador', 'disponible', 'retirado'] as const;
export interface RecurrenciaCobro {
periodicidad: Periodicidad;
intervalos: number;
}
/** Línea inmutable que entiende tanto la aplicación como la pasarela. */
export interface LineaDeCobro {
codigo: string;
tipo: TipoLineaCobro;
nombre: string;
descripcion?: string;
cantidad: number;
precioUnitarioCentimos: number;
moneda: string;
modalidad: ModalidadCobro;
recurrencia?: RecurrenciaCobro;
}
/** Producto no musical disponible para incorporarlo al cálculo del carrito. */
export interface ArticuloDeCarrito {
codigo: string;
tipo: TipoProducto;
nombre: string;
descripcion?: string;
cantidad: number;
precioUnitarioCentimos: number;
moneda?: string;
}
/** Forma común de un producto leído del catálogo junto con su cantidad. */
export interface ProductoSeleccionado {
codigo: string;
tipo: TipoProducto;
nombre: string;
descripcion: string | null;
cantidad: number;
precioCentimos: number;
moneda: string;
}
export function articuloDeProducto(producto: ProductoSeleccionado): ArticuloDeCarrito {
return {
codigo: producto.codigo,
tipo: producto.tipo,
nombre: producto.nombre,
descripcion: producto.descripcion ?? undefined,
cantidad: producto.cantidad,
precioUnitarioCentimos: producto.precioCentimos,
moneda: producto.moneda
};
}
export function importeDeLinea(linea: LineaDeCobro): number {
return linea.cantidad * linea.precioUnitarioCentimos;
}
export function totalDeLineas(lineas: readonly LineaDeCobro[]): number {
return lineas.reduce((total, linea) => total + importeDeLinea(linea), 0);
}
/**
* Decide el modo de Checkout y rechaza una cesta ambigua.
*
* Los cobros únicos y recurrentes no se mezclan: una suscripción tiene su
* propio ciclo, cancelación e impuestos. Si alguna vez se quiere cobrar un
* alta junto a la primera cuota se representará expresamente en el plan, no
* escondiendo un pago único entre líneas recurrentes.
*/
export function modalidadDeLineas(lineas: readonly LineaDeCobro[]): ModalidadCobro {
if (lineas.length === 0) throw new Error('No hay líneas que cobrar.');
const modalidades = new Set(lineas.map((linea) => linea.modalidad));
if (modalidades.size !== 1) {
throw new Error('Los pagos únicos y las suscripciones deben cobrarse por separado.');
}
return lineas[0].modalidad;
}
/** Comprueba cantidades e importes antes de construir una petición de pago. */
export function validarLineasDeCobro(lineas: readonly LineaDeCobro[]): void {
modalidadDeLineas(lineas);
const moneda = lineas[0].moneda.toLowerCase();
const codigos = new Set<string>();
for (const linea of lineas) {
if (!linea.codigo.trim() || !linea.nombre.trim())
throw new Error('Hay una línea sin identidad.');
if (codigos.has(linea.codigo)) throw new Error(`La línea ${linea.codigo} está duplicada.`);
codigos.add(linea.codigo);
if (!TIPOS_LINEA_COBRO.includes(linea.tipo))
throw new Error(`Tipo de línea no válido en ${linea.codigo}.`);
if (!MODALIDADES_COBRO.includes(linea.modalidad))
throw new Error(`Modalidad no válida en ${linea.codigo}.`);
if (!Number.isInteger(linea.cantidad) || linea.cantidad < 1)
throw new Error(`Cantidad no válida en ${linea.codigo}.`);
if (!Number.isInteger(linea.precioUnitarioCentimos) || linea.precioUnitarioCentimos < 0)
throw new Error(`Precio no válido en ${linea.codigo}.`);
if (!/^[a-z]{3}$/.test(linea.moneda) || linea.moneda.toLowerCase() !== moneda)
throw new Error('Todas las líneas deben usar la misma moneda ISO.');
if (linea.modalidad === 'suscripcion') {
if (
!linea.recurrencia ||
!PERIODICIDADES.includes(linea.recurrencia.periodicidad) ||
!Number.isInteger(linea.recurrencia.intervalos)
)
throw new Error(`Falta la periodicidad de ${linea.codigo}.`);
if (linea.recurrencia.intervalos < 1)
throw new Error(`Periodicidad no válida en ${linea.codigo}.`);
} else if (linea.recurrencia) {
throw new Error(`Una compra única no puede ser recurrente (${linea.codigo}).`);
}
}
}

@ -7,7 +7,7 @@
{#if canciones.length === 0}
<p class="muted">
Todavía no has comprado nada. Las canciones se venden en packs de {CANCIONES_POR_PACK} desde
Todavía no has comprado nada. Las canciones se agrupan en packs de hasta {CANCIONES_POR_PACK} desde
<a href={resolve('/musica')}>la sección de música</a>.
</p>
{:else}

@ -75,7 +75,7 @@
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
}
const enCarrito = $derived(page.data.carrito?.length ?? 0);
const enCarrito = $derived(page.data.cantidadCarrito ?? page.data.carrito?.length ?? 0);
const usuario = $derived(page.data.usuario);
// Al navegar, el menú móvil se cierra solo.
@ -122,7 +122,7 @@
>
Carrito
{#if enCarrito > 0}
<span class="contador" aria-label="{enCarrito} canciones">{enCarrito}</span>
<span class="contador" aria-label="{enCarrito} artículos">{enCarrito}</span>
{/if}
</a>

@ -8,9 +8,9 @@
*/
import type { Cookies } from '@sveltejs/kit';
import { and, eq } from 'drizzle-orm';
import { and, eq, sql } from 'drizzle-orm';
import { db } from './db';
import { cancion, carrito, carritoItem } from './db/schema';
import { cancion, carrito, carritoItem, carritoProducto, producto } from './db/schema';
import { idDeCancion } from './canciones';
export const COOKIE_CARRITO = 'carrito';
@ -83,8 +83,11 @@ export async function asegurarCarrito(cookies: Cookies, usuarioId: string | null
return creado.id;
}
/** Slugs del carrito actual. No crea nada si todavia no existe. */
export async function leerCarrito(cookies: Cookies, usuarioId: string | null): Promise<string[]> {
/** Localiza el carrito actual sin crear uno como efecto secundario de leer. */
async function carritoActual(
cookies: Cookies,
usuarioId: string | null
): Promise<string | undefined> {
let id: string | undefined;
if (usuarioId) {
@ -98,6 +101,12 @@ export async function leerCarrito(cookies: Cookies, usuarioId: string | null): P
id = cookies.get(COOKIE_CARRITO);
}
return id;
}
/** Slugs de canciones del carrito actual. */
export async function leerCarrito(cookies: Cookies, usuarioId: string | null): Promise<string[]> {
const id = await carritoActual(cookies, usuarioId);
if (!id) return [];
/*
@ -115,6 +124,57 @@ export async function leerCarrito(cookies: Cookies, usuarioId: string | null): P
return filas.map((f) => f.slug);
}
export interface ProductoEnCarrito {
id: string;
codigo: string;
tipo: typeof producto.$inferSelect.tipo;
nombre: string;
descripcion: string | null;
estado: typeof producto.$inferSelect.estado;
cantidad: number;
precioCentimos: number;
moneda: string;
}
/** Artículos y servicios unitarios; las canciones siguen su regla de packs. */
export async function leerProductosDelCarrito(
cookies: Cookies,
usuarioId: string | null
): Promise<ProductoEnCarrito[]> {
const id = await carritoActual(cookies, usuarioId);
if (!id) return [];
return db
.select({
id: producto.id,
codigo: producto.codigo,
tipo: producto.tipo,
nombre: producto.nombre,
descripcion: producto.descripcion,
estado: producto.estado,
cantidad: carritoProducto.cantidad,
precioCentimos: producto.precioCentimos,
moneda: producto.moneda
})
.from(carritoProducto)
.innerJoin(producto, eq(producto.id, carritoProducto.productoId))
.where(eq(carritoProducto.carritoId, id))
.orderBy(carritoProducto.anadidoEn);
}
/** Unidades de productos no musicales, para el contador global del carrito. */
export async function contarProductosDelCarrito(
cookies: Cookies,
usuarioId: string | null
): Promise<number> {
const id = await carritoActual(cookies, usuarioId);
if (!id) return 0;
const [fila] = await db
.select({ cantidad: sql<number>`coalesce(sum(${carritoProducto.cantidad}), 0)::int` })
.from(carritoProducto)
.where(eq(carritoProducto.carritoId, id));
return fila?.cantidad ?? 0;
}
/**
* Devuelve `false` si ese slug no existe en el catálogo.
*
@ -144,11 +204,51 @@ export async function quitarDelCarrito(carritoId: string, cancionSlug: string):
await tocar(carritoId);
}
export async function vaciarCarrito(carritoId: string): Promise<void> {
await db.delete(carritoItem).where(eq(carritoItem.carritoId, carritoId));
/** Añade o incrementa un producto disponible sin confiar en datos del cliente. */
export async function anadirProductoAlCarrito(
carritoId: string,
codigo: string,
cantidad = 1
): Promise<boolean> {
if (!Number.isInteger(cantidad) || cantidad < 1) return false;
const [vendible] = await db
.select({ id: producto.id })
.from(producto)
.where(and(eq(producto.codigo, codigo), eq(producto.estado, 'disponible')))
.limit(1);
if (!vendible) return false;
await db
.insert(carritoProducto)
.values({ carritoId, productoId: vendible.id, cantidad })
.onConflictDoUpdate({
target: [carritoProducto.carritoId, carritoProducto.productoId],
set: { cantidad: sql`${carritoProducto.cantidad} + ${cantidad}` }
});
await tocar(carritoId);
return true;
}
export async function quitarProductoDelCarrito(carritoId: string, codigo: string): Promise<void> {
const [fila] = await db
.select({ id: producto.id })
.from(producto)
.where(eq(producto.codigo, codigo))
.limit(1);
if (!fila) return;
await db
.delete(carritoProducto)
.where(and(eq(carritoProducto.carritoId, carritoId), eq(carritoProducto.productoId, fila.id)));
await tocar(carritoId);
}
export async function vaciarCarrito(carritoId: string): Promise<void> {
await db.transaction(async (tx) => {
await tx.delete(carritoItem).where(eq(carritoItem.carritoId, carritoId));
await tx.delete(carritoProducto).where(eq(carritoProducto.carritoId, carritoId));
await tx.update(carrito).set({ actualizadoEn: new Date() }).where(eq(carrito.id, carritoId));
});
}
async function tocar(carritoId: string): Promise<void> {
await db.update(carrito).set({ actualizadoEn: new Date() }).where(eq(carrito.id, carritoId));
}
@ -175,6 +275,21 @@ export async function fusionarCarrito(cookies: Cookies, usuarioId: string): Prom
.onConflictDoNothing();
}
const productos = await tx
.select({ productoId: carritoProducto.productoId, cantidad: carritoProducto.cantidad })
.from(carritoProducto)
.where(eq(carritoProducto.carritoId, origen));
for (const item of productos) {
await tx
.insert(carritoProducto)
.values({ carritoId: destino, productoId: item.productoId, cantidad: item.cantidad })
.onConflictDoUpdate({
target: [carritoProducto.carritoId, carritoProducto.productoId],
set: { cantidad: sql`${carritoProducto.cantidad} + ${item.cantidad}` }
});
}
await tx.delete(carrito).where(eq(carrito.id, origen));
});
}

@ -1,23 +1,81 @@
/** Reintentos de Checkout coordinados por una intención persistente. */
import { createHash } from 'node:crypto';
import { and, eq, sql } from 'drizzle-orm';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { db } from './db';
import { intencionPago } from './db/schema';
import { crearSesionDePago, obtenerSesionDePago, ErrorDePago, type PeticionDePago } from './stripe';
import { crearPedido, finalizarPedido, cancelarPedido, type LineaDePedido } from './pedidos';
import { PRECIO_PACK_CENTIMOS } from '$lib/tienda';
import {
CANCIONES_POR_PACK,
CODIGO_PACK_CANCIONES,
MONEDA,
PRECIO_PACK_CENTIMOS
} from '$lib/tienda';
import { totalDeLineas, type LineaDeCobro } from '$lib/comercio';
export function huellaDeCompra(slugs: string[]): string {
type DatosIntencion = (typeof intencionPago.$inferSelect)['datos'];
export function huellaDeCompra(slugs: string[], lineas: readonly LineaDeCobro[]): string {
return createHash('sha256')
.update(
JSON.stringify({
slugs: [...new Set(slugs)].sort(),
lineas: [...lineas]
.map((linea) => ({
codigo: linea.codigo,
cantidad: linea.cantidad,
precio: linea.precioUnitarioCentimos,
moneda: linea.moneda,
modalidad: linea.modalidad,
recurrencia: linea.recurrencia
}))
.sort((a, b) => a.codigo.localeCompare(b.codigo))
})
)
.digest('hex');
}
function huellaDeCompraV1(slugs: string[]): string {
return createHash('sha256')
.update(JSON.stringify([...new Set(slugs)].sort()))
.digest('hex');
}
/** Adapta intenciones v1 que aún estuvieran abiertas durante el despliegue. */
export function lineasDeIntencion(datos: DatosIntencion): LineaDeCobro[] {
return (
datos.lineas ?? [
{
codigo: CODIGO_PACK_CANCIONES,
tipo: 'pack_canciones',
nombre: `Pack de hasta ${CANCIONES_POR_PACK} canciones`,
descripcion: 'Descarga en MP3 de las canciones elegidas, con todas sus versiones.',
cantidad: datos.packs,
precioUnitarioCentimos: datos.precioPack,
moneda: MONEDA,
modalidad: 'pago_unico'
}
]
);
}
export function totalDeIntencion(datos: DatosIntencion): number {
return datos.totalCentimos ?? totalDeLineas(lineasDeIntencion(datos));
}
export async function abrirCheckout(
peticion: PeticionDePago,
items: LineaDePedido[]
): Promise<string> {
const huella = huellaDeCompra(peticion.slugs);
const huella = huellaDeCompra(peticion.slugs, peticion.lineas);
const huellasCompatibles = [huella];
if (
peticion.slugs.length > 0 &&
peticion.lineas.length === 1 &&
peticion.lineas[0].tipo === 'pack_canciones'
) {
huellasCompatibles.push(huellaDeCompraV1(peticion.slugs));
}
// El bloqueo solo protege la reserva, nunca espera a la red de Stripe.
const intencion = await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${peticion.usuarioId}))`);
@ -27,7 +85,7 @@ export async function abrirCheckout(
.where(
and(
eq(intencionPago.usuarioId, peticion.usuarioId),
eq(intencionPago.huella, huella),
inArray(intencionPago.huella, huellasCompatibles),
eq(intencionPago.estado, 'abierta')
)
);
@ -39,9 +97,11 @@ export async function abrirCheckout(
huella,
datos: {
...peticion,
version: 2,
slugs: [...peticion.slugs].sort(),
items,
precioPack: PRECIO_PACK_CENTIMOS,
totalCentimos: totalDeLineas(peticion.lineas),
expiraEn: Math.floor(Date.now() / 1000) + 7200
}
})
@ -49,6 +109,8 @@ export async function abrirCheckout(
return creada;
});
const datos = intencion.datos;
const lineas = lineasDeIntencion(datos);
const totalCentimos = totalDeIntencion(datos);
let sesion;
if (intencion.sesionPagoId) {
const existente = await obtenerSesionDePago(intencion.sesionPagoId);
@ -56,8 +118,9 @@ export async function abrirCheckout(
usuarioId: datos.usuarioId,
sesionPagoId: existente.id,
packs: datos.packs,
totalCentimos: datos.precioPack * datos.packs,
items: datos.items
totalCentimos,
items: datos.items,
lineas
});
if (existente.status === 'expired') {
await cancelarPedido(existente.id);
@ -78,7 +141,11 @@ export async function abrirCheckout(
// haber olvidado. El panel debe conciliar esa intención antes de reabrirla.
if (Date.now() >= datos.expiraEn * 1000)
throw new ErrorDePago('Hay un pago pendiente de conciliación. Contacta con nosotros.');
sesion = await crearSesionDePago({ ...datos, claveIdempotencia: `checkout:${intencion.id}` });
sesion = await crearSesionDePago({
...datos,
lineas,
claveIdempotencia: `checkout:${intencion.id}`
});
await db
.update(intencionPago)
.set({ sesionPagoId: sesion.id })
@ -88,8 +155,9 @@ export async function abrirCheckout(
usuarioId: datos.usuarioId,
sesionPagoId: sesion.id,
packs: datos.packs,
totalCentimos: datos.precioPack * datos.packs,
items: datos.items
totalCentimos,
items: datos.items,
lineas
});
return sesion.url;
}

@ -18,10 +18,26 @@
*/
import { sql } from 'drizzle-orm';
import { index, integer, jsonb, pgTable, primaryKey, text, uniqueIndex } from 'drizzle-orm/pg-core';
import {
check,
index,
integer,
jsonb,
pgTable,
primaryKey,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { ahora, id, instante } from './comun';
import { cancion } from './catalogo';
import { usuario } from './cuentas';
import {
ESTADOS_PRODUCTO,
MODALIDADES_COBRO,
TIPOS_LINEA_COBRO,
TIPOS_PRODUCTO,
type LineaDeCobro
} from '../../../comercio';
/* -------------------------------------------------------------------------
* Carrito
@ -61,6 +77,66 @@ export const carritoItem = pgTable(
(t) => [primaryKey({ columns: [t.carritoId, t.cancionId] })]
);
/* -------------------------------------------------------------------------
* Catálogo comercial
* ---------------------------------------------------------------------- */
/**
* Una oferta que puede añadirse al carrito con precio unitario.
*
* Las canciones no crean una fila por tema: se eligen en `carrito_item` y el
* motor las agrupa en packs al pagar. Aquí viven los demás productos y
* servicios, con un código estable aunque cambien su nombre o precio.
*/
export const producto = pgTable(
'producto',
{
id: id(),
codigo: text('codigo').notNull().unique(),
tipo: text('tipo', { enum: TIPOS_PRODUCTO }).notNull(),
nombre: text('nombre').notNull(),
descripcion: text('descripcion'),
estado: text('estado', { enum: ESTADOS_PRODUCTO }).notNull().default('borrador'),
precioCentimos: integer('precio_centimos').notNull(),
moneda: text('moneda').notNull().default('eur'),
metadatos: jsonb('metadatos').notNull().default({}).$type<Record<string, unknown>>(),
creadoEn: ahora('creado_en'),
actualizadoEn: ahora('actualizado_en')
},
(t) => [
index('producto_tipo_estado').on(t.tipo, t.estado),
check(
'producto_tipo_conocido',
sql`${t.tipo} in ('libro', 'partitura', 'midi', 'stems', 'servicio')`
),
check('producto_estado_conocido', sql`${t.estado} in ('borrador', 'disponible', 'retirado')`),
check('producto_precio_no_negativo', sql`${t.precioCentimos} >= 0`),
check('producto_moneda_iso', sql`${t.moneda} ~ '^[a-z]{3}$'`),
check('producto_codigo_no_vacio', sql`btrim(${t.codigo}) <> ''`),
check('producto_nombre_no_vacio', sql`btrim(${t.nombre}) <> ''`),
check('producto_metadatos_objeto', sql`jsonb_typeof(${t.metadatos}) = 'object'`)
]
);
/** Productos unitarios del carrito; la cantidad se puede incrementar. */
export const carritoProducto = pgTable(
'carrito_producto',
{
carritoId: text('carrito_id')
.notNull()
.references(() => carrito.id, { onDelete: 'cascade' }),
productoId: text('producto_id')
.notNull()
.references(() => producto.id, { onDelete: 'cascade' }),
cantidad: integer('cantidad').notNull().default(1),
anadidoEn: ahora('anadido_en')
},
(t) => [
primaryKey({ columns: [t.carritoId, t.productoId] }),
check('carrito_producto_cantidad_positiva', sql`${t.cantidad} > 0`)
]
);
/* -------------------------------------------------------------------------
* Pedidos y compras
* ---------------------------------------------------------------------- */
@ -103,6 +179,49 @@ export const pedidoItem = pgTable(
(t) => [primaryKey({ columns: [t.pedidoId, t.cancionId] })]
);
/**
* Desglose económico congelado del pedido.
*
* `pedido_item` conserva las canciones entregadas; esta tabla conserva lo que
* se cobró. No tienen por qué coincidir uno a uno: dos packs pueden entregar
* diecisiete canciones y una línea de libro puede entregar varios archivos.
*/
export const pedidoLinea = pgTable(
'pedido_linea',
{
pedidoId: text('pedido_id')
.notNull()
.references(() => pedido.id, { onDelete: 'cascade' }),
codigo: text('codigo').notNull(),
productoId: text('producto_id').references(() => producto.id, { onDelete: 'set null' }),
tipo: text('tipo', { enum: TIPOS_LINEA_COBRO }).notNull(),
nombre: text('nombre').notNull(),
descripcion: text('descripcion'),
cantidad: integer('cantidad').notNull(),
precioUnitarioCentimos: integer('precio_unitario_centimos').notNull(),
totalCentimos: integer('total_centimos').notNull(),
moneda: text('moneda').notNull(),
modalidad: text('modalidad', { enum: MODALIDADES_COBRO }).notNull(),
datos: jsonb('datos').notNull().default({}).$type<Pick<LineaDeCobro, 'recurrencia'>>()
},
(t) => [
primaryKey({ columns: [t.pedidoId, t.codigo] }),
index('pedido_linea_tipo').on(t.tipo),
check('pedido_linea_cantidad_positiva', sql`${t.cantidad} > 0`),
check(
'pedido_linea_tipo_conocido',
sql`${t.tipo} in ('pack_canciones', 'libro', 'partitura', 'midi', 'stems', 'servicio', 'suscripcion')`
),
check('pedido_linea_modalidad_conocida', sql`${t.modalidad} in ('pago_unico', 'suscripcion')`),
check(
'pedido_linea_importes_validos',
sql`${t.precioUnitarioCentimos} >= 0 and ${t.totalCentimos} = ${t.precioUnitarioCentimos} * ${t.cantidad}`
),
check('pedido_linea_moneda_iso', sql`${t.moneda} ~ '^[a-z]{3}$'`),
check('pedido_linea_datos_objeto', sql`jsonb_typeof(${t.datos}) = 'object'`)
]
);
/**
* Lo que da derecho a descargar. Es una tabla aparte de `pedidoItem` a
* proposito: responde a "puede esta persona descargar este tema" en una sola
@ -135,6 +254,7 @@ export const intencionPago = pgTable(
.references(() => usuario.id, { onDelete: 'cascade' }),
huella: text('huella').notNull(),
datos: jsonb('datos').notNull().$type<{
version?: 1 | 2;
packs: number;
email: string;
usuarioId: string;
@ -142,6 +262,8 @@ export const intencionPago = pgTable(
urlCancelacion: string;
slugs: string[];
precioPack: number;
totalCentimos?: number;
lineas?: LineaDeCobro[];
expiraEn: number;
items: { slug: string; titulo: string }[];
}>(),

@ -5,9 +5,19 @@
* contenido, y no cambia con ella.
*/
import { bigint, check, index, integer, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import {
bigint,
check,
index,
integer,
jsonb,
pgTable,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { ahora, id, instante } from './comun';
import { ESTADOS_PLAN_SUSCRIPCION, PERIODICIDADES } from '../../../comercio';
/* -------------------------------------------------------------------------
* Cuentas
@ -48,6 +58,50 @@ export const ESTADOS_SUSCRIPCION = [
'vencida'
] as const;
/**
* Nivel comercial de suscripción.
*
* La suscripción de una cuenta apunta al plan que contrató; el nivel y las
* prestaciones no se deducen del importe ni de un identificador de Stripe.
*/
export const planSuscripcion = pgTable(
'plan_suscripcion',
{
id: id(),
codigo: text('codigo').notNull().unique(),
nombre: text('nombre').notNull(),
descripcion: text('descripcion'),
nivel: integer('nivel').notNull(),
precioCentimos: integer('precio_centimos').notNull(),
moneda: text('moneda').notNull().default('eur'),
periodicidad: text('periodicidad', { enum: PERIODICIDADES }).notNull(),
intervalos: integer('intervalos').notNull().default(1),
referenciaPrecioExterna: text('referencia_precio_externa').unique(),
prestaciones: jsonb('prestaciones').notNull().default([]).$type<string[]>(),
estado: text('estado', { enum: ESTADOS_PLAN_SUSCRIPCION }).notNull().default('borrador'),
creadoEn: ahora('creado_en'),
actualizadoEn: ahora('actualizado_en')
},
(t) => [
index('plan_suscripcion_estado_nivel').on(t.estado, t.nivel),
check(
'plan_suscripcion_periodicidad_conocida',
sql`${t.periodicidad} in ('dia', 'semana', 'mes', 'ano')`
),
check(
'plan_suscripcion_estado_conocido',
sql`${t.estado} in ('borrador', 'disponible', 'retirado')`
),
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_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}) <> ''`),
check('plan_suscripcion_prestaciones_array', sql`jsonb_typeof(${t.prestaciones}) = 'array'`)
]
);
/**
* Un periodo de suscripción de una cuenta.
*
@ -59,6 +113,7 @@ export const suscripcion = pgTable(
'suscripcion',
{
id: id(),
planId: text('plan_id').references(() => planSuscripcion.id, { onDelete: 'restrict' }),
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),

@ -6,9 +6,18 @@
*/
import { relations } from 'drizzle-orm';
import { suscripcion, usuario } from './cuentas';
import { planSuscripcion, suscripcion, usuario } from './cuentas';
import { cancion } from './catalogo';
import { carrito, carritoItem, compra, pedido, pedidoItem } from './comercio';
import {
carrito,
carritoItem,
carritoProducto,
compra,
pedido,
pedidoItem,
pedidoLinea,
producto
} from './comercio';
import {
accesoCancionUsuario,
favorito,
@ -53,6 +62,7 @@ export const relacionesListaReproduccionCancion = relations(
export const relacionesCarrito = relations(carrito, ({ many, one }) => ({
items: many(carritoItem),
productos: many(carritoProducto),
usuario: one(usuario, { fields: [carrito.usuarioId], references: [usuario.id] })
}));
@ -60,8 +70,19 @@ export const relacionesCarritoItem = relations(carritoItem, ({ one }) => ({
carrito: one(carrito, { fields: [carritoItem.carritoId], references: [carrito.id] })
}));
export const relacionesProducto = relations(producto, ({ many }) => ({
carritos: many(carritoProducto),
lineasDePedido: many(pedidoLinea)
}));
export const relacionesCarritoProducto = relations(carritoProducto, ({ one }) => ({
carrito: one(carrito, { fields: [carritoProducto.carritoId], references: [carrito.id] }),
producto: one(producto, { fields: [carritoProducto.productoId], references: [producto.id] })
}));
export const relacionesPedido = relations(pedido, ({ many, one }) => ({
items: many(pedidoItem),
lineas: many(pedidoLinea),
usuario: one(usuario, { fields: [pedido.usuarioId], references: [usuario.id] })
}));
@ -69,10 +90,29 @@ export const relacionesPedidoItem = relations(pedidoItem, ({ one }) => ({
pedido: one(pedido, { fields: [pedidoItem.pedidoId], references: [pedido.id] })
}));
export const relacionesPedidoLinea = relations(pedidoLinea, ({ one }) => ({
pedido: one(pedido, { fields: [pedidoLinea.pedidoId], references: [pedido.id] }),
producto: one(producto, { fields: [pedidoLinea.productoId], references: [producto.id] })
}));
export const relacionesPlanSuscripcion = relations(planSuscripcion, ({ many }) => ({
suscripciones: many(suscripcion)
}));
export const relacionesSuscripcion = relations(suscripcion, ({ one }) => ({
plan: one(planSuscripcion, {
fields: [suscripcion.planId],
references: [planSuscripcion.id]
}),
usuario: one(usuario, { fields: [suscripcion.usuarioId], references: [usuario.id] })
}));
export type Usuario = typeof usuario.$inferSelect;
export type Pedido = typeof pedido.$inferSelect;
export type Compra = typeof compra.$inferSelect;
export type Valoracion = typeof valoracion.$inferSelect;
export type Favorito = typeof favorito.$inferSelect;
export type Suscripcion = typeof suscripcion.$inferSelect;
export type PlanSuscripcion = typeof planSuscripcion.$inferSelect;
export type Producto = typeof producto.$inferSelect;
export type ListaReproduccion = typeof listaReproduccion.$inferSelect;

@ -32,6 +32,7 @@ import { bibliotecaDeUsuario } from './biblioteca';
import { guardarCancion, cancionParaEditar, type CambiosDeCancion } from './panel-canciones';
import { renombrarLista, moverEnLista, borrarLista, listasDeUsuario } from './listas-reproduccion';
import { concederSuscripcion, revocarSuscripcion } from './operaciones';
import { resumirCarrito } from '$lib/tienda';
const conexion = process.env.TEST_DATABASE_URL;
describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => {
@ -54,6 +55,7 @@ describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => {
}) as typeof cliente.query;
for (const tabla of [
'usuario',
'plan_suscripcion',
'estilo',
'album',
'cancion',
@ -63,9 +65,12 @@ describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => {
'cancion_interprete',
'pedido',
'pedido_item',
'pedido_linea',
'compra',
'carrito',
'carrito_item',
'producto',
'carrito_producto',
'lista_reproduccion',
'lista_reproduccion_cancion',
'suscripcion'
@ -116,6 +121,7 @@ describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => {
usuarioId: 'u',
email: 'prueba@example.test',
packs: 1,
lineas: resumirCarrito(1).lineasDeCobro,
slugs: ['tema'],
urlExito: 'https://test.invalid/compra/completada',
urlCancelacion: 'https://test.invalid/carrito'
@ -145,6 +151,41 @@ describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => {
});
expect(await bibliotecaDeUsuario('otro')).toEqual([]);
});
it('congela productos en el pedido y descuenta solo la cantidad pagada del carrito', async () => {
await cliente.query(
"INSERT INTO producto(id,codigo,tipo,nombre,estado,precio_centimos) VALUES ('pr','libro-prueba','libro','Libro de prueba','disponible',900); INSERT INTO carrito(id,usuario_id) VALUES ('ca','u'); INSERT INTO carrito_producto(carrito_id,producto_id,cantidad) VALUES ('ca','pr',3)"
);
await crearPedido({
usuarioId: 'u',
sesionPagoId: 'cs_producto',
packs: 0,
totalCentimos: 1800,
items: [],
lineas: [
{
codigo: 'libro-prueba',
tipo: 'libro',
nombre: 'Libro de prueba',
cantidad: 2,
precioUnitarioCentimos: 900,
moneda: 'eur',
modalidad: 'pago_unico'
}
]
});
expect(await finalizarPedido('cs_producto')).toBe(true);
expect(
(await cliente.query("SELECT cantidad FROM carrito_producto WHERE producto_id='pr'")).rows[0]
.cantidad
).toBe(1);
expect(
(
await cliente.query(
"SELECT nombre,total_centimos FROM pedido_linea WHERE codigo='libro-prueba'"
)
).rows[0]
).toEqual({ nombre: 'Libro de prueba', total_centimos: 1800 });
});
it('guardar una canción preserva instrumento y nombre acreditado', async () => {
const original = (await cancionParaEditar('tema'))!;
await guardarCancion('tema', {
@ -201,7 +242,13 @@ describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => {
sesionPagoId: 'cs_test_1',
packs: 1,
totalCentimos: 100,
items: [{ slug: 'tema', titulo: 'Tema' }]
items: [{ slug: 'tema', titulo: 'Tema' }],
lineas: [
{
...resumirCarrito(1).lineasDeCobro[0],
precioUnitarioCentimos: 100
}
]
})
).rejects.toThrow('incompatible');
});
@ -246,6 +293,7 @@ describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => {
usuarioId: 'u',
email: 'prueba@example.test',
packs: 1,
lineas: resumirCarrito(1).lineasDeCobro,
slugs: ['tema2'],
urlExito: 'https://test.invalid/ok',
urlCancelacion: 'https://test.invalid/carrito'

@ -3,10 +3,11 @@ import { stat, readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { and, desc, eq, sql } from 'drizzle-orm';
import { db } from './db';
import { intencionPago, pedido, suscripcion, usuario } from './db/schema';
import { intencionPago, pedido, planSuscripcion, suscripcion, usuario } from './db/schema';
import { directorioDeMedios, rutaDeAudio, rutaDeEscucha, rutaDeOnda } from './medios';
import { consultarEstadoDePago, obtenerSesionDePago } from './stripe';
import { cancelarPedido, crearPedido, finalizarPedido } from './pedidos';
import { lineasDeIntencion, totalDeIntencion } from './checkout';
export async function diagnosticoCatalogo() {
const resultados = await db.execute<{
@ -95,12 +96,15 @@ export async function operacionesRecientes() {
.select({
id: suscripcion.id,
email: usuario.email,
plan: planSuscripcion.nombre,
nivel: planSuscripcion.nivel,
estado: suscripcion.estado,
fin: suscripcion.terminaEn,
proveedor: suscripcion.proveedor
})
.from(suscripcion)
.innerJoin(usuario, eq(usuario.id, suscripcion.usuarioId))
.leftJoin(planSuscripcion, eq(planSuscripcion.id, suscripcion.planId))
.orderBy(desc(suscripcion.creadaEn))
.limit(50)
]);
@ -114,19 +118,27 @@ export async function conciliarIntencion(id: string, sesionIndicada: string) {
return 'Indica el identificador cs_ de Checkout, consultado en Stripe.';
const sesion = await obtenerSesionDePago(sesionId);
const datos = intencion.datos;
const lineas = lineasDeIntencion(datos);
const totalCentimos = totalDeIntencion(datos);
const resumenLineas = lineas
.map((linea) => `${linea.codigo}:${linea.cantidad}`)
.join(',')
.slice(0, 480);
if (
sesion.client_reference_id !== intencion.usuarioId ||
sesion.amount_total !== datos.packs * datos.precioPack ||
sesion.amount_total !== totalCentimos ||
sesion.currency !== 'eur' ||
sesion.metadata?.canciones !== datos.slugs.join(',').slice(0, 480)
sesion.metadata?.canciones !== datos.slugs.join(',').slice(0, 480) ||
(sesion.metadata?.lineas !== undefined && sesion.metadata.lineas !== resumenLineas)
)
return 'La sesión no corresponde a esta selección, cuenta o importe.';
await crearPedido({
usuarioId: intencion.usuarioId,
sesionPagoId: sesion.id,
packs: datos.packs,
totalCentimos: datos.packs * datos.precioPack,
items: datos.items
totalCentimos,
items: datos.items,
lineas
});
await db.update(intencionPago).set({ sesionPagoId: sesion.id }).where(eq(intencionPago.id, id));
if (sesion.status === 'expired') {

@ -13,10 +13,27 @@
* `$lib/server/canciones` y es la frontera entre las dos cosas.
*/
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
import { and, desc, eq, gt, inArray, isNull, lte, sql } from 'drizzle-orm';
import { db } from './db';
import { cancion, carrito, carritoItem, compra, pedido, pedidoItem } from './db/schema';
import { MONEDA } from '$lib/tienda';
import {
cancion,
carrito,
carritoItem,
carritoProducto,
compra,
pedido,
pedidoItem,
pedidoLinea,
producto
} from './db/schema';
import {
importeDeLinea,
TIPOS_PRODUCTO,
totalDeLineas,
validarLineasDeCobro,
type LineaDeCobro
} from '$lib/comercio';
import { CANCIONES_POR_PACK } from '$lib/tienda';
export interface LineaDePedido {
slug: string;
@ -29,6 +46,7 @@ export interface DatosDePedido {
packs: number;
totalCentimos: number;
items: LineaDePedido[];
lineas: LineaDeCobro[];
}
/** Slugs que ya ha comprado una cuenta. */
@ -68,6 +86,22 @@ export class TemaDesconocido extends Error {
}
export async function crearPedido(datos: DatosDePedido): Promise<string> {
validarLineasDeCobro(datos.lineas);
if (totalDeLineas(datos.lineas) !== datos.totalCentimos) {
throw new Error('El total del pedido no coincide con sus líneas.');
}
const itemsUnicos = new Set(datos.items.map((item) => item.slug));
if (itemsUnicos.size !== datos.items.length) {
throw new Error('El pedido contiene canciones duplicadas.');
}
const packsResueltos = datos.lineas
.filter((linea) => linea.tipo === 'pack_canciones')
.reduce((total, linea) => total + linea.cantidad, 0);
const packsNecesarios =
datos.items.length === 0 ? 0 : Math.ceil(datos.items.length / CANCIONES_POR_PACK);
if (datos.packs !== packsNecesarios || packsResueltos !== packsNecesarios) {
throw new Error('Las canciones del pedido no coinciden con los packs facturados.');
}
const [existente] = await db
.select()
.from(pedido)
@ -99,6 +133,21 @@ export async function crearPedido(datos: DatosDePedido): Promise<string> {
const ids = new Map(filas.map((f) => [f.slug, f.id]));
const perdidos = datos.items.filter((i) => !ids.has(i.slug)).map((i) => i.slug);
if (perdidos.length > 0) throw new TemaDesconocido(perdidos);
const tiposDeProducto = new Set<string>(TIPOS_PRODUCTO);
const codigosDeProducto = datos.lineas
.filter((linea) => tiposDeProducto.has(linea.tipo))
.map((linea) => linea.codigo);
const productos = codigosDeProducto.length
? await db
.select({ id: producto.id, codigo: producto.codigo })
.from(producto)
.where(inArray(producto.codigo, codigosDeProducto))
: [];
const productoPorCodigo = new Map(productos.map((fila) => [fila.codigo, fila.id]));
const productosPerdidos = codigosDeProducto.filter((codigo) => !productoPorCodigo.has(codigo));
if (productosPerdidos.length > 0) {
throw new Error(`No existen en el catálogo comercial: ${productosPerdidos.join(', ')}`);
}
return db.transaction(async (tx) => {
const [creado] = await tx
@ -108,7 +157,7 @@ export async function crearPedido(datos: DatosDePedido): Promise<string> {
sesionPagoId: datos.sesionPagoId,
packs: datos.packs,
totalCentimos: datos.totalCentimos,
moneda: MONEDA
moneda: datos.lineas[0].moneda
})
.onConflictDoNothing({ target: pedido.sesionPagoId })
.returning({ id: pedido.id });
@ -138,6 +187,23 @@ export async function crearPedido(datos: DatosDePedido): Promise<string> {
);
}
await tx.insert(pedidoLinea).values(
datos.lineas.map((linea) => ({
pedidoId: creado.id,
codigo: linea.codigo,
productoId: productoPorCodigo.get(linea.codigo),
tipo: linea.tipo,
nombre: linea.nombre,
descripcion: linea.descripcion,
cantidad: linea.cantidad,
precioUnitarioCentimos: linea.precioUnitarioCentimos,
totalCentimos: importeDeLinea(linea),
moneda: linea.moneda,
modalidad: linea.modalidad,
datos: { recurrencia: linea.recurrencia }
}))
);
return creado.id;
});
}
@ -164,6 +230,14 @@ export async function finalizarPedido(sesionPagoId: string): Promise<boolean> {
.from(pedidoItem)
.where(eq(pedidoItem.pedidoId, registro.id));
const comprados = items.map((i) => i.cancionId);
const productosComprados = (
await tx
.select({ productoId: pedidoLinea.productoId, cantidad: pedidoLinea.cantidad })
.from(pedidoLinea)
.where(eq(pedidoLinea.pedidoId, registro.id))
).filter(
(linea): linea is { productoId: string; cantidad: number } => linea.productoId !== null
);
if (comprados.length > 0) {
await tx
@ -193,6 +267,39 @@ export async function finalizarPedido(sesionPagoId: string): Promise<boolean> {
}
}
if (productosComprados.length > 0) {
const [suCarrito] = await tx
.select({ id: carrito.id })
.from(carrito)
.where(eq(carrito.usuarioId, registro.usuarioId))
.limit(1);
if (suCarrito) {
for (const productoComprado of productosComprados) {
await tx
.delete(carritoProducto)
.where(
and(
eq(carritoProducto.carritoId, suCarrito.id),
eq(carritoProducto.productoId, productoComprado.productoId),
lte(carritoProducto.cantidad, productoComprado.cantidad)
)
);
await tx
.update(carritoProducto)
.set({
cantidad: sql`${carritoProducto.cantidad} - ${productoComprado.cantidad}`
})
.where(
and(
eq(carritoProducto.carritoId, suCarrito.id),
eq(carritoProducto.productoId, productoComprado.productoId),
gt(carritoProducto.cantidad, productoComprado.cantidad)
)
);
}
}
}
return true;
});
}
@ -224,8 +331,9 @@ export async function obtenerPedidoPorSesion(sesionPagoId: string) {
.from(pedidoItem)
.innerJoin(cancion, eq(cancion.id, pedidoItem.cancionId))
.where(eq(pedidoItem.pedidoId, registro.id));
const lineas = await lineasDePedido(registro.id);
return { ...registro, items };
return { ...registro, items, lineas };
}
export interface PedidoConItems {
@ -235,6 +343,26 @@ export interface PedidoConItems {
packs: number;
totalCentimos: number;
items: LineaDePedido[];
lineas: LineaDeCobro[];
}
async function lineasDePedido(pedidoId: string): Promise<LineaDeCobro[]> {
const filas = await db.select().from(pedidoLinea).where(eq(pedidoLinea.pedidoId, pedidoId));
return filas.map(lineaDeFila);
}
function lineaDeFila(linea: typeof pedidoLinea.$inferSelect): LineaDeCobro {
return {
codigo: linea.codigo,
tipo: linea.tipo,
nombre: linea.nombre,
descripcion: linea.descripcion ?? undefined,
cantidad: linea.cantidad,
precioUnitarioCentimos: linea.precioUnitarioCentimos,
moneda: linea.moneda,
modalidad: linea.modalidad,
recurrencia: linea.datos.recurrencia
};
}
/** Historial de pedidos pagados de una cuenta, del mas reciente al primero. */
@ -261,6 +389,15 @@ export async function pedidosDeUsuario(usuarioId: string): Promise<PedidoConItem
cabeceras.map((c) => c.id)
)
);
const detalles = await db
.select()
.from(pedidoLinea)
.where(
inArray(
pedidoLinea.pedidoId,
cabeceras.map((c) => c.id)
)
);
return cabeceras.map((cabecera) => ({
id: cabecera.id,
@ -268,6 +405,7 @@ export async function pedidosDeUsuario(usuarioId: string): Promise<PedidoConItem
estado: cabecera.estado,
packs: cabecera.packs,
totalCentimos: cabecera.totalCentimos,
lineas: detalles.filter((linea) => linea.pedidoId === cabecera.id).map(lineaDeFila),
items: lineas
.filter((l) => l.pedidoId === cabecera.id)
.map((l) => ({ slug: l.slug, titulo: l.titulo }))

@ -12,7 +12,7 @@
import Stripe from 'stripe';
import { env } from '$env/dynamic/private';
import { MONEDA, PRECIO_PACK_CENTIMOS, CANCIONES_POR_PACK } from '$lib/tienda';
import { modalidadDeLineas, validarLineasDeCobro, type LineaDeCobro } from '$lib/comercio';
export class ErrorDePago extends Error {
constructor(mensaje: string) {
@ -37,53 +37,72 @@ function stripe(): Stripe {
}
export interface PeticionDePago {
/** Desglose cobrable, independiente de los derechos que entrega el pedido. */
lineas: LineaDeCobro[];
/** Conteo informativo que aún usa la vista histórica de pedidos musicales. */
packs: number;
precioPack?: number;
expiraEn?: number;
claveIdempotencia?: string;
email: string;
usuarioId: string;
urlExito: string;
urlCancelacion: string;
/** Slugs comprados, para poder reconstruir el pedido desde el webhook. */
/** Canciones entregadas; otros productos tendrán su propio concedente. */
slugs: string[];
}
/**
* Crea la sesion de pago y devuelve su id y la URL a la que redirigir.
*
* El importe no se envia como total: se envia el precio del pack y la
* cantidad, para que la factura de Stripe diga exactamente lo mismo que el
* carrito de la web.
* Stripe recibe el mismo desglose que se congela en el pedido. Esto admite un
* pack, varios, artículos unitarios y planes recurrentes sin abrir ramas de
* código por cada producto.
*/
export async function crearSesionDePago(peticion: PeticionDePago): Promise<{
id: string;
url: string;
}> {
validarLineasDeCobro(peticion.lineas);
const modalidad = modalidadDeLineas(peticion.lineas);
const sesion = await stripe().checkout.sessions.create(
{
mode: 'payment',
mode: modalidad === 'suscripcion' ? 'subscription' : 'payment',
expires_at: peticion.expiraEn,
customer_email: peticion.email,
client_reference_id: peticion.usuarioId,
line_items: [
{
quantity: peticion.packs,
price_data: {
currency: MONEDA,
unit_amount: peticion.precioPack ?? PRECIO_PACK_CENTIMOS,
product_data: {
name: `Pack de ${CANCIONES_POR_PACK} canciones`,
description: 'Descarga en MP3 de las canciones elegidas, con todas sus versiones.'
}
}
line_items: peticion.lineas.map((linea) => ({
quantity: linea.cantidad,
price_data: {
currency: linea.moneda,
unit_amount: linea.precioUnitarioCentimos,
product_data: {
name: linea.nombre,
description: linea.descripcion
},
...(linea.recurrencia
? {
recurring: {
interval: {
dia: 'day',
semana: 'week',
mes: 'month',
ano: 'year'
}[linea.recurrencia.periodicidad] as Stripe.Price.Recurring.Interval,
interval_count: linea.recurrencia.intervalos
}
}
: {})
}
],
})),
metadata: {
usuarioId: peticion.usuarioId,
// Metadato informativo: la fuente de verdad son las filas de
// pedido_item, no esta cadena.
canciones: peticion.slugs.join(',').slice(0, 480)
canciones: peticion.slugs.join(',').slice(0, 480),
lineas: peticion.lineas
.map((linea) => `${linea.codigo}:${linea.cantidad}`)
.join(',')
.slice(0, 480)
},
success_url: peticion.urlExito,
cancel_url: peticion.urlCancelacion

@ -12,8 +12,9 @@ describe('resumirCarrito', () => {
expect(resumirCarrito(0)).toEqual({
canciones: 0,
packs: 0,
faltan: 0,
completo: false,
disponibles: 0,
pagable: false,
lineasDeCobro: [],
totalCentimos: 0
});
});
@ -21,8 +22,8 @@ describe('resumirCarrito', () => {
it('un pack justo está completo', () => {
expect(resumirCarrito(CANCIONES_POR_PACK)).toMatchObject({
packs: 1,
faltan: 0,
completo: true,
disponibles: 0,
pagable: true,
totalCentimos: PRECIO_PACK_CENTIMOS
});
});
@ -30,33 +31,34 @@ describe('resumirCarrito', () => {
it('el doble son dos packs', () => {
expect(resumirCarrito(2 * CANCIONES_POR_PACK)).toMatchObject({
packs: 2,
completo: true,
pagable: true,
totalCentimos: 2 * PRECIO_PACK_CENTIMOS
});
});
it('un pack a medias no se puede pagar', () => {
// Es la regla del negocio: solo packs cerrados.
it('cualquier cantidad positiva se puede pagar como un pack', () => {
for (let n = 1; n < CANCIONES_POR_PACK; n++) {
expect(resumirCarrito(n).completo).toBe(false);
expect(resumirCarrito(n)).toMatchObject({
packs: 1,
pagable: true,
totalCentimos: PRECIO_PACK_CENTIMOS
});
}
});
it('dice cuántas faltan para cerrar el pack', () => {
expect(resumirCarrito(1).faltan).toBe(CANCIONES_POR_PACK - 1);
expect(resumirCarrito(CANCIONES_POR_PACK - 1).faltan).toBe(1);
it('dice cuántas canciones caben todavía por el mismo precio', () => {
expect(resumirCarrito(1).disponibles).toBe(CANCIONES_POR_PACK - 1);
expect(resumirCarrito(CANCIONES_POR_PACK - 1).disponibles).toBe(1);
// Y vuelve a contar desde cero en el pack siguiente.
expect(resumirCarrito(CANCIONES_POR_PACK + 1).faltan).toBe(CANCIONES_POR_PACK - 1);
expect(resumirCarrito(2 * CANCIONES_POR_PACK - 1).faltan).toBe(1);
expect(resumirCarrito(CANCIONES_POR_PACK + 1).disponibles).toBe(CANCIONES_POR_PACK - 1);
expect(resumirCarrito(2 * CANCIONES_POR_PACK - 1).disponibles).toBe(1);
});
it('solo cobra los packs cerrados', () => {
// Un pack completo y dos sueltas: las sueltas no se cobran, y el pago
// sigue bloqueado hasta completarlas.
it('resuelve un pack completo y dos canciones más como dos packs', () => {
const resumen = resumirCarrito(CANCIONES_POR_PACK + 2);
expect(resumen.packs).toBe(1);
expect(resumen.totalCentimos).toBe(PRECIO_PACK_CENTIMOS);
expect(resumen.completo).toBe(false);
expect(resumen.packs).toBe(2);
expect(resumen.totalCentimos).toBe(2 * PRECIO_PACK_CENTIMOS);
expect(resumen.pagable).toBe(true);
});
it('ignora cantidades imposibles', () => {
@ -84,21 +86,21 @@ describe('mensajeDeCarrito', () => {
expect(mensajeDeCarrito(resumirCarrito(0))).toMatch(/vacío/);
});
it('cuenta los packs cuando está completo', () => {
it('cuenta los packs resueltos', () => {
expect(mensajeDeCarrito(resumirCarrito(CANCIONES_POR_PACK))).toBe(
`1 pack de ${CANCIONES_POR_PACK} canciones.`
`${CANCIONES_POR_PACK} canciones se cobrarán como 1 pack.`
);
expect(mensajeDeCarrito(resumirCarrito(2 * CANCIONES_POR_PACK))).toBe(
`2 packs de ${CANCIONES_POR_PACK} canciones.`
`${2 * CANCIONES_POR_PACK} canciones se cobrarán como 2 packs.`
);
});
it('concuerda el singular al faltar una sola', () => {
expect(mensajeDeCarrito(resumirCarrito(CANCIONES_POR_PACK - 1))).toBe(
'Falta 1 canción para completar el pack.'
it('explica los huecos que no aumentan el precio', () => {
expect(mensajeDeCarrito(resumirCarrito(1))).toBe(
'1 canción se cobrará como 1 pack. Puedes añadir 9 más sin cambiar el precio.'
);
expect(mensajeDeCarrito(resumirCarrito(CANCIONES_POR_PACK - 2))).toBe(
'Faltan 2 canciones para completar el pack.'
expect(mensajeDeCarrito(resumirCarrito(CANCIONES_POR_PACK - 1))).toBe(
'9 canciones se cobrarán como 1 pack. Puedes añadir 1 más sin cambiar el precio.'
);
});
});

@ -1,51 +1,95 @@
/**
* Reglas de la tienda.
*
* Las canciones se venden en packs cerrados de diez: no hay precio por tema
* suelto, y el pago se bloquea hasta que el carrito tiene un multiplo de diez.
* Toda esa aritmetica vive aqui, en funciones puras, porque la usan por igual
* la pagina del carrito y la accion de pago del servidor, y no puede haber dos
* versiones del calculo.
* Las canciones se agrupan al pagar en packs de hasta diez. El carrito no se
* bloquea por dejar huecos: de una a diez canciones forman un pack, de once a
* veinte forman dos, y así sucesivamente. La selección y la facturación son
* conceptos distintos; los demás artículos producen sus propias líneas.
*
* Diez canciones por el precio de un cafe. Es la propuesta, y por eso el numero
* y el importe se leen juntos en todas partes: sueltos no dicen nada.
*/
/** Canciones que componen un pack. */
import type { ArticuloDeCarrito, LineaDeCobro } from './comercio';
import { totalDeLineas } from './comercio';
/** Capacidad máxima de canciones de cada pack. */
export const CANCIONES_POR_PACK = 10;
/** Precio de un pack, en centimos. */
export const PRECIO_PACK_CENTIMOS = 150;
export const MONEDA = 'eur';
export const CODIGO_PACK_CANCIONES = 'pack-canciones-10';
export interface ResumenCarrito {
/** Canciones en el carrito. */
canciones: number;
/** Packs completos que se van a cobrar. */
packs: number;
/** Canciones que faltan para cerrar el pack empezado. */
faltan: number;
/** `true` cuando el carrito se puede pagar. */
completo: boolean;
/** Importe a cobrar, en centimos. Solo tiene sentido si `completo`. */
/** Canciones que aún caben en los packs ya incluidos en el total. */
disponibles: number;
/** `true` cuando hay algo que cobrar. */
pagable: boolean;
/** Líneas resueltas para la pasarela y el pedido. */
lineasDeCobro: LineaDeCobro[];
/** Importe a cobrar, en céntimos. */
totalCentimos: number;
}
export function resumirCarrito(canciones: number): ResumenCarrito {
export interface CompraPorResolver {
canciones: number;
articulos?: readonly ArticuloDeCarrito[];
}
/** Convierte la selección heterogénea en líneas cobrables. */
export function resolverCompra({ canciones, articulos = [] }: CompraPorResolver): ResumenCarrito {
const n = Math.max(0, Math.floor(canciones));
const resto = n % CANCIONES_POR_PACK;
const packs = Math.floor(n / CANCIONES_POR_PACK);
const packs = n === 0 ? 0 : Math.ceil(n / CANCIONES_POR_PACK);
const lineasDeCobro: LineaDeCobro[] = [];
if (packs > 0) {
lineasDeCobro.push({
codigo: CODIGO_PACK_CANCIONES,
tipo: 'pack_canciones',
nombre: `Pack de hasta ${CANCIONES_POR_PACK} canciones`,
descripcion: 'Descarga en MP3 de las canciones elegidas, con todas sus versiones.',
cantidad: packs,
precioUnitarioCentimos: PRECIO_PACK_CENTIMOS,
moneda: MONEDA,
modalidad: 'pago_unico'
});
}
for (const articulo of articulos) {
const cantidad = Math.max(0, Math.floor(articulo.cantidad));
if (cantidad === 0) continue;
lineasDeCobro.push({
codigo: articulo.codigo,
tipo: articulo.tipo,
nombre: articulo.nombre,
descripcion: articulo.descripcion,
cantidad,
precioUnitarioCentimos: articulo.precioUnitarioCentimos,
moneda: articulo.moneda ?? MONEDA,
modalidad: 'pago_unico'
});
}
return {
canciones: n,
packs,
faltan: resto === 0 ? 0 : CANCIONES_POR_PACK - resto,
completo: n > 0 && resto === 0,
totalCentimos: packs * PRECIO_PACK_CENTIMOS
disponibles: packs * CANCIONES_POR_PACK - n,
pagable: lineasDeCobro.length > 0,
lineasDeCobro,
totalCentimos: totalDeLineas(lineasDeCobro)
};
}
export function resumirCarrito(canciones: number): ResumenCarrito {
return resolverCompra({ canciones });
}
/** "1,50 €" */
export function formatearPrecio(centimos: number, locale = 'es-ES'): string {
return new Intl.NumberFormat(locale, {
@ -54,14 +98,12 @@ export function formatearPrecio(centimos: number, locale = 'es-ES'): string {
}).format(centimos / 100);
}
/** Mensaje que explica que le falta al carrito para poder pagarse. */
/** Mensaje que explica cómo se resolverá la selección al cobrar. */
export function mensajeDeCarrito(resumen: ResumenCarrito): string {
if (resumen.canciones === 0) return 'El carrito está vacío.';
if (resumen.completo) {
const packs = resumen.packs === 1 ? '1 pack' : `${resumen.packs} packs`;
return `${packs} de ${CANCIONES_POR_PACK} canciones.`;
}
return resumen.faltan === 1
? 'Falta 1 canción para completar el pack.'
: `Faltan ${resumen.faltan} canciones para completar el pack.`;
const canciones = resumen.canciones === 1 ? '1 canción' : `${resumen.canciones} canciones`;
const packs = resumen.packs === 1 ? '1 pack' : `${resumen.packs} packs`;
const base = `${canciones} ${resumen.canciones === 1 ? 'se cobrará' : 'se cobrarán'} como ${packs}.`;
if (resumen.disponibles === 0) return base;
return `${base} Puedes añadir ${resumen.disponibles === 1 ? '1 más' : `${resumen.disponibles} más`} sin cambiar el precio.`;
}

@ -1,4 +1,4 @@
import { leerCarrito } from '$lib/server/carrito';
import { contarProductosDelCarrito, leerCarrito } from '$lib/server/carrito';
import { favoritosDeUsuario } from '$lib/server/favoritos';
import { comprasDeUsuario } from '$lib/server/pedidos';
import { proveedoresDisponibles } from '$lib/server/oauth';
@ -32,25 +32,36 @@ export async function load({ locals, cookies, url }) {
/^(?:\/$|\/(?:musica|albumes|canciones|etiquetas|carrito|favoritos|listas|interpretes|bio|blog)(?:\/|$))/.test(
url.pathname
);
const [carrito, compras, favoritos, listas, legales, catalogo, valoraciones, accesoCanciones] =
await Promise.all([
leerCarrito(cookies, usuario?.id ?? null),
usuario ? comprasDeUsuario(usuario.id) : Promise.resolve([]),
usuario ? favoritosDeUsuario(usuario.id) : Promise.resolve([]),
usuario && necesitaCatalogo ? listasDeUsuario(usuario.id) : Promise.resolve([]),
indiceLegal(),
necesitaCatalogo
? leerCatalogo()
: Promise.resolve({ estilos: [], albumes: [], canciones: [] }),
necesitaCatalogo ? resumenesDeCanciones() : Promise.resolve({}),
contextoDeAccesoCanciones(usuario?.id)
]);
const [
carrito,
productosEnCarrito,
compras,
favoritos,
listas,
legales,
catalogo,
valoraciones,
accesoCanciones
] = await Promise.all([
leerCarrito(cookies, usuario?.id ?? null),
contarProductosDelCarrito(cookies, usuario?.id ?? null),
usuario ? comprasDeUsuario(usuario.id) : Promise.resolve([]),
usuario ? favoritosDeUsuario(usuario.id) : Promise.resolve([]),
usuario && necesitaCatalogo ? listasDeUsuario(usuario.id) : Promise.resolve([]),
indiceLegal(),
necesitaCatalogo
? leerCatalogo()
: Promise.resolve({ estilos: [], albumes: [], canciones: [] }),
necesitaCatalogo ? resumenesDeCanciones() : Promise.resolve({}),
contextoDeAccesoCanciones(usuario?.id)
]);
return {
// Solo lo que la interfaz necesita: nada de fechas internas ni ids de
// sesión viajando al cliente.
usuario: usuario ? { id: usuario.id, email: usuario.email } : null,
carrito,
cantidadCarrito: carrito.length + productosEnCarrito,
compras,
// Los favoritos viajan enteros, como el carrito: son unas decenas de
// slugs y cualquier tarjeta necesita saber si el suyo esta dentro.

@ -24,7 +24,7 @@
];
/* Con `?.`: en una página de error el layout puede no haberse ejecutado. */
const enCarrito = $derived(page.data.carrito?.length ?? 0);
const enCarrito = $derived(page.data.cantidadCarrito ?? page.data.carrito?.length ?? 0);
const principal = $derived(data.bloquePrincipal);
const pistasPrincipal = $derived(
@ -66,10 +66,10 @@
<LaminaCta
imagen={heroe.cafe}
eyebrow="La tienda"
titulo="{CANCIONES_POR_PACK} canciones por un café"
titulo="Hasta {CANCIONES_POR_PACK} canciones por un café"
texto="{formatearPrecio(
PRECIO_PACK_CENTIMOS
)} el pack, en MP3 y con todas sus versiones. En la web se escucha la canción completa a calidad reducida; la compra entrega el archivo de mayor calidad."
)} cada pack, en MP3 y con todas sus versiones. El carrito agrupa automáticamente cualquier cantidad de canciones. En la web se escucha la canción completa a calidad reducida; la compra entrega el archivo de mayor calidad."
>
{#snippet acciones()}
<a class="btn btn--primary" href={resolve('/musica')}>Elegir canciones</a>

@ -168,7 +168,7 @@
{#if cancion.alaVenta}
<p class="venta muted">
Escúchala completa aquí a calidad reducida. La compra entrega el archivo de mayor
calidad, con todas sus versiones, en packs de {CANCIONES_POR_PACK} canciones por {formatearPrecio(
calidad, con todas sus versiones, en packs de hasta {CANCIONES_POR_PACK} canciones por {formatearPrecio(
PRECIO_PACK_CENTIMOS
)}.
</p>

@ -1,11 +1,14 @@
import { fail, redirect } from '@sveltejs/kit';
import { obtenerCancion } from '$lib/server/catalogo';
import { mensajeDeCarrito, resumirCarrito } from '$lib/tienda';
import { mensajeDeCarrito, resolverCompra } from '$lib/tienda';
import {
anadirAlCarrito,
anadirProductoAlCarrito,
asegurarCarrito,
leerCarrito,
leerProductosDelCarrito,
quitarDelCarrito,
quitarProductoDelCarrito,
vaciarCarrito
} from '$lib/server/carrito';
import { comprasDeUsuario, tieneCompra } from '$lib/server/pedidos';
@ -13,14 +16,18 @@ import { ErrorDePago, pagosConfigurados } from '$lib/server/stripe';
import { abrirCheckout } from '$lib/server/checkout';
import type { Actions } from './$types';
import { destinoSeguro } from '$lib/server/destino';
import { articuloDeProducto } from '$lib/comercio';
/**
* Las canciones del carrito no hacen falta aqui: el catalogo es estatico y
* viaja al cliente, asi que la pagina resuelve los slugs de `page.data.carrito`
* por su cuenta. Este modulo solo aporta las acciones.
*/
export function load() {
return { pagosActivos: pagosConfigurados() };
export async function load({ cookies, locals }) {
return {
pagosActivos: pagosConfigurados(),
productos: await leerProductosDelCarrito(cookies, locals.usuario?.id ?? null)
};
}
export const actions: Actions = {
@ -44,6 +51,18 @@ export const actions: Actions = {
redirect(303, volverA);
},
anadirProducto: async ({ request, cookies, locals }) => {
const datos = await request.formData();
const codigo = String(datos.get('producto') ?? '');
const cantidad = Number(datos.get('cantidad') ?? 1);
const volverA = destinoSeguro(datos.get('volverA'), '/carrito');
const carritoId = await asegurarCarrito(cookies, locals.usuario?.id ?? null);
if (!(await anadirProductoAlCarrito(carritoId, codigo, cantidad))) {
return fail(400, { error: 'Ese artículo no está disponible.' });
}
redirect(303, volverA);
},
quitar: async ({ request, cookies, locals }) => {
const datos = await request.formData();
const slug = String(datos.get('cancion') ?? '');
@ -55,6 +74,15 @@ export const actions: Actions = {
redirect(303, volverA);
},
quitarProducto: async ({ request, cookies, locals }) => {
const datos = await request.formData();
const codigo = String(datos.get('producto') ?? '');
const volverA = destinoSeguro(datos.get('volverA'), '/carrito');
const carritoId = await asegurarCarrito(cookies, locals.usuario?.id ?? null);
await quitarProductoDelCarrito(carritoId, codigo);
redirect(303, volverA);
},
vaciar: async ({ cookies, locals }) => {
const carritoId = await asegurarCarrito(cookies, locals.usuario?.id ?? null);
await vaciarCarrito(carritoId);
@ -78,8 +106,14 @@ export const actions: Actions = {
comprables.push(cancion);
}
const resumen = resumirCarrito(comprables.length);
if (!resumen.completo) {
const productos = (await leerProductosDelCarrito(cookies, locals.usuario.id)).filter(
(producto) => producto.estado === 'disponible'
);
const resumen = resolverCompra({
canciones: comprables.length,
articulos: productos.map(articuloDeProducto)
});
if (!resumen.pagable) {
return fail(400, { error: mensajeDeCarrito(resumen) });
}
@ -95,6 +129,7 @@ export const actions: Actions = {
destino = await abrirCheckout(
{
packs: resumen.packs,
lineas: resumen.lineasDeCobro,
email: locals.usuario.email,
usuarioId: locals.usuario.id,
slugs: comprables.map((c) => c.slug),

@ -15,10 +15,11 @@
formatearPrecio,
mensajeDeCarrito,
PRECIO_PACK_CENTIMOS,
resumirCarrito
resolverCompra
} from '$lib/tienda';
import SinPortada from '$lib/components/SinPortada.svelte';
import type { Cancion } from '$lib/types';
import { articuloDeProducto } from '$lib/comercio';
let { data, form } = $props();
let pagando = $state(false);
@ -32,18 +33,25 @@
data.carrito.map(obtenerCancion).filter((c): c is Cancion => c !== undefined)
);
const resumen = $derived(resumirCarrito(enCarrito.length));
const productosDisponibles = $derived(data.productos.filter((p) => p.estado === 'disponible'));
const productosRetirados = $derived(data.productos.filter((p) => p.estado !== 'disponible'));
const resumen = $derived(
resolverCompra({
canciones: enCarrito.length,
articulos: productosDisponibles.map(articuloDeProducto)
})
);
/** Sugerencias para cerrar el pack empezado. */
/** Sugerencias para aprovechar los huecos ya incluidos en el precio. */
const sugerencias = $derived(
resumen.faltan === 0
resumen.disponibles === 0
? []
: canciones()
.filter(
(c) => c.alaVenta && !data.carrito.includes(c.slug) && !data.compras.includes(c.slug)
)
.sort((a, b) => Number(b.destacada) - Number(a.destacada))
.slice(0, resumen.faltan)
.slice(0, resumen.disponibles)
);
const volverA = '/carrito';
@ -55,9 +63,10 @@
<header class="intro">
<h1>Carrito</h1>
<p class="entradilla">
Las canciones se venden en packs de {CANCIONES_POR_PACK}, a {formatearPrecio(
Las canciones se agrupan al pagar en packs de hasta {CANCIONES_POR_PACK}, a {formatearPrecio(
PRECIO_PACK_CENTIMOS
)} el pack. Cada compra incluye la descarga en MP3 con todas las versiones del tema.
)} cada uno. Puedes pagar cualquier cantidad; el carrito calcula automáticamente cuántos packs hacen
falta. Cada compra incluye la descarga en MP3 con todas las versiones del tema.
</p>
</header>
@ -65,60 +74,93 @@
<p class="aviso error" role="alert">{form.error}</p>
{/if}
{#if enCarrito.length === 0}
{#if enCarrito.length === 0 && data.productos.length === 0}
<p class="muted">
No has añadido ninguna canción todavía. Empieza por <a href={resolve('/musica')}>la música</a
>.
No has añadido nada todavía. Empieza por <a href={resolve('/musica')}>la música</a>.
</p>
{:else}
<div class="disposicion">
<section aria-label="Canciones en el carrito">
<ul class="lista">
{#each enCarrito as cancion (cancion.slug)}
{@const album = obtenerAlbum(albumDeCancion(cancion))}
<li class="fila">
{#if portadaDeCancion(cancion)}
<img
class="portada"
src={portadaDeCancion(cancion)}
alt=""
width="48"
height="48"
loading="lazy"
/>
{:else}
<SinPortada titulo={cancion.titulo} lado={48} muda />
{/if}
<div class="datos">
<a
class="titulo"
href={resolve('/(sitio)/canciones/[slug]', { slug: cancion.slug })}
>
{cancion.titulo}
</a>
<p class="contexto muted">
{album?.titulo ?? ''}
{#if cancion.versiones.length > 0}
<span aria-hidden="true">&middot;</span>
{cancion.versiones.length + 1} versiones
<div>
{#if enCarrito.length > 0}
<section aria-labelledby="carrito-canciones">
<h2 id="carrito-canciones" class="subtitulo">Canciones</h2>
<ul class="lista">
{#each enCarrito as cancion (cancion.slug)}
{@const album = obtenerAlbum(albumDeCancion(cancion))}
<li class="fila">
{#if portadaDeCancion(cancion)}
<img
class="portada"
src={portadaDeCancion(cancion)}
alt=""
width="48"
height="48"
loading="lazy"
/>
{:else}
<SinPortada titulo={cancion.titulo} lado={48} muda />
{/if}
</p>
</div>
<form method="POST" action="?/quitar" use:enhance>
<input type="hidden" name="cancion" value={cancion.slug} />
<input type="hidden" name="volverA" value={volverA} />
<button type="submit" class="quitar">Quitar</button>
</form>
</li>
{/each}
</ul>
<div class="datos">
<a
class="titulo"
href={resolve('/(sitio)/canciones/[slug]', { slug: cancion.slug })}
>
{cancion.titulo}
</a>
<p class="contexto muted">
{album?.titulo ?? ''}
{#if cancion.versiones.length > 0}
<span aria-hidden="true">&middot;</span>
{cancion.versiones.length + 1} versiones
{/if}
</p>
</div>
<form method="POST" action="?/quitar" use:enhance>
<input type="hidden" name="cancion" value={cancion.slug} />
<input type="hidden" name="volverA" value={volverA} />
<button type="submit" class="quitar">Quitar</button>
</form>
</li>
{/each}
</ul>
</section>
{/if}
{#if data.productos.length > 0}
<section class="articulos" aria-labelledby="carrito-articulos">
<h2 id="carrito-articulos" class="subtitulo">Otros artículos y servicios</h2>
<ul class="lista">
{#each data.productos as producto (producto.codigo)}
<li class:retirado={producto.estado !== 'disponible'} class="fila producto">
<div class="producto-icono" aria-hidden="true">
{producto.tipo.slice(0, 1).toUpperCase()}
</div>
<div class="datos">
<strong class="titulo">{producto.nombre}</strong>
<p class="contexto muted">
{producto.cantidad} × {formatearPrecio(producto.precioCentimos)}
{#if producto.estado !== 'disponible'}
<span aria-hidden="true">&middot;</span> Ya no está disponible
{/if}
</p>
</div>
<form method="POST" action="?/quitarProducto" use:enhance>
<input type="hidden" name="producto" value={producto.codigo} />
<input type="hidden" name="volverA" value={volverA} />
<button type="submit" class="quitar">Quitar</button>
</form>
</li>
{/each}
</ul>
</section>
{/if}
<form method="POST" action="?/vaciar" use:enhance class="vaciar">
<button type="submit" class="quitar">Vaciar el carrito</button>
</form>
</section>
</div>
<aside class="resumen">
<h2>Resumen</h2>
@ -132,13 +174,26 @@
<dt>Packs</dt>
<dd>{resumen.packs}</dd>
</div>
{#if productosDisponibles.length > 0}
<div>
<dt>Otros artículos</dt>
<dd>{productosDisponibles.reduce((total, p) => total + p.cantidad, 0)}</dd>
</div>
{/if}
<div class="total">
<dt>Total</dt>
<dd>{formatearPrecio(resumen.totalCentimos)}</dd>
</div>
</dl>
<p class="estado" class:incompleto={!resumen.completo}>{mensajeDeCarrito(resumen)}</p>
{#if resumen.canciones > 0}
<p class="estado">{mensajeDeCarrito(resumen)}</p>
{/if}
{#if productosRetirados.length > 0}
<p class="estado aviso-producto">
Los artículos no disponibles no se incluirán en el pago.
</p>
{/if}
{#if !data.usuario}
<a
@ -167,7 +222,7 @@
<button
class="btn btn--primary bloque"
type="submit"
disabled={!resumen.completo || pagando}
disabled={!resumen.pagable || pagando}
>
Pagar {formatearPrecio(resumen.totalCentimos)}
</button>
@ -184,8 +239,8 @@
{#if sugerencias.length > 0}
<section class="sugerencias">
<h2>Completa el pack</h2>
<p class="muted">{mensajeDeCarrito(resumen)} Estas podrían encajar:</p>
<h2>Aprovecha el espacio incluido</h2>
<p class="muted">Estas canciones no aumentarían ahora el total:</p>
<ul class="lista">
{#each sugerencias as cancion (cancion.slug)}
@ -350,10 +405,6 @@
font-size: var(--fs-sm);
}
.estado.incompleto {
color: var(--c-danger);
}
.bloque {
display: block;
width: 100%;
@ -381,6 +432,34 @@
padding-block: var(--sp-6);
}
.subtitulo {
margin: 0 0 var(--sp-2);
font-size: var(--fs-base);
}
.articulos {
margin-top: var(--sp-6);
}
.producto-icono {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border: 1px solid var(--c-border);
border-radius: 3px;
font-weight: 700;
color: var(--c-text-muted);
}
.retirado {
opacity: 0.65;
}
.aviso-producto {
color: var(--c-danger);
}
@media (max-width: 48rem) {
.disposicion {
grid-template-columns: minmax(0, 1fr);

@ -34,6 +34,7 @@ export async function load({ url, locals }) {
return {
pagado: pedido.estado === 'pagado',
items: pedido.items,
lineas: pedido.lineas,
totalCentimos: pedido.totalCentimos,
packs: pedido.packs
};

@ -14,10 +14,20 @@
{#if data.pagado}
<h1>Gracias por la compra</h1>
<p class="entradilla">
{data.packs === 1 ? '1 pack' : `${data.packs} packs`} ·
{formatearPrecio(data.totalCentimos)}. Las descargas ya están en tu cuenta y no caducan.
{formatearPrecio(data.totalCentimos)}. Tus compras ya están en la cuenta y no caducan.
</p>
{#if data.lineas.length > 0}
<ul class="desglose" aria-label="Desglose del pedido">
{#each data.lineas as linea (linea.codigo)}
<li>
<span>{linea.nombre} × {linea.cantidad}</span>
<span>{formatearPrecio(linea.precioUnitarioCentimos * linea.cantidad)}</span>
</li>
{/each}
</ul>
{/if}
<ul class="lista">
{#each data.items as item (item.slug)}
<li>
@ -59,6 +69,22 @@
border-top: 1px solid var(--c-border);
}
.desglose {
list-style: none;
margin: 0 0 var(--sp-5);
padding: var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
}
.desglose li {
display: flex;
justify-content: space-between;
gap: var(--sp-3);
font-size: var(--fs-sm);
}
.lista li {
display: flex;
justify-content: space-between;

@ -1,5 +1,5 @@
import { db } from '$lib/server/db';
import { suscripcion } from '$lib/server/db/schema';
import { planSuscripcion, suscripcion } from '$lib/server/db/schema';
import { desc, eq } from 'drizzle-orm';
import { redirect } from '@sveltejs/kit';
import { pedidosDeUsuario } from '$lib/server/pedidos';
@ -18,11 +18,14 @@ export async function load({ locals }) {
db
.select({
estado: suscripcion.estado,
plan: planSuscripcion.nombre,
nivel: planSuscripcion.nivel,
desde: suscripcion.iniciaEn,
hasta: suscripcion.terminaEn,
proveedor: suscripcion.proveedor
})
.from(suscripcion)
.leftJoin(planSuscripcion, eq(planSuscripcion.id, suscripcion.planId))
.where(eq(suscripcion.usuarioId, locals.usuario.id))
.orderBy(desc(suscripcion.creadaEn))
.limit(10)

@ -49,6 +49,14 @@
<section class="bloque">
<h2>Suscripción</h2>
{#if data.suscripciones[0]?.plan}
<p>
Plan {data.suscripciones[0].plan}
{#if data.suscripciones[0].nivel}
<span class="muted">· nivel {data.suscripciones[0].nivel}</span>
{/if}
</p>
{/if}
{#if data.suscripciones[0]?.hasta}<p>
Último periodo concedido: hasta {formatearFecha(
data.suscripciones[0].hasta.toISOString().slice(0, 10)
@ -82,7 +90,11 @@
{formatearFecha(pedido.fecha.toISOString().slice(0, 10))}
</time>
<span class="muted">
{pedido.packs === 1 ? '1 pack' : `${pedido.packs} packs`} ·
{pedido.lineas.length > 0
? pedido.lineas.map((linea) => `${linea.cantidad} × ${linea.nombre}`).join(' · ')
: pedido.packs === 1
? '1 pack'
: `${pedido.packs} packs`} ·
{formatearPrecio(pedido.totalCentimos)} · {pedido.estado}
</span>
</p>

Loading…
Cancel
Save

Powered by TurnKey Linux.