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.
|
||||
@ -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
@ -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}).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue