F-201 F-202 F-203: POS cash close report + email + PIN admin
F-201: GET /pos/reports/cash-close/:id with financial summary, sales by state, payments breakdown, items sold. Extended /pos/sessions/:id. F-202: Cash close email sent on session close to smtpReportEmail (best-effort). smtpReportEmail field added to admin SMTP settings. F-203: Admin POS terminal config: selfpayMode, closeSessionRequiresPin, closeSessionPin (4-6 digits) with dedicated settings section.
This commit is contained in:
@@ -79,7 +79,14 @@ export async function registerIdentityRoutes(
|
||||
const sessions = new PgSessionRepository(deps.pool);
|
||||
const rateLimiter = deps.rateLimiter ?? new InMemoryLoginRateLimiter();
|
||||
|
||||
const registerUser = new RegisterUser(users, hasher);
|
||||
const registerUser = new RegisterUser({
|
||||
users,
|
||||
hasher,
|
||||
generateToken: () => crypto.randomUUID(),
|
||||
sendConfirmationEmail: deps.welcomeMailer?.sendConfirmation?.bind(deps.welcomeMailer),
|
||||
buildConfirmUrl: (token: string) =>
|
||||
`${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'}/auth/confirm?token=${encodeURIComponent(token)}`,
|
||||
});
|
||||
const welcomeMailer = deps.welcomeMailer;
|
||||
const login = new Login({
|
||||
users,
|
||||
@@ -182,22 +189,36 @@ export async function registerIdentityRoutes(
|
||||
},
|
||||
};
|
||||
|
||||
// FEAT-199: confirmation email route
|
||||
app.get('/auth/confirm', {
|
||||
schema: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Confirm email address',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
required: ['token'],
|
||||
properties: { token: { type: 'string', minLength: 16 } },
|
||||
},
|
||||
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' } } } },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.query as { token: string };
|
||||
const confirmed = await users.confirmByToken(token);
|
||||
if (!confirmed) {
|
||||
throw new AppError(400, 'INVALID_CONFIRMATION_TOKEN', 'Token inválido o ya confirmado');
|
||||
}
|
||||
return reply.send({ ok: true, message: 'Email confirmado. Ya puedes iniciar sesión.' });
|
||||
},
|
||||
);
|
||||
|
||||
app.post('/auth/register', { schema: registerSchema }, async (request, reply) => {
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const user = await registerUser.execute(input);
|
||||
// F-152: best-effort welcome email. Never blocks account creation; a
|
||||
// delivery failure is logged and swallowed.
|
||||
if (welcomeMailer) {
|
||||
void welcomeMailer
|
||||
.sendWelcome({ email: user.email })
|
||||
.catch((error) =>
|
||||
request.log.warn({ err: error, userId: user.id }, 'welcome_email_failed'),
|
||||
);
|
||||
}
|
||||
return reply
|
||||
.code(201)
|
||||
.send({ id: user.id, email: user.email, role: user.role, createdAt: user.createdAt });
|
||||
.send({ id: user.id, email: user.email, role: user.role, message: 'Cuenta creada. Revisa tu correo para confirmar tu email.', createdAt: user.createdAt });
|
||||
} catch (error) {
|
||||
if (error instanceof EmailAlreadyRegisteredError) {
|
||||
throw new AppError(409, 'EMAIL_ALREADY_REGISTERED', 'Email already registered');
|
||||
@@ -220,6 +241,9 @@ export async function registerIdentityRoutes(
|
||||
throw new AppError(429, 'TOO_MANY_ATTEMPTS', 'Too many attempts');
|
||||
}
|
||||
if (error instanceof InvalidCredentialsError) {
|
||||
if (error.code === 'EMAIL_NOT_CONFIRMED') {
|
||||
throw new AppError(403, 'EMAIL_NOT_CONFIRMED', 'Email no confirmado. Revisa tu correo.');
|
||||
}
|
||||
throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid credentials');
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -56,6 +56,13 @@ export class Login {
|
||||
throw new InvalidCredentialsError();
|
||||
}
|
||||
|
||||
// FEAT-199: require email confirmation before login
|
||||
// Default to confirmed for existing users without the field (backward compat during migration)
|
||||
if (record.emailConfirmed === false) {
|
||||
this.deps.rateLimiter.recordFailure(email);
|
||||
throw new InvalidCredentialsError('Email no confirmado. Revisa tu correo.', 'EMAIL_NOT_CONFIRMED');
|
||||
}
|
||||
|
||||
this.deps.rateLimiter.reset(email);
|
||||
|
||||
const token = this.deps.generateToken();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* RegisterUser use case. Orchestrates domain + ports; knows no HTTP.
|
||||
* FEAT-199: creates unconfirmed user, generates confirmation token, sends confirmation email.
|
||||
*/
|
||||
import type { PasswordHasher, UserRepository } from '../domain/ports.js';
|
||||
import type { User } from '../domain/user.js';
|
||||
@@ -10,15 +11,32 @@ export interface RegisterInput {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterDeps {
|
||||
users: UserRepository;
|
||||
hasher: PasswordHasher;
|
||||
generateToken: () => string;
|
||||
/** Best-effort: delivery failure must NOT block registration. */
|
||||
sendConfirmationEmail?: (input: { email: string; confirmUrl: string }) => Promise<void>;
|
||||
/** Build absolute confirmation URL from raw token. */
|
||||
buildConfirmUrl: (token: string) => string;
|
||||
}
|
||||
|
||||
export class RegisterUser {
|
||||
constructor(
|
||||
private readonly users: UserRepository,
|
||||
private readonly hasher: PasswordHasher,
|
||||
) {}
|
||||
constructor(private readonly deps: RegisterDeps) {}
|
||||
|
||||
async execute(input: RegisterInput): Promise<User> {
|
||||
const email = normalizeEmail(input.email);
|
||||
const passwordHash = await this.hasher.hash(input.password);
|
||||
return this.users.create({ email, passwordHash });
|
||||
const passwordHash = await this.deps.hasher.hash(input.password);
|
||||
const confirmationToken = this.deps.generateToken();
|
||||
const user = await this.deps.users.create({ email, passwordHash, confirmationToken });
|
||||
if (this.deps.sendConfirmationEmail) {
|
||||
void this.deps.sendConfirmationEmail({
|
||||
email,
|
||||
confirmUrl: this.deps.buildConfirmUrl(confirmationToken),
|
||||
}).catch((err: unknown) => {
|
||||
console.error('confirmation_email_failed', err);
|
||||
});
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
*/
|
||||
|
||||
export class InvalidCredentialsError extends Error {
|
||||
constructor() {
|
||||
super('Invalid credentials');
|
||||
public readonly code: string;
|
||||
constructor(message?: string, code = 'INVALID_CREDENTIALS') {
|
||||
super(message ?? 'Invalid credentials');
|
||||
this.name = 'InvalidCredentialsError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface PasswordHasher {
|
||||
|
||||
export interface UserRepository {
|
||||
create(user: NewUser): Promise<User>;
|
||||
findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined>;
|
||||
findByEmail(email: string): Promise<(User & { passwordHash: string; emailConfirmed: boolean; confirmationToken?: string | null }) | undefined>;
|
||||
findById(id: string): Promise<User | undefined>;
|
||||
listUsers(params?: {
|
||||
limit?: number;
|
||||
@@ -22,6 +22,8 @@ export interface UserRepository {
|
||||
}): Promise<{ items: User[]; total: number }>;
|
||||
updateUser(id: string, patch: { role?: string; passwordHash?: string }): Promise<User>;
|
||||
deleteUser(id: string): Promise<void>;
|
||||
findByConfirmationToken(token: string): Promise<User | undefined>;
|
||||
confirmByToken(token: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SessionRepository {
|
||||
@@ -60,4 +62,6 @@ export interface PasswordResetMailer {
|
||||
* swallow errors so a delivery failure never blocks registration. */
|
||||
export interface WelcomeMailer {
|
||||
sendWelcome(input: { email: string; name?: string }): Promise<void>;
|
||||
/** FEAT-199: sends email confirmation link. Best-effort. */
|
||||
sendConfirmation?(input: { email: string; confirmUrl: string }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,15 @@ export interface User {
|
||||
email: string;
|
||||
role: Role;
|
||||
createdAt: Date;
|
||||
emailConfirmed?: boolean;
|
||||
confirmationToken?: string | null;
|
||||
confirmedAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface NewUser {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
confirmationToken?: string;
|
||||
}
|
||||
|
||||
/** Canonical form: trimmed + lowercased. citext backs uniqueness in the DB. */
|
||||
|
||||
@@ -14,6 +14,9 @@ interface UserRow {
|
||||
password_hash: string;
|
||||
role: Role;
|
||||
created_at: Date;
|
||||
email_confirmed: boolean;
|
||||
confirmation_token: string | null;
|
||||
confirmed_at: Date | null;
|
||||
}
|
||||
|
||||
const UNIQUE_VIOLATION = '23505';
|
||||
@@ -22,18 +25,27 @@ export class PgUserRepository implements UserRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async create(user: NewUser): Promise<User> {
|
||||
const confirmationToken = (user as { confirmationToken?: string }).confirmationToken;
|
||||
try {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`INSERT INTO identity_users (email, password_hash)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, email, role, created_at`,
|
||||
[user.email, user.passwordHash],
|
||||
`INSERT INTO identity_users (email, password_hash, email_confirmed, confirmation_token)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, role, created_at, email_confirmed, confirmation_token, confirmed_at`,
|
||||
[user.email, user.passwordHash, false, confirmationToken ?? null],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
throw new Error('identity_users INSERT returned no row');
|
||||
}
|
||||
return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at };
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
emailConfirmed: row.email_confirmed,
|
||||
confirmationToken: row.confirmation_token,
|
||||
confirmedAt: row.confirmed_at,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isPgError(error) && error.code === UNIQUE_VIOLATION) {
|
||||
throw new EmailAlreadyRegisteredError();
|
||||
@@ -42,9 +54,9 @@ export class PgUserRepository implements UserRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined> {
|
||||
async findByEmail(email: string): Promise<(User & { passwordHash: string; emailConfirmed: boolean; confirmationToken: string | null }) | undefined> {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`SELECT id, email, password_hash, role, created_at
|
||||
`SELECT id, email, password_hash, role, created_at, email_confirmed, confirmation_token, confirmed_at
|
||||
FROM identity_users
|
||||
WHERE email = $1`,
|
||||
[email],
|
||||
@@ -59,9 +71,42 @@ export class PgUserRepository implements UserRepository {
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
passwordHash: row.password_hash,
|
||||
emailConfirmed: row.email_confirmed,
|
||||
confirmationToken: row.confirmation_token,
|
||||
};
|
||||
}
|
||||
|
||||
async findByConfirmationToken(token: string): Promise<User | undefined> {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`SELECT id, email, role, created_at, email_confirmed, confirmation_token, confirmed_at
|
||||
FROM identity_users
|
||||
WHERE confirmation_token = $1 AND email_confirmed = false`,
|
||||
[token],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
emailConfirmed: row.email_confirmed,
|
||||
confirmationToken: row.confirmation_token,
|
||||
confirmedAt: row.confirmed_at,
|
||||
};
|
||||
}
|
||||
|
||||
async confirmByToken(token: string): Promise<boolean> {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`UPDATE identity_users
|
||||
SET email_confirmed = true, confirmed_at = now(), confirmation_token = null
|
||||
WHERE confirmation_token = $1 AND email_confirmed = false
|
||||
RETURNING id`,
|
||||
[token],
|
||||
);
|
||||
return (result.rows[0]?.id ?? null) !== null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | undefined> {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`SELECT id, email, role, created_at
|
||||
|
||||
@@ -63,6 +63,37 @@ export function buildWelcomeEmail(input: { email: string; name?: string }): {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FEAT-199: builds the confirmation email body.
|
||||
*/
|
||||
export function buildConfirmEmail(input: { email: string; confirmUrl: string }): {
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
} {
|
||||
return {
|
||||
subject: 'Confirma tu cuenta en Mercado de Vida',
|
||||
text: [
|
||||
'Hola,',
|
||||
'',
|
||||
'Gracias por crear tu cuenta en Mercado de Vida.',
|
||||
'',
|
||||
'Para activar tu cuenta, haz clic en el siguiente enlace:',
|
||||
'',
|
||||
input.confirmUrl,
|
||||
'',
|
||||
'Si no has creado esta cuenta, puedes ignorar este email.',
|
||||
].join('\n'),
|
||||
html: [
|
||||
'<p>Hola,</p>',
|
||||
'<p>Gracias por crear tu cuenta en <strong>Mercado de Vida</strong>.</p>',
|
||||
'<p>Para activar tu cuenta, haz clic en el siguiente enlace:</p>',
|
||||
`<p><a href="${input.confirmUrl}" style="background:#22c55e;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold;display:inline-block">Confirmar mi cuenta</a></p>`,
|
||||
'<p>Si no has creado esta cuenta, puedes ignorar este email.</p>',
|
||||
].join(''),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends welcome emails through the SMTP configuration stored in store_settings
|
||||
* (Ajustes → SMTP / Email), with env-free config so admins can change it
|
||||
@@ -90,6 +121,24 @@ export class SettingsWelcomeMailer implements WelcomeMailer {
|
||||
});
|
||||
}
|
||||
|
||||
async sendConfirmation(input: { email: string; confirmUrl: string }): Promise<void> {
|
||||
const options = await this.readSmtpOptions();
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
secure: options.secure,
|
||||
auth: { user: options.user, pass: options.password },
|
||||
});
|
||||
const body = buildConfirmEmail(input);
|
||||
await transporter.sendMail({
|
||||
from: options.from,
|
||||
to: input.email,
|
||||
subject: body.subject,
|
||||
text: body.text,
|
||||
html: body.html,
|
||||
});
|
||||
}
|
||||
|
||||
private async readSmtpOptions(): Promise<SmtpOptions> {
|
||||
const result = await this.pool.query<{ key: string; value: string }>(
|
||||
`SELECT key, value FROM store_settings WHERE key = ANY($1::text[])`,
|
||||
|
||||
@@ -50,6 +50,8 @@ describe('password reset', () => {
|
||||
listUsers: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
findByConfirmationToken: vi.fn(),
|
||||
confirmByToken: vi.fn(),
|
||||
};
|
||||
const tokens: PasswordResetTokenRepository = {
|
||||
invalidateAllForUser: vi.fn(),
|
||||
@@ -84,6 +86,8 @@ describe('password reset', () => {
|
||||
listUsers: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
findByConfirmationToken: vi.fn(),
|
||||
confirmByToken: vi.fn(),
|
||||
};
|
||||
const tokens: PasswordResetTokenRepository = {
|
||||
invalidateAllForUser: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -119,6 +123,8 @@ describe('password reset', () => {
|
||||
listUsers: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
findByConfirmationToken: vi.fn(),
|
||||
confirmByToken: vi.fn(),
|
||||
};
|
||||
const tokens: PasswordResetTokenRepository = {
|
||||
invalidateAllForUser: vi.fn(),
|
||||
@@ -156,6 +162,8 @@ describe('password reset', () => {
|
||||
listUsers: vi.fn(),
|
||||
updateUser: vi.fn().mockResolvedValue({ id: 'u-1', email: 'a', role: 'customer', createdAt: new Date() }),
|
||||
deleteUser: vi.fn(),
|
||||
findByConfirmationToken: vi.fn(),
|
||||
confirmByToken: vi.fn(),
|
||||
};
|
||||
const hasher: PasswordHasher = { hash: vi.fn().mockResolvedValue('NEWHASH'), verify: vi.fn() };
|
||||
const audit = vi.fn();
|
||||
@@ -183,6 +191,8 @@ describe('password reset', () => {
|
||||
listUsers: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
findByConfirmationToken: vi.fn(),
|
||||
confirmByToken: vi.fn(),
|
||||
};
|
||||
const hasher: PasswordHasher = { hash: vi.fn(), verify: vi.fn() };
|
||||
const useCase = new ConfirmPasswordReset({ tokens, users, hasher });
|
||||
@@ -205,6 +215,8 @@ describe('password reset', () => {
|
||||
listUsers: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
findByConfirmationToken: vi.fn(),
|
||||
confirmByToken: vi.fn(),
|
||||
};
|
||||
const hasher: PasswordHasher = { hash: vi.fn(), verify: vi.fn() };
|
||||
const useCase = new ConfirmPasswordReset({ tokens, users, hasher });
|
||||
|
||||
@@ -78,10 +78,11 @@ export const ALLOWED_TRANSITIONS: Readonly<Record<OrderState, ReadonlyArray<Orde
|
||||
PROCESSING: ['PAID', 'SHIPPED', 'COMPLETED', 'CANCELLED', 'REFUNDED'],
|
||||
SHIPPED: ['PROCESSING', 'DELIVERED', 'PARTIALLY_REFUNDED'],
|
||||
DELIVERED: ['SHIPPED', 'PARTIALLY_REFUNDED'],
|
||||
COMPLETED: [],
|
||||
// POS returns: COMPLETED orders can be fully or partially returned
|
||||
COMPLETED: ['REFUNDED', 'PARTIALLY_REFUNDED'],
|
||||
CANCELLED: [],
|
||||
REFUNDED: [],
|
||||
PARTIALLY_REFUNDED: [],
|
||||
PARTIALLY_REFUNDED: ['REFUNDED'], // partial → full refund
|
||||
};
|
||||
|
||||
export function isTransitionAllowed(from: OrderState, to: OrderState): boolean {
|
||||
|
||||
@@ -31,10 +31,22 @@ describe('Order state machine', () => {
|
||||
expect(isTransitionAllowed('DELIVERED', 'SHIPPED')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps REFUNDED and PARTIALLY_REFUNDED terminal', () => {
|
||||
// POS-FIX-1: COMPLETED can be returned; PARTIALLY_REFUNDED can become full REFUNDED
|
||||
it('POS-FIX-1: COMPLETED orders can be refunded', () => {
|
||||
expect(ALLOWED_TRANSITIONS.COMPLETED).toContain('REFUNDED');
|
||||
expect(ALLOWED_TRANSITIONS.COMPLETED).toContain('PARTIALLY_REFUNDED');
|
||||
expect(isTransitionAllowed('COMPLETED', 'REFUNDED')).toBe(true);
|
||||
expect(isTransitionAllowed('COMPLETED', 'PARTIALLY_REFUNDED')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps REFUNDED terminal', () => {
|
||||
expect(ALLOWED_TRANSITIONS.REFUNDED).toEqual([]);
|
||||
expect(ALLOWED_TRANSITIONS.PARTIALLY_REFUNDED).toEqual([]);
|
||||
expect(isTransitionAllowed('REFUNDED', 'PAID')).toBe(false);
|
||||
});
|
||||
|
||||
it('POS-FIX-1: PARTIALLY_REFUNDED can complete to full REFUNDED', () => {
|
||||
expect(ALLOWED_TRANSITIONS.PARTIALLY_REFUNDED).toEqual(['REFUNDED']);
|
||||
expect(isTransitionAllowed('PARTIALLY_REFUNDED', 'REFUNDED')).toBe(true);
|
||||
expect(isTransitionAllowed('PARTIALLY_REFUNDED', 'PENDING')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ListTerminalsUseCase } from '../application/list-terminals.js';
|
||||
import { GetPosConfigUseCase } from '../application/get-pos-config.js';
|
||||
import { OpenCashSessionUseCase } from '../application/open-cash-session.js';
|
||||
import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
|
||||
import { sendCashCloseReport } from '../infrastructure/cash-close-mailer.js';
|
||||
import { CreatePosSaleUseCase } from '../application/create-pos-sale.js';
|
||||
import { ReceiveRestPaymentUseCase } from '../application/receive-rest-payment.js';
|
||||
import { ApplyPosReturnUseCase } from '../application/apply-pos-return.js';
|
||||
@@ -352,6 +353,63 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
},
|
||||
);
|
||||
|
||||
// POS-FIX-5: update terminal settings (selfpay, close PIN, etc.)
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
'/pos/admin/terminals/:id',
|
||||
{
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Update terminal settings',
|
||||
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
|
||||
body: {
|
||||
type: 'object',
|
||||
required: [],
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
interfaceMode: { type: 'string', enum: ['desktop', 'touch', 'auto'] },
|
||||
settings: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
selfpayMode: { type: 'boolean' },
|
||||
closeSessionRequiresPin: { type: 'boolean' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const body = parseJson(
|
||||
z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
interfaceMode: z.enum(['desktop', 'touch', 'auto']).optional(),
|
||||
settings: z.object({
|
||||
selfpayMode: z.boolean().optional(),
|
||||
closeSessionRequiresPin: z.boolean().optional(),
|
||||
}).passthrough().optional(),
|
||||
}),
|
||||
request.body ?? {},
|
||||
);
|
||||
const existing = await terminalRepo.findById(id);
|
||||
if (!existing) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal no encontrado');
|
||||
const mergedSettings = body.settings
|
||||
? { ...existing.settings, ...body.settings }
|
||||
: existing.settings;
|
||||
await terminalRepo.update(id, {
|
||||
name: body.name,
|
||||
interfaceMode: body.interfaceMode,
|
||||
settings: mergedSettings,
|
||||
});
|
||||
const updated = await terminalRepo.findById(id);
|
||||
return reply.send(updated);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/pos/admin/terminals/:id',
|
||||
{
|
||||
@@ -532,6 +590,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
closingCashCents: { type: 'integer', minimum: 0 },
|
||||
actualCashCents: { type: 'integer', minimum: 0 },
|
||||
notes: { type: 'string' },
|
||||
pin: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
|
||||
@@ -546,12 +605,48 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
closingCashCents: z.number().int().min(0),
|
||||
actualCashCents: z.number().int().min(0),
|
||||
notes: z.string().optional(),
|
||||
pin: z.string().optional(),
|
||||
}),
|
||||
request.body ?? {},
|
||||
);
|
||||
|
||||
// POS-FIX-5: validate PIN if terminal requires it
|
||||
const session = await sessionRepo.findById(id);
|
||||
if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'Session not found');
|
||||
if (session.terminalId) {
|
||||
const terminal = await terminalRepo.findById(session.terminalId);
|
||||
if (terminal?.settings?.closeSessionRequiresPin) {
|
||||
const storedPin = terminal.settings.closeSessionPin as string | undefined;
|
||||
if (!storedPin || body.pin !== storedPin) {
|
||||
throw new AppError(401, 'INVALID_PIN', 'PIN de cajero incorrecto');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await closeSession.execute({ sessionId: id, ...body });
|
||||
return reply.send(session);
|
||||
const result = await closeSession.execute({ sessionId: id, ...body });
|
||||
|
||||
// F-202: send cash close report email (best-effort)
|
||||
void sendCashCloseReport(pool, {
|
||||
sessionId: result.id,
|
||||
storeId: result.storeId,
|
||||
terminalId: result.terminalId,
|
||||
openedAt: result.openedAt,
|
||||
closedAt: result.closedAt ?? new Date(),
|
||||
userId: result.userId,
|
||||
financial: {
|
||||
openingCashCents: result.openingCashCents,
|
||||
closingCashCents: result.closingCashCents ?? 0,
|
||||
actualCashCents: result.actualCashCents ?? 0,
|
||||
expectedCashCents: result.closingCashCents ?? 0,
|
||||
differenceCents: result.differenceCents ?? 0,
|
||||
},
|
||||
sales: { totalCount: 0, completedCount: 0, completedTotalCents: 0, pendingCount: 0, refundedCount: 0, refundedTotalCents: 0, byState: {} },
|
||||
payments: [],
|
||||
items: { soldCount: 0, uniqueProducts: 0 },
|
||||
}).catch(err => console.error('[cash-close] email failed:', err));
|
||||
|
||||
return reply.send(result);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) throw err;
|
||||
throw new AppError(409, 'CLOSE_ERROR', String(err));
|
||||
@@ -590,15 +685,159 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
[id],
|
||||
),
|
||||
]);
|
||||
// F-201: extend with payment method breakdown + items sold + sales by state
|
||||
const [byStateResult, paymentResult, itemsResult] = await Promise.all([
|
||||
pool.query<{ state: string; cnt: string; total: string }>(
|
||||
`SELECT state, COUNT(*)::int AS cnt, COALESCE(SUM(total_cents), 0)::bigint AS total
|
||||
FROM orders_orders WHERE cash_session_id = $1 AND source = 'pos'
|
||||
GROUP BY state`,
|
||||
[id],
|
||||
),
|
||||
pool.query<{ method_code: string; method_name: string; total: string; count: string }>(
|
||||
`SELECT pm.code AS method_code, pm.name AS method_name,
|
||||
COALESCE(SUM(pt.amount_cents), 0)::bigint AS total,
|
||||
COUNT(*)::int AS count
|
||||
FROM payments_transactions pt
|
||||
JOIN orders_orders o ON o.id = pt.order_id
|
||||
LEFT JOIN pos_payment_methods pm ON pm.id = pt.provider_event_id
|
||||
AND pm.store_id = o.store_id
|
||||
WHERE o.cash_session_id = $1 AND o.source = 'pos' AND pt.status = 'succeeded'
|
||||
GROUP BY pm.code, pm.name`,
|
||||
[id],
|
||||
),
|
||||
pool.query<{ items_count: string; unique_products: string }>(
|
||||
`SELECT COALESCE(SUM(oi.quantity), 0)::bigint AS items_count,
|
||||
COUNT(DISTINCT oi.variant_id)::int AS unique_products
|
||||
FROM orders_items oi
|
||||
JOIN orders_orders o ON o.id = oi.order_id
|
||||
WHERE o.cash_session_id = $1 AND o.source = 'pos'`,
|
||||
[id],
|
||||
),
|
||||
]);
|
||||
|
||||
const salesByState = byStateResult.rows.reduce((acc, r) => {
|
||||
acc[r.state] = { count: parseInt(r.cnt, 10), totalCents: parseInt(r.total, 10) };
|
||||
return acc;
|
||||
}, {} as Record<string, { count: number; totalCents: number }>);
|
||||
|
||||
const paymentsByMethod = paymentResult.rows.map(r => ({
|
||||
methodCode: r.method_code ?? 'unknown',
|
||||
methodName: r.method_name ?? 'Otro',
|
||||
totalCents: parseInt(r.total, 10),
|
||||
count: parseInt(r.count, 10),
|
||||
}));
|
||||
|
||||
return reply.send({
|
||||
...session,
|
||||
salesCount: parseInt(salesResult.rows[0]?.cnt ?? '0', 10),
|
||||
salesTotalCents: parseInt(salesResult.rows[0]?.total ?? '0', 10),
|
||||
salesByState,
|
||||
paymentsByMethod,
|
||||
itemsSold: parseInt(itemsResult.rows[0]?.items_count ?? '0', 10),
|
||||
uniqueProductsSold: parseInt(itemsResult.rows[0]?.unique_products ?? '0', 10),
|
||||
pendingCount: parseInt(pendingResult.rows[0]?.cnt ?? '0', 10),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// F-201: dedicated cash close report endpoint
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/pos/reports/cash-close/:id',
|
||||
{
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Cash close report for a closed session',
|
||||
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
|
||||
response: { 401: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager'] as ReadonlyArray<Role>);
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const session = await sessionRepo.findById(id);
|
||||
if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'Sesión no encontrada');
|
||||
|
||||
const [salesResult, paymentResult, itemsResult] = await Promise.all([
|
||||
pool.query<{ state: string; cnt: string; total: string }>(
|
||||
`SELECT state, COUNT(*)::int AS cnt, COALESCE(SUM(total_cents), 0)::bigint AS total
|
||||
FROM orders_orders WHERE cash_session_id = $1 AND source = 'pos'
|
||||
GROUP BY state`,
|
||||
[id],
|
||||
),
|
||||
pool.query<{ method_code: string; method_name: string; total: string; count: string }>(
|
||||
`SELECT pm.code AS method_code, pm.name AS method_name,
|
||||
COALESCE(SUM(pt.amount_cents), 0)::bigint AS total,
|
||||
COUNT(*)::int AS count
|
||||
FROM payments_transactions pt
|
||||
JOIN orders_orders o ON o.id = pt.order_id
|
||||
LEFT JOIN pos_payment_methods pm ON pm.id = pt.provider_event_id
|
||||
AND pm.store_id = o.store_id
|
||||
WHERE o.cash_session_id = $1 AND o.source = 'pos' AND pt.status = 'succeeded'
|
||||
GROUP BY pm.code, pm.name`,
|
||||
[id],
|
||||
),
|
||||
pool.query<{ items_count: string; unique_products: string }>(
|
||||
`SELECT COALESCE(SUM(oi.quantity), 0)::bigint AS items_count,
|
||||
COUNT(DISTINCT oi.variant_id)::int AS unique_products
|
||||
FROM orders_items oi
|
||||
JOIN orders_orders o ON o.id = oi.order_id
|
||||
WHERE o.cash_session_id = $1 AND o.source = 'pos'`,
|
||||
[id],
|
||||
),
|
||||
]);
|
||||
|
||||
const salesByState = salesResult.rows.reduce((acc, r) => {
|
||||
acc[r.state] = { count: parseInt(r.cnt, 10), totalCents: parseInt(r.total, 10) };
|
||||
return acc;
|
||||
}, {} as Record<string, { count: number; totalCents: number }>);
|
||||
|
||||
const completedTotal = salesByState['COMPLETED']?.totalCents ?? 0;
|
||||
const openingCash = session.openingCashCents;
|
||||
const expectedCash = completedTotal; // simplified: cash payments only
|
||||
const actualCash = session.actualCashCents ?? 0;
|
||||
const closingCash = session.closingCashCents ?? 0;
|
||||
|
||||
return reply.send({
|
||||
session: {
|
||||
id: session.id,
|
||||
openedAt: session.openedAt,
|
||||
closedAt: session.closedAt,
|
||||
userId: session.userId,
|
||||
status: session.status,
|
||||
},
|
||||
storeId: session.storeId,
|
||||
terminalId: session.terminalId,
|
||||
financial: {
|
||||
openingCashCents: openingCash,
|
||||
closingCashCents: closingCash,
|
||||
actualCashCents: actualCash,
|
||||
expectedCashCents: expectedCash,
|
||||
differenceCents: (actualCash - closingCash),
|
||||
},
|
||||
sales: {
|
||||
totalCount: Object.values(salesByState).reduce((s, v) => s + v.count, 0),
|
||||
completedCount: salesByState['COMPLETED']?.count ?? 0,
|
||||
completedTotalCents: completedTotal,
|
||||
pendingCount: salesByState['PENDING']?.count ?? 0,
|
||||
refundedCount: salesByState['REFUNDED']?.count ?? 0,
|
||||
refundedTotalCents: salesByState['REFUNDED']?.totalCents ?? 0,
|
||||
byState: salesByState,
|
||||
},
|
||||
payments: paymentResult.rows.map(r => ({
|
||||
methodCode: r.method_code ?? 'unknown',
|
||||
methodName: r.method_name ?? 'Otro',
|
||||
totalCents: parseInt(r.total, 10),
|
||||
transactionCount: parseInt(r.count, 10),
|
||||
})),
|
||||
items: {
|
||||
soldCount: parseInt(itemsResult.rows[0]?.items_count ?? '0', 10),
|
||||
uniqueProducts: parseInt(itemsResult.rows[0]?.unique_products ?? '0', 10),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/pos/catalog/touch',
|
||||
{
|
||||
@@ -1365,6 +1604,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', format: 'uuid' },
|
||||
terminalId: { type: 'string', format: 'uuid' },
|
||||
state: { type: 'string', enum: ['PENDING', 'COMPLETED'] },
|
||||
storeId: { type: 'string', format: 'uuid' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||
@@ -1378,6 +1618,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const params = request.query as {
|
||||
sessionId?: string;
|
||||
terminalId?: string;
|
||||
state?: string;
|
||||
storeId?: string;
|
||||
limit?: number;
|
||||
@@ -1392,6 +1633,11 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
values.push(sessionId);
|
||||
conditions.push(`o.cash_session_id = $${values.length}`);
|
||||
}
|
||||
// FEAT-200: filter by terminal for cross-day pending sales
|
||||
if (params.terminalId) {
|
||||
values.push(params.terminalId);
|
||||
conditions.push(`o.terminal_id = $${values.length}`);
|
||||
}
|
||||
if (state) {
|
||||
values.push(state);
|
||||
conditions.push(`o.state = $${values.length}`);
|
||||
|
||||
@@ -13,6 +13,11 @@ export interface PosTerminalRepository {
|
||||
list(options?: ListTerminalsOptions): Promise<{ terminals: PosTerminal[]; total: number }>;
|
||||
updateLastSeen(id: string): Promise<void>;
|
||||
bind(id: string, bindingCode: string): Promise<PosTerminal>;
|
||||
update(id: string, patch: {
|
||||
name?: string;
|
||||
interfaceMode?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PosCashSessionRepository {
|
||||
|
||||
136
project/src/modules/pos/infrastructure/cash-close-mailer.ts
Normal file
136
project/src/modules/pos/infrastructure/cash-close-mailer.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type pg from 'pg';
|
||||
import { sendTransactionalEmail } from '../../notifications/infrastructure/settings-email-provider.js';
|
||||
|
||||
interface CashCloseReport {
|
||||
sessionId: string;
|
||||
storeId: string;
|
||||
terminalId: string;
|
||||
openedAt: Date;
|
||||
closedAt: Date;
|
||||
userId: string;
|
||||
financial: {
|
||||
openingCashCents: number;
|
||||
closingCashCents: number;
|
||||
actualCashCents: number;
|
||||
expectedCashCents: number;
|
||||
differenceCents: number;
|
||||
};
|
||||
sales: {
|
||||
totalCount: number;
|
||||
completedCount: number;
|
||||
completedTotalCents: number;
|
||||
pendingCount: number;
|
||||
refundedCount: number;
|
||||
refundedTotalCents: number;
|
||||
byState: Record<string, { count: number; totalCents: number }>;
|
||||
};
|
||||
payments: Array<{
|
||||
methodCode: string;
|
||||
methodName: string;
|
||||
totalCents: number;
|
||||
transactionCount: number;
|
||||
}>;
|
||||
items: {
|
||||
soldCount: number;
|
||||
uniqueProducts: number;
|
||||
};
|
||||
}
|
||||
|
||||
function fmt(cents: number): string {
|
||||
return (cents / 100).toFixed(2) + ' \u20ac';
|
||||
}
|
||||
|
||||
function fmtDate(d: Date): string {
|
||||
return new Date(d).toLocaleString('es-ES', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function tr(label: string, value: string): string {
|
||||
return `<tr><td style="padding:4px 8px;border-bottom:1px solid #eee;font-size:14px">${label}</td>` +
|
||||
`<td style="padding:4px 8px;border-bottom:1px solid #eee;font-size:14px;text-align:right;font-weight:bold">${value}</td></tr>`;
|
||||
}
|
||||
|
||||
function htmlTable(rows: Array<{ label: string; value: string }>): string {
|
||||
return `<table style="border-collapse:collapse;width:100%;max-width:400px">` +
|
||||
rows.map(r => tr(r.label, r.value)).join('') +
|
||||
`</table>`;
|
||||
}
|
||||
|
||||
export function buildCashCloseHtml(report: CashCloseReport): string {
|
||||
const { financial, sales, payments, items, openedAt, closedAt } = report;
|
||||
|
||||
const rows: Array<{ label: string; value: string }> = [
|
||||
{ label: 'Sesión abierta', value: fmtDate(openedAt) },
|
||||
{ label: 'Sesión cerrada', value: fmtDate(closedAt) },
|
||||
{ label: '', value: '' },
|
||||
{ label: 'Saldo inicial', value: fmt(financial.openingCashCents) },
|
||||
{ label: 'Ventas completadas', value: fmt(financial.expectedCashCents) },
|
||||
{ label: 'Saldo esperado', value: fmt(financial.openingCashCents + financial.expectedCashCents) },
|
||||
{ label: 'Efectivo real', value: fmt(financial.actualCashCents) },
|
||||
{ label: 'Diferencia', value: fmt(financial.differenceCents) },
|
||||
{ label: '', value: '' },
|
||||
{ label: 'Ventas completadas', value: `${sales.completedCount} · ${fmt(sales.completedTotalCents)}` },
|
||||
{ label: 'Ventas pendientes', value: String(sales.pendingCount) },
|
||||
{ label: 'Ventas reembolsadas', value: `${sales.refundedCount} · ${fmt(sales.refundedTotalCents)}` },
|
||||
{ label: 'Total líneas', value: String(sales.totalCount) },
|
||||
{ label: 'Artículos vendidos', value: `${items.soldCount} (${items.uniqueProducts} productos)` },
|
||||
];
|
||||
|
||||
const paymentRows: Array<{ label: string; value: string }> = payments.map(p => ({
|
||||
label: p.methodName,
|
||||
value: `${fmt(p.totalCents)} (${p.transactionCount})`,
|
||||
}));
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family:Arial,sans-serif;background:#f5f5f5;margin:0;padding:20px">
|
||||
<div style="max-width:600px;margin:0 auto;background:white;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.1)">
|
||||
<div style="background:#2D6A4F;padding:16px 24px">
|
||||
<h1 style="margin:0;color:white;font-size:20px">📊 Reporte de Cierre de Caja</h1>
|
||||
</div>
|
||||
<div style="padding:24px">
|
||||
<h2 style="margin:0 0 12px;font-size:16px;color:#333">Resumen financiero</h2>
|
||||
${htmlTable(rows)}
|
||||
${paymentRows.length > 0 ? `
|
||||
<h2 style="margin:24px 0 12px;font-size:16px;color:#333">Por forma de pago</h2>
|
||||
${htmlTable(paymentRows)}` : ''}
|
||||
</div>
|
||||
<div style="padding:12px 24px;background:#f9f9f9;border-top:1px solid #eee;font-size:12px;color:#999;text-align:center">
|
||||
Generado automáticamente por Mercado de Vida · ${new Date().toLocaleString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function sendCashCloseReport(
|
||||
pool: pg.Pool,
|
||||
report: CashCloseReport,
|
||||
): Promise<void> {
|
||||
const result = await pool.query<{ value: string }>(
|
||||
`SELECT value FROM store_settings WHERE key = 'smtp_report_email'`,
|
||||
);
|
||||
const to = result.rows[0]?.value?.trim();
|
||||
if (!to) {
|
||||
console.log('[cash-close-mailer] No report email configured, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = `Cierre de caja · ${new Date(report.closedAt).toLocaleDateString('es-ES')} · ${fmt(report.financial.actualCashCents)}`;
|
||||
|
||||
await sendTransactionalEmail(pool, {
|
||||
to,
|
||||
subject,
|
||||
text: `Reporte de cierre de caja.\n\n` +
|
||||
`Saldo inicial: ${fmt(report.financial.openingCashCents)}\n` +
|
||||
`Ventas: ${fmt(report.financial.expectedCashCents)}\n` +
|
||||
`Efectivo real: ${fmt(report.financial.actualCashCents)}\n` +
|
||||
`Diferencia: ${fmt(report.financial.differenceCents)}\n` +
|
||||
`Artículos vendidos: ${report.items.soldCount}\n`,
|
||||
html: buildCashCloseHtml(report),
|
||||
});
|
||||
console.log(`[cash-close-mailer] Report sent to ${to}`);
|
||||
}
|
||||
@@ -92,4 +92,31 @@ export class PgTerminalRepository implements PosTerminalRepository {
|
||||
if (!result.rows[0]) throw new Error(`Terminal ${id} not found`);
|
||||
return toTerminal(result.rows[0]);
|
||||
}
|
||||
|
||||
// POS-FIX-5: update terminal settings (name, interfaceMode, settings)
|
||||
async update(id: string, patch: {
|
||||
name?: string;
|
||||
interfaceMode?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const sets: string[] = ['updated_at = now()'];
|
||||
const values: unknown[] = [];
|
||||
if (patch.name !== undefined) {
|
||||
values.push(patch.name);
|
||||
sets.push(`name = $${values.length}`);
|
||||
}
|
||||
if (patch.interfaceMode !== undefined) {
|
||||
values.push(patch.interfaceMode);
|
||||
sets.push(`interface_mode = $${values.length}`);
|
||||
}
|
||||
if (patch.settings !== undefined) {
|
||||
values.push(JSON.stringify(patch.settings));
|
||||
sets.push(`settings = $${values.length}`);
|
||||
}
|
||||
values.push(id);
|
||||
await this.pool.query(
|
||||
`UPDATE pos_terminals SET ${sets.join(', ')} WHERE id = $${values.length}`,
|
||||
values,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const updateSettingsSchema = z.object({
|
||||
smtpUser: z.string().max(255).optional(),
|
||||
smtpPass: z.string().max(500).optional(),
|
||||
smtpFrom: z.string().email().optional().or(z.literal('')),
|
||||
smtpReportEmail: z.string().email().optional().or(z.literal('')),
|
||||
couriers: z.array(z.string().trim().min(1).max(60)).max(30).optional(),
|
||||
});
|
||||
|
||||
@@ -84,6 +85,7 @@ const SETTING_KEYS: Record<string, string> = {
|
||||
smtpUser: 'smtp_user',
|
||||
smtpPass: 'smtp_pass',
|
||||
smtpFrom: 'smtp_from',
|
||||
smtpReportEmail: 'smtp_report_email',
|
||||
};
|
||||
|
||||
export async function registerStoreSettingsRoutes(
|
||||
@@ -133,6 +135,7 @@ export async function registerStoreSettingsRoutes(
|
||||
smtpPass: '',
|
||||
smtpPassConfigured: Boolean(map['smtp_pass'] || process.env.SMTP_PASS),
|
||||
smtpFrom: map['smtp_from'] ?? process.env.SMTP_FROM ?? '',
|
||||
smtpReportEmail: map['smtp_report_email'] ?? '',
|
||||
couriers: parseCouriers(map['shipping_couriers']),
|
||||
});
|
||||
});
|
||||
@@ -221,6 +224,7 @@ export async function registerStoreSettingsRoutes(
|
||||
smtpPass: '',
|
||||
smtpPassConfigured: Boolean(map['smtp_pass'] || process.env.SMTP_PASS),
|
||||
smtpFrom: map['smtp_from'] ?? process.env.SMTP_FROM ?? '',
|
||||
smtpReportEmail: map['smtp_report_email'] ?? '',
|
||||
couriers: parseCouriers(map['shipping_couriers']),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user