chore(checkpoint): save club core backend and pending pos fixes

This commit is contained in:
Deploy
2026-08-26 17:58:45 +02:00
parent cf3c906ed2
commit 49dfd00406
44 changed files with 2241 additions and 219 deletions

View File

@@ -671,8 +671,8 @@ export default function RegisterPage() {
sku: item.sku,
name: item.name,
ean: null,
unitPriceCents: item.unitPriceCents,
discountCents: item.discountCents,
unitPriceCents: Math.max(item.unitPriceCents - item.discountCents, 0),
discountCents: 0,
taxCents: 0,
quantity: item.quantity - item.returnedQuantity,
stock: null,
@@ -744,8 +744,8 @@ export default function RegisterPage() {
sku: item.sku,
name: item.name,
ean: null,
unitPriceCents: item.unitPriceCents,
discountCents: item.discountCents,
unitPriceCents: Math.max(item.unitPriceCents - item.discountCents, 0),
discountCents: 0,
taxCents: 0,
quantity: item.quantity - item.returnedQuantity,
stock: null,

View File

@@ -100,6 +100,17 @@ function parseItems(raw: RawItem[] | undefined): {
});
}
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
const text = await response.text();
if (!text) return fallback;
try {
const data = JSON.parse(text) as { error?: { message?: string }; message?: string };
return data.error?.message ?? data.message ?? text;
} catch {
return text;
}
}
async function syncCart(
cookies: string,
items: { productId: string; variantId: string; quantity: number }[],
@@ -125,7 +136,9 @@ async function syncCart(
}
const nextByVariant = new Map(items.map((item) => [item.variantId, item]));
const currentByVariant = new Map(serverItems.map((item) => [item.variantId, item]));
const operations: Promise<Response>[] = [];
for (const item of serverItems) {
if (!nextByVariant.has(item.variantId)) {
operations.push(
@@ -136,20 +149,35 @@ async function syncCart(
);
}
}
for (const item of items) {
operations.push(
fetch(`${API}/cart/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body: JSON.stringify(item),
}),
);
const existing = currentByVariant.get(item.variantId);
if (!existing) {
operations.push(
fetch(`${API}/cart/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body: JSON.stringify(item),
}),
);
continue;
}
if (existing.quantity !== item.quantity) {
operations.push(
fetch(`${API}/cart/items/${item.variantId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body: JSON.stringify({ quantity: item.quantity }),
}),
);
}
}
const results = await Promise.all(operations);
for (const response of results) {
if (!response.ok && response.status !== 404) {
const message = await response.text();
return { ok: false, status: response.status, message: message || 'Error al sincronizar el carrito' };
const message = await readErrorMessage(response, 'Error al sincronizar el carrito');
return { ok: false, status: response.status, message };
}
}
return { ok: true };

View File

@@ -5,10 +5,12 @@
*/
export const up = (pgm) => {
pgm.addColumn('catalog_product_variants', 'weight_grams', {
type: 'integer',
notNull: false,
default: null,
pgm.addColumn('catalog_product_variants', {
weight_grams: {
type: 'integer',
notNull: false,
default: null,
},
});
};

View File

@@ -6,20 +6,22 @@
*/
export const up = (pgm) => {
pgm.addColumn('identity_users', 'confirmation_token', {
type: 'string',
notNull: false,
default: null,
});
pgm.addColumn('identity_users', 'confirmed_at', {
type: 'timestamp',
notNull: false,
default: null,
});
pgm.addColumn('identity_users', 'email_confirmed', {
type: 'boolean',
notNull: true,
default: false,
pgm.addColumn('identity_users', {
confirmation_token: {
type: 'text',
notNull: false,
default: null,
},
confirmed_at: {
type: 'timestamptz',
notNull: false,
default: null,
},
email_confirmed: {
type: 'boolean',
notNull: true,
default: false,
},
});
// FEAT-199: migrate existing users to confirmed (they already verified their email during signup)
pgm.sql('UPDATE identity_users SET email_confirmed = true');

View File

@@ -0,0 +1,147 @@
/**
* CLUB-001 — Club de Clientes core backend.
*
* Phase 1 schema:
* - anonymous members + device tokens
* - ledger transactions as source of truth
* - future-ready recovery/campaign/reward tables
* - club config seeds in store_settings
*/
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES identity_users(id) ON DELETE SET NULL,
member_code text NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'active',
tier_code text NOT NULL DEFAULT 'base',
current_balance_cents integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT club_members_status_check CHECK (status IN ('active', 'blocked', 'merged')),
CONSTRAINT club_members_balance_non_negative CHECK (current_balance_cents >= 0),
CONSTRAINT club_members_member_code_format CHECK (member_code ~ '^MDV-[A-Z0-9]{8}$')
)
`);
pgm.sql(`
CREATE UNIQUE INDEX IF NOT EXISTS club_members_user_id_unique_idx
ON club_members (user_id)
WHERE user_id IS NOT NULL
`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES club_members(id) ON DELETE CASCADE,
device_token_hash text NOT NULL UNIQUE,
last_used_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz
)
`);
pgm.sql(`CREATE INDEX IF NOT EXISTS club_devices_member_id_idx ON club_devices (member_id)`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
kind text NOT NULL DEFAULT 'generic',
status text NOT NULL DEFAULT 'draft',
config jsonb NOT NULL DEFAULT '{}'::jsonb,
starts_at timestamptz,
ends_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT club_campaigns_status_check CHECK (status IN ('draft', 'active', 'archived'))
)
`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_transactions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES club_members(id) ON DELETE CASCADE,
sale_id uuid REFERENCES orders_orders(id) ON DELETE SET NULL,
store_id uuid REFERENCES pos_stores(id) ON DELETE SET NULL,
type text NOT NULL,
amount_cents integer NOT NULL DEFAULT 0,
balance_delta_cents integer NOT NULL,
idempotency_key text UNIQUE,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT club_transactions_type_check CHECK (type IN ('earn', 'redeem', 'refund', 'bonus', 'adjustment')),
CONSTRAINT club_transactions_amount_non_negative CHECK (amount_cents >= 0),
CONSTRAINT club_transactions_balance_delta_non_zero CHECK (balance_delta_cents <> 0)
)
`);
pgm.sql(`
CREATE INDEX IF NOT EXISTS club_transactions_member_id_created_at_idx
ON club_transactions (member_id, created_at DESC)
`);
pgm.sql(`CREATE INDEX IF NOT EXISTS club_transactions_sale_id_idx ON club_transactions (sale_id)`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_recovery_codes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES club_members(id) ON DELETE CASCADE,
code_hash text NOT NULL,
code_fingerprint text NOT NULL UNIQUE,
used_at timestamptz,
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
)
`);
pgm.sql(`CREATE INDEX IF NOT EXISTS club_recovery_codes_member_id_idx ON club_recovery_codes (member_id)`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_rewards (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES club_members(id) ON DELETE CASCADE,
campaign_id uuid REFERENCES club_campaigns(id) ON DELETE SET NULL,
status text NOT NULL DEFAULT 'available',
label text NOT NULL,
amount_cents integer,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT club_rewards_status_check CHECK (status IN ('available', 'redeemed', 'expired', 'cancelled'))
)
`);
pgm.sql(`CREATE INDEX IF NOT EXISTS club_rewards_member_id_idx ON club_rewards (member_id)`);
pgm.sql(`
INSERT INTO store_settings (key, value) VALUES
('club_enabled', 'true'),
('club_cashback_bps', '200'),
('club_allow_anonymous_members', 'true'),
('club_allow_recovery_codes', 'true'),
('club_minimum_redeem_cents', '500')
ON CONFLICT (key) DO NOTHING
`);
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql(`DELETE FROM store_settings WHERE key IN (
'club_enabled',
'club_cashback_bps',
'club_allow_anonymous_members',
'club_allow_recovery_codes',
'club_minimum_redeem_cents'
)`);
pgm.sql('DROP INDEX IF EXISTS club_rewards_member_id_idx');
pgm.sql('DROP TABLE IF EXISTS club_rewards');
pgm.sql('DROP INDEX IF EXISTS club_recovery_codes_member_id_idx');
pgm.sql('DROP TABLE IF EXISTS club_recovery_codes');
pgm.sql('DROP INDEX IF EXISTS club_transactions_sale_id_idx');
pgm.sql('DROP INDEX IF EXISTS club_transactions_member_id_created_at_idx');
pgm.sql('DROP TABLE IF EXISTS club_transactions');
pgm.sql('DROP TABLE IF EXISTS club_campaigns');
pgm.sql('DROP INDEX IF EXISTS club_devices_member_id_idx');
pgm.sql('DROP TABLE IF EXISTS club_devices');
pgm.sql('DROP INDEX IF EXISTS club_members_user_id_unique_idx');
pgm.sql('DROP TABLE IF EXISTS club_members');
};

View File

@@ -35,6 +35,7 @@ import { createPromotionService, registerPromotionsRoutes } from '../modules/pro
import { registerCartRoutes } from '../modules/cart/index.js';
import { registerShippingRoutes } from '../modules/shipping/index.js';
import { registerOrdersRoutes } from '../modules/orders/index.js';
import { registerClubRoutes } from '../modules/club/index.js';
import { registerPosRoutes } from '../modules/pos/index.js';
import { registerCheckoutRoutes } from '../modules/checkout/index.js';
import { registerPaymentsRoutes } from '../modules/payments/index.js';
@@ -320,6 +321,13 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
});
});
await app.register(async (instance) => {
await registerClubRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate: combinedAuth,
});
});
// POS routes (POS-004)
if (deps.pool && combinedAuth) {
await app.register(async (instance) => {

View File

@@ -0,0 +1,191 @@
import type { DestinationStream } from 'pino';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { createPool } from '../../infrastructure/db/pool.js';
import { createLogger } from '../../infrastructure/logging/logger.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
import { createClubService, CLUB_DEVICE_COOKIE_NAME } from '../../modules/club/index.js';
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
function silentLogger() {
const destination: DestinationStream = { write: () => undefined };
return createLogger({ level: 'info', destination });
}
function cookieValue(setCookieHeader: string | string[] | undefined): string {
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
expect(raw).toBeDefined();
const pair = (raw as string).split(';')[0] as string;
return pair.slice(pair.indexOf('=') + 1);
}
describe.skipIf(!hasDb)('club backend phase 1 (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let adminCookie = '';
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = { email: 'club-admin@example.com', password: 'correct horse battery staple' };
const registered = await app.inject({
method: 'POST',
url: '/auth/register',
headers: { 'content-type': 'application/json' },
payload: user,
});
const id = (registered.json() as { id: string }).id;
await pool.query(
`UPDATE identity_users
SET role = $1,
email_confirmed = true,
confirmed_at = now(),
confirmation_token = null
WHERE id = $2`,
['admin', id],
);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
adminCookie = cookieValue(login.headers['set-cookie']);
});
afterAll(async () => {
await app?.close();
await pool?.end();
});
it('creates an anonymous member, reuses device identity and lists movements', async () => {
const config = await app.inject({ method: 'GET', url: '/club/config' });
expect(config.statusCode).toBe(200);
expect(config.json()).toMatchObject({ clubEnabled: true, cashbackBps: 200 });
const joined = await app.inject({ method: 'POST', url: '/club/join' });
expect(joined.statusCode).toBe(201);
expect(String(joined.headers['set-cookie'])).toContain(`${CLUB_DEVICE_COOKIE_NAME}=`);
const payload = joined.json() as {
created: boolean;
deviceToken: string;
member: { id: string; memberCode: string; currentBalanceCents: number };
};
expect(payload.created).toBe(true);
expect(payload.deviceToken).toMatch(/^[A-Za-z0-9_-]{40,}$/);
expect(payload.member.memberCode).toMatch(/^MDV-[A-Z0-9]{8}$/);
expect(payload.member.currentBalanceCents).toBe(0);
const me = await app.inject({
method: 'GET',
url: '/club/me',
headers: { 'x-club-device-token': payload.deviceToken },
});
expect(me.statusCode).toBe(200);
expect(me.json()).toMatchObject({ member: { id: payload.member.id, memberCode: payload.member.memberCode } });
const repeatedJoin = await app.inject({
method: 'POST',
url: '/club/join',
cookies: { [CLUB_DEVICE_COOKIE_NAME]: payload.deviceToken },
});
expect(repeatedJoin.statusCode).toBe(200);
expect(repeatedJoin.json()).toMatchObject({ created: false, member: { id: payload.member.id } });
const club = createClubService(pool);
const first = await club.recordTransaction({
memberId: payload.member.id,
type: 'bonus',
amountCents: 100,
balanceDeltaCents: 100,
idempotencyKey: 'club-bonus-1',
metadata: { reason: 'welcome' },
});
expect(first.created).toBe(true);
expect(first.member.currentBalanceCents).toBe(100);
const replay = await club.recordTransaction({
memberId: payload.member.id,
type: 'bonus',
amountCents: 100,
balanceDeltaCents: 100,
idempotencyKey: 'club-bonus-1',
metadata: { reason: 'welcome' },
});
expect(replay.created).toBe(false);
expect(replay.member.currentBalanceCents).toBe(100);
const movements = await app.inject({
method: 'GET',
url: '/club/movements?limit=10',
headers: { 'x-club-device-token': payload.deviceToken },
});
expect(movements.statusCode).toBe(200);
expect(movements.json()).toMatchObject({
member: { id: payload.member.id, currentBalanceCents: 100 },
items: [
{
type: 'bonus',
amountCents: 100,
balanceDeltaCents: 100,
idempotencyKey: 'club-bonus-1',
},
],
});
});
it('reads and updates admin club settings and blocks new joins when disabled', async () => {
const current = await app.inject({
method: 'GET',
url: '/admin/club/settings',
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
});
expect(current.statusCode).toBe(200);
expect(current.json()).toMatchObject({
clubEnabled: true,
cashbackBps: 200,
cashbackPercentage: 2,
allowAnonymousMembers: true,
allowRecoveryCodes: true,
minimumRedeemAmountCents: 500,
});
const updated = await app.inject({
method: 'PATCH',
url: '/admin/club/settings',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: {
clubEnabled: false,
cashbackPercentage: 3.5,
allowAnonymousMembers: false,
allowRecoveryCodes: false,
minimumRedeemAmountCents: 700,
},
});
expect(updated.statusCode).toBe(200);
expect(updated.json()).toMatchObject({
clubEnabled: false,
cashbackBps: 350,
cashbackPercentage: 3.5,
allowAnonymousMembers: false,
allowRecoveryCodes: false,
minimumRedeemAmountCents: 700,
});
const blocked = await app.inject({ method: 'POST', url: '/club/join' });
expect(blocked.statusCode).toBe(409);
expect(blocked.json()).toMatchObject({ error: { code: 'CLUB_DISABLED' } });
});
});

View File

@@ -79,25 +79,25 @@ describe.skipIf(!hasDb)('F-186 POS checkout and receipts (real PostgreSQL)', ()
],
payments: [
{ methodCode: 'cash', amountCents: 500, tenderedCents: 1000 },
{ methodCode: 'card', amountCents: 700 },
{ methodCode: 'card', amountCents: 770 },
],
};
const result = await useCase.execute(input);
expect(result.totalCents).toBe(1200);
expect(result.totalCents).toBe(1270);
expect(result.changeCents).toBe(500);
expect(result.receiptNumber).toBe('TPV-000001');
expect(result.receipt.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'Producto test', freeItem: false, totalCents: 700 }),
expect.objectContaining({ name: 'Producto test', freeItem: false, totalCents: 770, taxCents: 70 }),
expect.objectContaining({ name: 'Servicio libre', freeItem: true, totalCents: 500 }),
]),
);
expect(result.receipt.payments).toEqual(
expect.arrayContaining([
expect.objectContaining({ methodCode: 'cash', amountCents: 500, changeCents: 500 }),
expect.objectContaining({ methodCode: 'card', amountCents: 700 }),
expect.objectContaining({ methodCode: 'card', amountCents: 770 }),
]),
);

View File

@@ -95,7 +95,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 3, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 3000, tenderedCents: 3000 }],
payments: [{ methodCode: 'cash', amountCents: 3300, tenderedCents: 3300 }],
});
expect(sale.state).toBe('COMPLETED');
@@ -127,11 +127,11 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.state).toBe('PARTIALLY_REFUNDED');
expect(body.refundedCents).toBe(1000);
expect(body.refundedCents).toBe(1100);
expect(body.receipt.receiptNumber.startsWith('R-')).toBe(true);
expect(body.receipt.totalCents).toBe(-1000);
expect(body.receipt.totalCents).toBe(-1100);
expect(body.receipt.items[0].quantity).toBe(1);
expect(body.receipt.items[0].totalCents).toBe(-1000);
expect(body.receipt.items[0].totalCents).toBe(-1100);
const order = await pool.query<{ state: string }>(
`SELECT state FROM orders_orders WHERE id = $1`,
@@ -183,7 +183,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 },
{ kind: 'free', name: 'Mano de obra', unitPriceCents: 500, quantity: 1 },
],
payments: [{ methodCode: 'cash', amountCents: 1500, tenderedCents: 1500 }],
payments: [{ methodCode: 'cash', amountCents: 1600, tenderedCents: 1600 }],
});
expect(sale.state).toBe('COMPLETED');
@@ -214,7 +214,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.state).toBe('REFUNDED');
expect(body.refundedCents).toBe(1500);
expect(body.refundedCents).toBe(1600);
const stock = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
@@ -238,7 +238,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 1000, tenderedCents: 1000 }],
payments: [{ methodCode: 'cash', amountCents: 1100, tenderedCents: 1100 }],
});
const items = await pool.query<{ id: string }>(
@@ -271,7 +271,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 2, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 2000, tenderedCents: 2000 }],
payments: [{ methodCode: 'cash', amountCents: 2200, tenderedCents: 2200 }],
});
const items = await pool.query<{ id: string }>(

View File

@@ -0,0 +1,303 @@
import type { FastifyInstance, FastifyReply, FastifyRequest, FastifySchema } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate, CurrentUser } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { errorSchema } from '../../../shared/swagger.js';
import { ClubService } from '../application/club-service.js';
import type { ClubMember, ClubSettings, ClubTransaction } from '../domain/club.js';
import {
ClubAnonymousJoinDisabledError,
ClubDeviceTokenRequiredError,
ClubDisabledError,
ClubInsufficientBalanceError,
ClubMemberNotFoundError,
InvalidClubTransactionError,
} from '../domain/errors.js';
import { PgClubRepository } from '../infrastructure/pg-club-repository.js';
export const CLUB_DEVICE_COOKIE_NAME = 'mdv_club';
const CLUB_DEVICE_COOKIE_MAX_AGE_S = 60 * 60 * 24 * 365;
export interface ClubRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const adminSettingsSchema = z
.object({
clubEnabled: z.boolean().optional(),
cashbackPercentage: z.number().min(0).max(100).optional(),
allowAnonymousMembers: z.boolean().optional(),
allowRecoveryCodes: z.boolean().optional(),
minimumRedeemAmountCents: z.number().int().min(0).optional(),
})
.strip();
const movementQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export async function registerClubRoutes(
app: FastifyInstance,
deps: ClubRoutesDeps,
): Promise<void> {
const clubs = new ClubService(new PgClubRepository(deps.pool));
app.get(
'/club/config',
{
schema: {
tags: ['Club'],
summary: 'Get public Club configuration',
response: { 200: { type: 'object' } },
} as FastifySchema,
},
async (_request, reply) => {
return reply.send(serializeSettings(await clubs.getPublicConfig()));
},
);
app.post(
'/club/join',
{
schema: {
tags: ['Club'],
summary: 'Create or reuse anonymous Club member for the current device',
response: { 201: { type: 'object' }, 200: { type: 'object' }, 409: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
try {
const result = await clubs.joinAnonymous(deviceTokenFromRequest(request));
setClubDeviceCookie(reply, result.deviceToken, isSecureRequest(request));
const config = await clubs.getPublicConfig();
return reply.code(result.created ? 201 : 200).send({
created: result.created,
deviceToken: result.deviceToken,
member: serializeMember(result.member),
config: serializeSettings(config),
});
} catch (error) {
throw mapClubError(error);
}
},
);
app.get(
'/club/me',
{
schema: {
tags: ['Club'],
summary: 'Get current Club member by device token or linked account',
response: { 200: { type: 'object' }, 401: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticateOptional(deps.authenticate, request);
try {
const member = await clubs.getMemberOrThrow({
userId: user?.role === 'customer' ? user.id : null,
deviceToken: deviceTokenFromRequest(request),
});
const config = await clubs.getPublicConfig();
return reply.send({ member: serializeMember(member), config: serializeSettings(config) });
} catch (error) {
throw mapClubError(error);
}
},
);
app.get(
'/club/movements',
{
schema: {
tags: ['Club'],
summary: 'List Club ledger movements for current member',
querystring: {
type: 'object',
properties: { limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 } },
},
response: { 200: { type: 'object' }, 401: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticateOptional(deps.authenticate, request);
const { limit } = parseJson(movementQuerySchema, request.query ?? {});
try {
const { member, items } = await clubs.listMovements(
{
userId: user?.role === 'customer' ? user.id : null,
deviceToken: deviceTokenFromRequest(request),
},
limit,
);
return reply.send({
member: serializeMember(member),
items: items.map(serializeTransaction),
});
} catch (error) {
throw mapClubError(error);
}
},
);
app.get(
'/admin/club/settings',
{
schema: {
tags: ['Club Admin'],
summary: 'Get Club settings (admin)',
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
return reply.send(serializeSettings(await clubs.getAdminSettings()));
},
);
app.patch(
'/admin/club/settings',
{
schema: {
tags: ['Club Admin'],
summary: 'Update Club settings (admin)',
body: { type: 'object' },
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(adminSettingsSchema, request.body ?? {});
try {
const updatedBy = await resolveUpdatedBy(deps.pool, user);
const settings = await clubs.updateSettings(
{
enabled: input.clubEnabled,
cashbackBps:
input.cashbackPercentage === undefined
? undefined
: Math.round(input.cashbackPercentage * 100),
allowAnonymousMembers: input.allowAnonymousMembers,
allowRecoveryCodes: input.allowRecoveryCodes,
minimumRedeemAmountCents: input.minimumRedeemAmountCents,
},
updatedBy,
);
return reply.send(serializeSettings(settings));
} catch (error) {
throw mapClubError(error);
}
},
);
}
async function authenticateOptional(
authenticate: Authenticate,
request: FastifyRequest,
): Promise<CurrentUser | null> {
try {
return await authenticate(request);
} catch (error) {
if (error instanceof AppError && error.statusCode === 401) return null;
throw error;
}
}
function deviceTokenFromRequest(request: FastifyRequest): string | null {
const header = request.headers['x-club-device-token'];
const token = Array.isArray(header) ? header[0] : header;
if (typeof token === 'string' && token.trim()) return token.trim();
const cookieToken = request.cookies?.[CLUB_DEVICE_COOKIE_NAME];
return typeof cookieToken === 'string' && cookieToken.trim() ? cookieToken.trim() : null;
}
function isSecureRequest(request: FastifyRequest): boolean {
const forwardedProto = request.headers['x-forwarded-proto'];
const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
return request.protocol === 'https' || proto === 'https';
}
function setClubDeviceCookie(reply: FastifyReply, token: string, secure: boolean): void {
void reply.setCookie(CLUB_DEVICE_COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure,
maxAge: CLUB_DEVICE_COOKIE_MAX_AGE_S,
});
}
async function resolveUpdatedBy(pool: pg.Pool, user: CurrentUser): Promise<string | null> {
const identityUser = await pool.query<{ id: string }>(`SELECT id FROM identity_users WHERE id = $1`, [
user.id,
]);
return identityUser.rowCount ? user.id : null;
}
function mapClubError(error: unknown): Error {
if (error instanceof ClubDisabledError) {
return new AppError(409, 'CLUB_DISABLED', error.message);
}
if (error instanceof ClubAnonymousJoinDisabledError) {
return new AppError(403, 'CLUB_ANONYMOUS_DISABLED', error.message);
}
if (error instanceof ClubDeviceTokenRequiredError) {
return new AppError(401, 'CLUB_DEVICE_REQUIRED', error.message);
}
if (error instanceof ClubMemberNotFoundError) {
return new AppError(404, 'CLUB_MEMBER_NOT_FOUND', error.message);
}
if (error instanceof ClubInsufficientBalanceError) {
return new AppError(409, 'CLUB_INSUFFICIENT_BALANCE', error.message);
}
if (error instanceof InvalidClubTransactionError) {
return new AppError(400, 'CLUB_INVALID_TRANSACTION', error.message);
}
return error instanceof Error ? error : new Error('Unknown club error');
}
function serializeSettings(settings: ClubSettings) {
return {
clubEnabled: settings.enabled,
cashbackBps: settings.cashbackBps,
cashbackPercentage: settings.cashbackBps / 100,
allowAnonymousMembers: settings.allowAnonymousMembers,
allowRecoveryCodes: settings.allowRecoveryCodes,
minimumRedeemAmountCents: settings.minimumRedeemAmountCents,
};
}
function serializeMember(member: ClubMember) {
return {
id: member.id,
userId: member.userId,
memberCode: member.memberCode,
status: member.status,
tierCode: member.tierCode,
currentBalanceCents: member.currentBalanceCents,
isAnonymous: member.userId === null,
createdAt: member.createdAt.toISOString(),
updatedAt: member.updatedAt.toISOString(),
};
}
function serializeTransaction(transaction: ClubTransaction) {
return {
id: transaction.id,
memberId: transaction.memberId,
saleId: transaction.saleId,
storeId: transaction.storeId,
type: transaction.type,
amountCents: transaction.amountCents,
balanceDeltaCents: transaction.balanceDeltaCents,
idempotencyKey: transaction.idempotencyKey,
metadata: transaction.metadata,
createdAt: transaction.createdAt.toISOString(),
};
}

View File

@@ -0,0 +1,128 @@
import type {
ClubMember,
ClubResolveInput,
ClubSettings,
JoinClubResult,
RecordClubTransactionCommand,
RecordClubTransactionResult,
UpdateClubSettingsCommand,
} from '../domain/club.js';
import {
ClubAnonymousJoinDisabledError,
ClubDeviceTokenRequiredError,
ClubDisabledError,
ClubMemberNotFoundError,
InvalidClubTransactionError,
} from '../domain/errors.js';
import type { ClubRepository } from '../domain/ports.js';
import { generateDeviceToken, hashDeviceToken } from '../infrastructure/device-token.js';
import { generateMemberCode } from '../infrastructure/member-code.js';
export class ClubService {
constructor(private readonly clubs: ClubRepository) {}
getPublicConfig(): Promise<ClubSettings> {
return this.clubs.getSettings();
}
getAdminSettings(): Promise<ClubSettings> {
return this.clubs.getSettings();
}
async updateSettings(
input: UpdateClubSettingsCommand,
updatedBy: string | null,
): Promise<ClubSettings> {
if (input.cashbackBps !== undefined && (!Number.isInteger(input.cashbackBps) || input.cashbackBps < 0)) {
throw new InvalidClubTransactionError('El cashback del Club debe ser un entero no negativo');
}
if (
input.minimumRedeemAmountCents !== undefined &&
(!Number.isInteger(input.minimumRedeemAmountCents) || input.minimumRedeemAmountCents < 0)
) {
throw new InvalidClubTransactionError(
'El mínimo de canje del Club debe ser un entero no negativo',
);
}
return this.clubs.updateSettings(input, updatedBy);
}
async joinAnonymous(existingDeviceToken?: string | null): Promise<JoinClubResult> {
const normalizedExisting = normalizeToken(existingDeviceToken);
if (normalizedExisting) {
const existing = await this.clubs.findMemberByDeviceTokenHash(
hashDeviceToken(normalizedExisting),
true,
);
if (existing) {
return { member: existing, deviceToken: normalizedExisting, created: false };
}
}
const settings = await this.clubs.getSettings();
if (!settings.enabled) throw new ClubDisabledError();
if (!settings.allowAnonymousMembers) throw new ClubAnonymousJoinDisabledError();
const deviceToken = generateDeviceToken();
const deviceTokenHash = hashDeviceToken(deviceToken);
for (let attempt = 0; attempt < 10; attempt += 1) {
const member = await this.clubs.createMemberWithDevice({
memberCode: generateMemberCode(),
deviceTokenHash,
});
if (member) return { member, deviceToken, created: true };
}
throw new Error('No se pudo generar un código único para el Club');
}
resolveMember(input: ClubResolveInput): Promise<ClubMember | null> {
return this.clubs.resolveMember({
...input,
deviceTokenHash: input.deviceToken ? hashDeviceToken(input.deviceToken) : null,
});
}
async getMemberOrThrow(input: ClubResolveInput): Promise<ClubMember> {
const normalized = {
userId: input.userId ?? null,
deviceToken: normalizeToken(input.deviceToken),
};
if (!normalized.userId && !normalized.deviceToken) {
throw new ClubDeviceTokenRequiredError();
}
const member = await this.resolveMember(normalized);
if (!member) throw new ClubMemberNotFoundError();
return member;
}
async listMovements(input: ClubResolveInput, limit = 50) {
const safeLimit = Math.min(Math.max(limit, 1), 200);
const member = await this.getMemberOrThrow(input);
const items = await this.clubs.listTransactions(member.id, safeLimit);
return { member, items };
}
async recordTransaction(
input: RecordClubTransactionCommand,
): Promise<RecordClubTransactionResult> {
if (!Number.isInteger(input.amountCents) || input.amountCents < 0) {
throw new InvalidClubTransactionError('El importe base del movimiento Club no es válido');
}
if (!Number.isInteger(input.balanceDeltaCents) || input.balanceDeltaCents === 0) {
throw new InvalidClubTransactionError('El delta de saldo Club no es válido');
}
return this.clubs.recordTransaction({
...input,
idempotencyKey: input.idempotencyKey ?? null,
metadata: input.metadata ?? {},
saleId: input.saleId ?? null,
storeId: input.storeId ?? null,
});
}
}
function normalizeToken(value: string | null | undefined): string | null {
if (!value) return null;
const normalized = value.trim();
return normalized ? normalized : null;
}

View File

@@ -0,0 +1,78 @@
export type ClubMemberStatus = 'active' | 'blocked' | 'merged';
export type ClubTransactionType = 'earn' | 'redeem' | 'refund' | 'bonus' | 'adjustment';
export interface ClubMember {
id: string;
userId: string | null;
memberCode: string;
status: ClubMemberStatus;
tierCode: string;
currentBalanceCents: number;
createdAt: Date;
updatedAt: Date;
}
export interface ClubTransaction {
id: string;
memberId: string;
saleId: string | null;
storeId: string | null;
type: ClubTransactionType;
amountCents: number;
balanceDeltaCents: number;
idempotencyKey: string | null;
metadata: Record<string, unknown>;
createdAt: Date;
}
export interface ClubSettings {
enabled: boolean;
cashbackBps: number;
allowAnonymousMembers: boolean;
allowRecoveryCodes: boolean;
minimumRedeemAmountCents: number;
}
export const DEFAULT_CLUB_SETTINGS: ClubSettings = {
enabled: true,
cashbackBps: 200,
allowAnonymousMembers: true,
allowRecoveryCodes: true,
minimumRedeemAmountCents: 500,
};
export interface ClubResolveInput {
userId?: string | null;
deviceToken?: string | null;
}
export interface UpdateClubSettingsCommand {
enabled?: boolean;
cashbackBps?: number;
allowAnonymousMembers?: boolean;
allowRecoveryCodes?: boolean;
minimumRedeemAmountCents?: number;
}
export interface RecordClubTransactionCommand {
memberId: string;
saleId?: string | null;
storeId?: string | null;
type: ClubTransactionType;
amountCents: number;
balanceDeltaCents: number;
idempotencyKey?: string | null;
metadata?: Record<string, unknown>;
}
export interface JoinClubResult {
member: ClubMember;
deviceToken: string;
created: boolean;
}
export interface RecordClubTransactionResult {
member: ClubMember;
transaction: ClubTransaction;
created: boolean;
}

View File

@@ -0,0 +1,41 @@
export class ClubDisabledError extends Error {
constructor() {
super('Club de Clientes no está disponible');
this.name = 'ClubDisabledError';
}
}
export class ClubAnonymousJoinDisabledError extends Error {
constructor() {
super('El alta anónima del Club está desactivada');
this.name = 'ClubAnonymousJoinDisabledError';
}
}
export class ClubDeviceTokenRequiredError extends Error {
constructor() {
super('Se requiere un token de dispositivo Club');
this.name = 'ClubDeviceTokenRequiredError';
}
}
export class ClubMemberNotFoundError extends Error {
constructor() {
super('No se encontró el socio Club');
this.name = 'ClubMemberNotFoundError';
}
}
export class ClubInsufficientBalanceError extends Error {
constructor() {
super('Saldo Club insuficiente');
this.name = 'ClubInsufficientBalanceError';
}
}
export class InvalidClubTransactionError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidClubTransactionError';
}
}

View File

@@ -0,0 +1,26 @@
import type {
ClubMember,
ClubResolveInput,
ClubSettings,
ClubTransaction,
RecordClubTransactionCommand,
RecordClubTransactionResult,
UpdateClubSettingsCommand,
} from './club.js';
export interface CreateClubMemberCommand {
memberCode: string;
deviceTokenHash: string;
userId?: string | null;
}
export interface ClubRepository {
getSettings(): Promise<ClubSettings>;
updateSettings(input: UpdateClubSettingsCommand, updatedBy: string | null): Promise<ClubSettings>;
createMemberWithDevice(input: CreateClubMemberCommand): Promise<ClubMember | null>;
findMemberByUserId(userId: string): Promise<ClubMember | null>;
findMemberByDeviceTokenHash(hash: string, touch?: boolean): Promise<ClubMember | null>;
resolveMember(input: ClubResolveInput & { deviceTokenHash?: string | null }): Promise<ClubMember | null>;
listTransactions(memberId: string, limit: number): Promise<ClubTransaction[]>;
recordTransaction(input: RecordClubTransactionCommand): Promise<RecordClubTransactionResult>;
}

View File

@@ -0,0 +1,32 @@
import type pg from 'pg';
import { ClubService } from './application/club-service.js';
import { PgClubRepository } from './infrastructure/pg-club-repository.js';
export { registerClubRoutes, type ClubRoutesDeps, CLUB_DEVICE_COOKIE_NAME } from './api/club.routes.js';
export { ClubService } from './application/club-service.js';
export type {
ClubMember,
ClubMemberStatus,
ClubSettings,
ClubTransaction,
ClubTransactionType,
JoinClubResult,
RecordClubTransactionCommand,
RecordClubTransactionResult,
UpdateClubSettingsCommand,
} from './domain/club.js';
export {
ClubDisabledError,
ClubAnonymousJoinDisabledError,
ClubDeviceTokenRequiredError,
ClubMemberNotFoundError,
ClubInsufficientBalanceError,
InvalidClubTransactionError,
} from './domain/errors.js';
export type { ClubRepository } from './domain/ports.js';
export { generateDeviceToken, hashDeviceToken } from './infrastructure/device-token.js';
export { generateMemberCode } from './infrastructure/member-code.js';
export function createClubService(pool: pg.Pool): ClubService {
return new ClubService(new PgClubRepository(pool));
}

View File

@@ -0,0 +1,9 @@
import { createHash, randomBytes } from 'node:crypto';
export function generateDeviceToken(): string {
return randomBytes(48).toString('base64url');
}
export function hashDeviceToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}

View File

@@ -0,0 +1,5 @@
import { randomBytes } from 'node:crypto';
export function generateMemberCode(): string {
return `MDV-${randomBytes(4).toString('hex').toUpperCase()}`;
}

View File

@@ -0,0 +1,341 @@
import type pg from 'pg';
import type {
ClubMember,
ClubResolveInput,
ClubSettings,
ClubTransaction,
RecordClubTransactionCommand,
RecordClubTransactionResult,
UpdateClubSettingsCommand,
} from '../domain/club.js';
import { DEFAULT_CLUB_SETTINGS } from '../domain/club.js';
import { ClubInsufficientBalanceError } from '../domain/errors.js';
import type { ClubRepository, CreateClubMemberCommand } from '../domain/ports.js';
interface SettingRow {
key: string;
value: string;
}
interface MemberRow {
id: string;
user_id: string | null;
member_code: string;
status: 'active' | 'blocked' | 'merged';
tier_code: string;
current_balance_cents: number;
created_at: Date;
updated_at: Date;
}
interface TransactionRow {
id: string;
member_id: string;
sale_id: string | null;
store_id: string | null;
type: 'earn' | 'redeem' | 'refund' | 'bonus' | 'adjustment';
amount_cents: number;
balance_delta_cents: number;
idempotency_key: string | null;
metadata: Record<string, unknown> | null;
created_at: Date;
}
type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>;
const CLUB_SETTING_KEYS = {
enabled: 'club_enabled',
cashbackBps: 'club_cashback_bps',
allowAnonymousMembers: 'club_allow_anonymous_members',
allowRecoveryCodes: 'club_allow_recovery_codes',
minimumRedeemAmountCents: 'club_minimum_redeem_cents',
} as const;
export class PgClubRepository implements ClubRepository {
constructor(private readonly pool: pg.Pool) {}
async getSettings(): Promise<ClubSettings> {
const result = await this.pool.query<SettingRow>(
`SELECT key, value FROM store_settings WHERE key = ANY($1::text[])`,
[Object.values(CLUB_SETTING_KEYS)],
);
return parseSettings(result.rows);
}
async updateSettings(
input: UpdateClubSettingsCommand,
updatedBy: string | null,
): Promise<ClubSettings> {
const updates: Array<[string, string]> = [];
if (input.enabled !== undefined) {
updates.push([CLUB_SETTING_KEYS.enabled, String(input.enabled)]);
}
if (input.cashbackBps !== undefined) {
updates.push([CLUB_SETTING_KEYS.cashbackBps, String(input.cashbackBps)]);
}
if (input.allowAnonymousMembers !== undefined) {
updates.push([
CLUB_SETTING_KEYS.allowAnonymousMembers,
String(input.allowAnonymousMembers),
]);
}
if (input.allowRecoveryCodes !== undefined) {
updates.push([CLUB_SETTING_KEYS.allowRecoveryCodes, String(input.allowRecoveryCodes)]);
}
if (input.minimumRedeemAmountCents !== undefined) {
updates.push([
CLUB_SETTING_KEYS.minimumRedeemAmountCents,
String(input.minimumRedeemAmountCents),
]);
}
for (const [key, value] of updates) {
await this.pool.query(
`INSERT INTO store_settings (key, value, updated_by) VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
updated_at = NOW(),
updated_by = EXCLUDED.updated_by`,
[key, value, updatedBy],
);
}
return this.getSettings();
}
async createMemberWithDevice(input: CreateClubMemberCommand): Promise<ClubMember | null> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const memberResult = await client.query<MemberRow>(
`INSERT INTO club_members (user_id, member_code, status, tier_code, current_balance_cents)
VALUES ($1, $2, 'active', 'base', 0)
ON CONFLICT (member_code) DO NOTHING
RETURNING *`,
[input.userId ?? null, input.memberCode],
);
const row = memberResult.rows[0];
if (!row) {
await client.query('ROLLBACK');
return null;
}
await client.query(
`INSERT INTO club_devices (member_id, device_token_hash)
VALUES ($1, $2)`,
[row.id, input.deviceTokenHash],
);
await client.query('COMMIT');
return toMember(row);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async findMemberByUserId(userId: string): Promise<ClubMember | null> {
const result = await this.pool.query<MemberRow>(
`SELECT * FROM club_members
WHERE user_id = $1 AND status <> 'merged'
LIMIT 1`,
[userId],
);
return result.rows[0] ? toMember(result.rows[0]) : null;
}
async findMemberByDeviceTokenHash(hash: string, touch = false): Promise<ClubMember | null> {
const result = await this.pool.query<MemberRow>(
`SELECT member.*
FROM club_devices device
JOIN club_members member ON member.id = device.member_id
WHERE device.device_token_hash = $1
AND device.revoked_at IS NULL
AND member.status <> 'merged'
LIMIT 1`,
[hash],
);
const row = result.rows[0];
if (!row) return null;
if (touch) {
await this.pool.query(
`UPDATE club_devices SET last_used_at = now()
WHERE device_token_hash = $1 AND revoked_at IS NULL`,
[hash],
);
}
return toMember(row);
}
async resolveMember(
input: ClubResolveInput & { deviceTokenHash?: string | null },
): Promise<ClubMember | null> {
if (input.userId) {
const byUser = await this.findMemberByUserId(input.userId);
if (byUser) return byUser;
}
if (input.deviceTokenHash) {
return this.findMemberByDeviceTokenHash(input.deviceTokenHash, true);
}
return null;
}
async listTransactions(memberId: string, limit: number): Promise<ClubTransaction[]> {
const result = await this.pool.query<TransactionRow>(
`SELECT * FROM club_transactions
WHERE member_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2`,
[memberId, limit],
);
return result.rows.map(toTransaction);
}
async recordTransaction(
input: RecordClubTransactionCommand,
): Promise<RecordClubTransactionResult> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
if (input.idempotencyKey) {
const existing = await client.query<TransactionRow>(
`SELECT * FROM club_transactions WHERE idempotency_key = $1`,
[input.idempotencyKey],
);
const existingRow = existing.rows[0];
if (existingRow) {
const member = await client.query<MemberRow>(
`SELECT * FROM club_members WHERE id = $1`,
[existingRow.member_id],
);
const memberRow = member.rows[0];
if (!memberRow) {
throw new Error(`club_members row not found for transaction ${existingRow.id}`);
}
await client.query('COMMIT');
return {
member: toMember(memberRow),
transaction: toTransaction(existingRow),
created: false,
};
}
}
const memberResult = await client.query<MemberRow>(
`SELECT * FROM club_members WHERE id = $1 FOR UPDATE`,
[input.memberId],
);
const member = memberResult.rows[0];
if (!member) {
throw new Error(`club_members row not found: ${input.memberId}`);
}
const nextBalance = Number(member.current_balance_cents) + input.balanceDeltaCents;
if (nextBalance < 0) {
throw new ClubInsufficientBalanceError();
}
const transactionResult = await client.query<TransactionRow>(
`INSERT INTO club_transactions (
member_id, sale_id, store_id, type, amount_cents,
balance_delta_cents, idempotency_key, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
RETURNING *`,
[
input.memberId,
input.saleId ?? null,
input.storeId ?? null,
input.type,
input.amountCents,
input.balanceDeltaCents,
input.idempotencyKey ?? null,
JSON.stringify(input.metadata ?? {}),
],
);
const updatedMember = await client.query<MemberRow>(
`UPDATE club_members
SET current_balance_cents = $2,
updated_at = now()
WHERE id = $1
RETURNING *`,
[input.memberId, nextBalance],
);
const updatedMemberRow = updatedMember.rows[0];
const transactionRow = transactionResult.rows[0];
if (!updatedMemberRow || !transactionRow) {
throw new Error('Club transaction persistence returned no row');
}
await client.query('COMMIT');
return {
member: toMember(updatedMemberRow),
transaction: toTransaction(transactionRow),
created: true,
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
function parseSettings(rows: SettingRow[]): ClubSettings {
const map = new Map(rows.map((row) => [row.key, row.value]));
return {
enabled: parseBoolean(map.get(CLUB_SETTING_KEYS.enabled), DEFAULT_CLUB_SETTINGS.enabled),
cashbackBps: parseInteger(map.get(CLUB_SETTING_KEYS.cashbackBps), DEFAULT_CLUB_SETTINGS.cashbackBps),
allowAnonymousMembers: parseBoolean(
map.get(CLUB_SETTING_KEYS.allowAnonymousMembers),
DEFAULT_CLUB_SETTINGS.allowAnonymousMembers,
),
allowRecoveryCodes: parseBoolean(
map.get(CLUB_SETTING_KEYS.allowRecoveryCodes),
DEFAULT_CLUB_SETTINGS.allowRecoveryCodes,
),
minimumRedeemAmountCents: parseInteger(
map.get(CLUB_SETTING_KEYS.minimumRedeemAmountCents),
DEFAULT_CLUB_SETTINGS.minimumRedeemAmountCents,
),
};
}
function parseBoolean(raw: string | undefined, fallback: boolean): boolean {
if (raw === undefined) return fallback;
return raw === 'true';
}
function parseInteger(raw: string | undefined, fallback: number): number {
if (raw === undefined) return fallback;
const parsed = Number(raw);
return Number.isInteger(parsed) ? parsed : fallback;
}
function toMember(row: MemberRow): ClubMember {
return {
id: row.id,
userId: row.user_id,
memberCode: row.member_code,
status: row.status,
tierCode: row.tier_code,
currentBalanceCents: Number(row.current_balance_cents),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function toTransaction(row: TransactionRow): ClubTransaction {
return {
id: row.id,
memberId: row.member_id,
saleId: row.sale_id,
storeId: row.store_id,
type: row.type,
amountCents: Number(row.amount_cents),
balanceDeltaCents: Number(row.balance_delta_cents),
idempotencyKey: row.idempotency_key,
metadata: row.metadata ?? {},
createdAt: row.created_at,
};
}

View File

@@ -0,0 +1,22 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? sourceFiles(path) : path.endsWith('.ts') ? [path] : [];
});
}
describe('club module boundary', () => {
it('does not import other module internals directly', () => {
const dir = new URL('..', import.meta.url);
for (const file of sourceFiles(dir.pathname)) {
const source = readFileSync(file, 'utf8');
expect(source).not.toMatch(
/modules\/(identity|users|catalog|inventory|pricing|promotions|cart|shipping|orders|payments|notifications|reviews|cms|security|pos|reporting|store-settings)\/(api|application|domain|infrastructure|tests)/,
);
}
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { generateDeviceToken, hashDeviceToken } from '../infrastructure/device-token.js';
import { generateMemberCode } from '../infrastructure/member-code.js';
describe('club opaque tokens and member codes', () => {
it('hashDeviceToken is deterministic sha256 hex', () => {
const token = 'club-token-example';
expect(hashDeviceToken(token)).toMatch(/^[a-f0-9]{64}$/);
expect(hashDeviceToken(token)).toBe(hashDeviceToken(token));
expect(hashDeviceToken(token)).not.toBe(token);
});
it('generateDeviceToken returns distinct opaque base64url tokens', () => {
const first = generateDeviceToken();
const second = generateDeviceToken();
expect(first).toMatch(/^[A-Za-z0-9_-]{40,}$/);
expect(second).toMatch(/^[A-Za-z0-9_-]{40,}$/);
expect(first).not.toBe(second);
});
it('generateMemberCode uses MDV short-code format', () => {
expect(generateMemberCode()).toMatch(/^MDV-[A-Z0-9]{8}$/);
});
});

View File

@@ -82,6 +82,28 @@ async function resolveStoreIdForReceipt(
return id;
}
const POS_GROSS_PRICE_SQL = `COALESCE(
pp.offer_cents,
ROUND(
pp.net_unit_amount_cents * CASE pp.vat_rate
WHEN 'general' THEN 1.21
WHEN 'reduced' THEN 1.10
WHEN 'super-reduced' THEN 1.04
ELSE 1.21
END
)::int,
0
)`;
function grossFromNet(
netUnitAmountCents: number,
vatRate: 'general' | 'reduced' | 'super-reduced' | null,
): number {
if (!vatRate) return netUnitAmountCents;
const basisPoints = vatRate === 'general' ? 2100 : vatRate === 'reduced' ? 1000 : 400;
return Math.round(netUnitAmountCents * (1 + basisPoints / 10_000));
}
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
const { pool, authenticate } = deps;
@@ -295,7 +317,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const result = variantIds.length > 0
? await pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
${POS_GROSS_PRICE_SQL} AS "priceCents"
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
@@ -305,7 +327,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
)
: await pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
${POS_GROSS_PRICE_SQL} AS "priceCents"
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
@@ -942,7 +964,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
pc.category_id AS "categoryId", COALESCE(stock.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
${POS_GROSS_PRICE_SQL} AS "priceCents"
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
@@ -1008,7 +1030,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents,
${POS_GROSS_PRICE_SQL} AS price_cents,
c.name AS category, b.name AS brand
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
@@ -1064,7 +1086,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents
${POS_GROSS_PRICE_SQL} AS price_cents
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL (
@@ -1106,7 +1128,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents
${POS_GROSS_PRICE_SQL} AS price_cents
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL (
@@ -2145,23 +2167,36 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
is_free_item: boolean;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
vat_rate: 'general' | 'reduced' | 'super-reduced' | null;
}>(
`SELECT id, name, sku, quantity, returned_quantity, is_free_item,
unit_price_cents, discount_cents
unit_price_cents, discount_cents, tax_cents, vat_rate
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[id],
);
return reply.send({
items: itemRows.rows.map((row) => ({
id: row.id,
name: row.name,
sku: row.sku,
quantity: Number(row.quantity),
returnedQuantity: Number(row.returned_quantity),
freeItem: row.is_free_item,
unitPriceCents: Number(row.unit_price_cents),
discountCents: Number(row.discount_cents),
})),
items: itemRows.rows.map((row) => {
const unitNetCents = Number(row.unit_price_cents);
const unitTaxCents = Number(row.tax_cents);
const unitGrossCents = row.is_free_item
? unitNetCents
: grossFromNet(unitNetCents, row.vat_rate);
const discountGrossCents = Math.max(
0,
unitGrossCents - (unitNetCents - Number(row.discount_cents) + unitTaxCents),
);
return {
id: row.id,
name: row.name,
sku: row.sku,
quantity: Number(row.quantity),
returnedQuantity: Number(row.returned_quantity),
freeItem: row.is_free_item,
unitPriceCents: unitGrossCents,
discountCents: discountGrossCents,
};
}),
});
},
);

View File

@@ -175,7 +175,7 @@ export class ApplyPosReturnUseCase {
);
}
totalRefundCents +=
(item.unit_price_cents - item.discount_cents) * line.returnedQuantity;
(item.unit_price_cents - item.discount_cents + item.tax_cents) * line.returnedQuantity;
if (!item.is_free_item && item.variant_id) {
stockUpdates.push({
variantId: item.variant_id,

View File

@@ -11,6 +11,7 @@ interface ReturnedItemRow {
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
vat_rate: 'general' | 'reduced' | 'super-reduced' | null;
name: string;
sku: string;
is_free_item: boolean;
@@ -47,6 +48,7 @@ interface ReceiptItemRow {
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
vat_rate: 'general' | 'reduced' | 'super-reduced' | null;
is_free_item: boolean;
}
@@ -80,7 +82,7 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
const [itemResult, paymentResult] = await Promise.all([
queryable.query<ReceiptItemRow>(
`SELECT name, sku, quantity, unit_price_cents, discount_cents, tax_cents, is_free_item
`SELECT name, sku, quantity, unit_price_cents, discount_cents, tax_cents, vat_rate, is_free_item
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[orderId],
),
@@ -124,18 +126,25 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
sessionId: order.cash_session_id,
customerEmail: order.customer_email,
items: itemResult.rows.map((item) => {
const subtotalCents = Number(item.unit_price_cents) * Number(item.quantity);
const discountCents = Number(item.discount_cents) * Number(item.quantity);
const taxCents = Number(item.tax_cents) * Number(item.quantity);
const quantity = Number(item.quantity);
const unitNetCents = Number(item.unit_price_cents);
const unitTaxCents = Number(item.tax_cents);
const unitGrossCents = item.is_free_item
? unitNetCents
: grossFromNet(unitNetCents, item.vat_rate);
const totalCents = (unitNetCents - Number(item.discount_cents) + unitTaxCents) * quantity;
const subtotalCents = unitGrossCents * quantity;
const discountCents = Math.max(0, subtotalCents - totalCents);
const taxCents = unitTaxCents * quantity;
return {
name: item.name,
sku: item.sku,
quantity: Number(item.quantity),
unitPriceCents: Number(item.unit_price_cents),
quantity,
unitPriceCents: unitGrossCents,
subtotalCents,
discountCents,
taxCents,
totalCents: subtotalCents - discountCents + taxCents,
totalCents,
freeItem: item.is_free_item,
};
}),
@@ -152,6 +161,15 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
};
}
function grossFromNet(
netUnitAmountCents: number,
vatRate: 'general' | 'reduced' | 'super-reduced' | null,
): number {
if (!vatRate) return netUnitAmountCents;
const basisPoints = vatRate === 'general' ? 2100 : vatRate === 'reduced' ? 1000 : 400;
return Math.round(netUnitAmountCents * (1 + basisPoints / 10_000));
}
function isPaymentKind(value: unknown): value is PosPaymentKind {
return value === 'cash' || value === 'card' || value === 'other';
}
@@ -196,7 +214,7 @@ export async function buildPosReturnReceipt(
const original = await buildPosReceipt(queryable, orderId);
const itemResult = await queryable.query<ReturnedItemRow>(
`SELECT id, quantity, returned_quantity, unit_price_cents, discount_cents, tax_cents,
name, sku, is_free_item
vat_rate, name, sku, is_free_item
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[orderId],
);
@@ -204,18 +222,20 @@ export async function buildPosReturnReceipt(
.filter((row) => row.returned_quantity > 0)
.map((row) => {
const returnedQuantity = Number(row.returned_quantity);
const unitPrice = Number(row.unit_price_cents);
const discount = Number(row.discount_cents);
const tax = Number(row.tax_cents);
const subtotal = unitPrice * returnedQuantity;
const discountCents = discount * returnedQuantity;
const taxCents = tax * returnedQuantity;
const total = subtotal - discountCents + taxCents;
const unitNetCents = Number(row.unit_price_cents);
const unitTaxCents = Number(row.tax_cents);
const unitGrossCents = row.is_free_item
? unitNetCents
: grossFromNet(unitNetCents, row.vat_rate);
const total = (unitNetCents - Number(row.discount_cents) + unitTaxCents) * returnedQuantity;
const subtotal = unitGrossCents * returnedQuantity;
const discountCents = Math.max(0, subtotal - total);
const taxCents = unitTaxCents * returnedQuantity;
return {
name: row.name,
sku: row.sku,
quantity: returnedQuantity,
unitPriceCents: unitPrice,
unitPriceCents: unitGrossCents,
subtotalCents: subtotal,
discountCents,
taxCents,

View File

@@ -24,10 +24,17 @@ interface CatalogLineRow {
sku: string;
ean: string | null;
name: string;
unit_price_cents: number;
vat_rate: string;
net_unit_amount_cents: number;
offer_cents: number | null;
vat_rate: 'general' | 'reduced' | 'super-reduced';
}
const POS_VAT_BASIS_POINTS: Record<CatalogLineRow['vat_rate'], number> = {
general: 2100,
reduced: 1000,
'super-reduced': 400,
};
export interface ConfiguredPaymentMethod {
id: string;
code: string;
@@ -114,6 +121,18 @@ export function validatePaymentAllocations(
return validated;
}
function grossFromNet(netUnitAmountCents: number, vatRate: CatalogLineRow['vat_rate']): number {
return Math.round(netUnitAmountCents * (1 + POS_VAT_BASIS_POINTS[vatRate] / 10_000));
}
function netFromGross(grossUnitAmountCents: number, vatRate: CatalogLineRow['vat_rate']): number {
return Math.round(grossUnitAmountCents / (1 + POS_VAT_BASIS_POINTS[vatRate] / 10_000));
}
function effectiveGrossUnitPrice(catalog: CatalogLineRow): number {
return catalog.offer_cents ?? grossFromNet(Number(catalog.net_unit_amount_cents), catalog.vat_rate);
}
export class CreatePosSaleUseCase {
constructor(private readonly pool: pg.Pool) {}
@@ -153,6 +172,9 @@ export class CreatePosSaleUseCase {
throw new AppError(400, 'POS_EMPTY_CART', 'El carrito está vacío');
const lineDiscountsEnabled = session.terminal_settings?.lineDiscountsEnabled !== false;
let subtotalCents = 0;
let discountCents = 0;
let taxCents = 0;
const items: PosSaleLineItem[] = [];
for (const inputItem of input.items) {
if (inputItem.kind === 'free') {
@@ -168,6 +190,7 @@ export class CreatePosSaleUseCase {
'El artículo libre requiere nombre y precio positivo',
);
}
subtotalCents += inputItem.unitPriceCents * inputItem.quantity;
items.push({
kind: 'free',
variantId: null,
@@ -187,7 +210,8 @@ export class CreatePosSaleUseCase {
const catalogResult = await client.query<CatalogLineRow>(
`SELECT variant.id AS variant_id, variant.product_id, variant.sku, variant.ean,
product.name,
COALESCE(price.offer_cents, price.net_unit_amount_cents) AS unit_price_cents,
price.net_unit_amount_cents,
price.offer_cents,
price.vat_rate
FROM catalog_product_variants variant
JOIN catalog_products product ON product.id = variant.product_id
@@ -198,15 +222,16 @@ export class CreatePosSaleUseCase {
const catalog = catalogResult.rows[0];
if (!catalog)
throw new AppError(404, 'POS_PRODUCT_NOT_FOUND', 'El producto ya no está disponible');
const discountCents = inputItem.discountCents ?? 0;
const grossUnitPriceCents = effectiveGrossUnitPrice(catalog);
const discountGrossCents = inputItem.discountCents ?? 0;
if (
!Number.isInteger(discountCents) ||
discountCents < 0 ||
discountCents > Number(catalog.unit_price_cents)
!Number.isInteger(discountGrossCents) ||
discountGrossCents < 0 ||
discountGrossCents > grossUnitPriceCents
) {
throw new AppError(400, 'POS_INVALID_DISCOUNT', 'El descuento de línea no es válido');
}
if (!lineDiscountsEnabled && discountCents > 0) {
if (!lineDiscountsEnabled && discountGrossCents > 0) {
throw new AppError(
403,
'POS_DISCOUNTS_DISABLED',
@@ -226,6 +251,18 @@ export class CreatePosSaleUseCase {
`Stock insuficiente para ${catalog.name}`,
);
}
const netUnitPriceCents =
catalog.offer_cents === null
? Number(catalog.net_unit_amount_cents)
: netFromGross(grossUnitPriceCents, catalog.vat_rate);
const discountedGrossUnitCents = grossUnitPriceCents - discountGrossCents;
const discountedNetUnitCents = netFromGross(discountedGrossUnitCents, catalog.vat_rate);
const discountNetCents = Math.max(0, netUnitPriceCents - discountedNetUnitCents);
const taxUnitCents = discountedGrossUnitCents - discountedNetUnitCents;
subtotalCents += grossUnitPriceCents * inputItem.quantity;
discountCents += discountGrossCents * inputItem.quantity;
taxCents += taxUnitCents * inputItem.quantity;
items.push({
kind: 'stock',
variantId: catalog.variant_id,
@@ -233,24 +270,15 @@ export class CreatePosSaleUseCase {
sku: catalog.sku,
ean: catalog.ean,
name: catalog.name,
unitPriceCents: Number(catalog.unit_price_cents),
discountCents,
taxCents: 0,
unitPriceCents: netUnitPriceCents,
discountCents: discountNetCents,
taxCents: taxUnitCents,
quantity: inputItem.quantity,
vatRate: catalog.vat_rate,
});
}
const subtotalCents = items.reduce(
(sum, item) => sum + item.unitPriceCents * item.quantity,
0,
);
const discountCents = items.reduce(
(sum, item) => sum + item.discountCents * item.quantity,
0,
);
const taxCents = items.reduce((sum, item) => sum + item.taxCents * item.quantity, 0);
const totalCents = subtotalCents - discountCents + taxCents;
const totalCents = subtotalCents - discountCents;
if (totalCents <= 0)
throw new AppError(400, 'POS_INVALID_TOTAL', 'El total de la venta debe ser positivo');