feat(ADM-018): completed feature
This commit is contained in:
94
project/src/modules/promotions/api/promotions.routes.ts
Normal file
94
project/src/modules/promotions/api/promotions.routes.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { PromotionServiceImpl } from '../application/promotion-service.js';
|
||||
import type { Promotion } from '../domain/promotion.js';
|
||||
import { PgPromotionRepository } from '../infrastructure/pg-promotion-repository.js';
|
||||
|
||||
export interface PromotionsRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const promotionBodySchema = z.object({
|
||||
code: z.string().min(1).max(64),
|
||||
type: z.enum(['percent', 'fixed_amount']),
|
||||
value: z.number().int().positive(),
|
||||
startsAt: z.coerce.date(),
|
||||
endsAt: z.coerce.date(),
|
||||
usageLimit: z.number().int().positive().optional().nullable(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const promotionPatchSchema = promotionBodySchema.partial().omit({ code: true });
|
||||
|
||||
export async function registerPromotionsRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: PromotionsRoutesDeps,
|
||||
): Promise<void> {
|
||||
const service = new PromotionServiceImpl(new PgPromotionRepository(deps.pool));
|
||||
|
||||
// List all promotions (admin)
|
||||
app.get('/promotions', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const items = await service.list();
|
||||
return reply.send({ items: items.map(serializePromotion) });
|
||||
});
|
||||
|
||||
// Create promotion (admin)
|
||||
app.post('/promotions', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(promotionBodySchema, request.body);
|
||||
const promotion = await service.create(input);
|
||||
return reply.code(201).send(serializePromotion(promotion));
|
||||
});
|
||||
|
||||
// Update promotion (admin)
|
||||
app.patch('/promotions/:code', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { code } = parseJson(z.object({ code: z.string() }), request.params);
|
||||
const patch = parseJson(promotionPatchSchema, request.body);
|
||||
try {
|
||||
const promotion = await service.update(code, patch);
|
||||
return reply.send(serializePromotion(promotion));
|
||||
} catch (error) {
|
||||
throw error instanceof Error && error.message === 'Promotion not found'
|
||||
? new AppError(404, 'NOT_FOUND', 'Promotion not found')
|
||||
: (error instanceof Error ? error : new Error('Unknown error'));
|
||||
}
|
||||
});
|
||||
|
||||
// Delete promotion (admin)
|
||||
app.delete('/promotions/:code', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { code } = parseJson(z.object({ code: z.string() }), request.params);
|
||||
try {
|
||||
await service.delete(code);
|
||||
} catch {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Promotion not found');
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
function serializePromotion(promotion: Promotion) {
|
||||
return {
|
||||
code: promotion.code,
|
||||
type: promotion.type,
|
||||
value: promotion.value,
|
||||
startsAt: promotion.startsAt.toISOString(),
|
||||
endsAt: promotion.endsAt.toISOString(),
|
||||
usageLimit: promotion.usageLimit,
|
||||
usageCount: promotion.usageCount,
|
||||
active: promotion.active,
|
||||
createdAt: promotion.createdAt.toISOString(),
|
||||
updatedAt: promotion.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { NewPromotion, Promotion, PromotionDiscount, PromotionType } from '../domain/promotion.js';
|
||||
import { PromotionInvalidError } from '../domain/errors.js';
|
||||
import type { PromotionRepository, PromotionService } from '../domain/ports.js';
|
||||
|
||||
function isActiveAt(promotion: Promotion, now: Date): boolean {
|
||||
return promotion.active && now >= promotion.startsAt && now <= promotion.endsAt;
|
||||
}
|
||||
|
||||
export class PromotionServiceImpl implements PromotionService {
|
||||
constructor(private readonly promotions: PromotionRepository) {}
|
||||
|
||||
async create(input: NewPromotion): Promise<Promotion> {
|
||||
return this.promotions.create(input);
|
||||
}
|
||||
|
||||
async list(): Promise<Promotion[]> {
|
||||
return this.promotions.findAll();
|
||||
}
|
||||
|
||||
async update(code: string, patch: Partial<NewPromotion>): Promise<Promotion> {
|
||||
return this.promotions.update(code, patch);
|
||||
}
|
||||
|
||||
async delete(code: string): Promise<void> {
|
||||
return this.promotions.delete(code);
|
||||
}
|
||||
|
||||
async calculateDiscount(
|
||||
code: string,
|
||||
cartTotalCents: number,
|
||||
now = new Date(),
|
||||
): Promise<PromotionDiscount> {
|
||||
const promotion = await this.promotions.findByCode(code);
|
||||
if (!promotion || !isActiveAt(promotion, now)) {
|
||||
throw new Error('Promotion not found or inactive');
|
||||
}
|
||||
|
||||
const discountCents =
|
||||
promotion.type === 'percent'
|
||||
? Math.round((cartTotalCents * promotion.value) / 10000)
|
||||
: Math.min(promotion.value, cartTotalCents);
|
||||
|
||||
return { code: promotion.code, type: promotion.type, discountCents };
|
||||
}
|
||||
|
||||
async validateCode(code: string, now = new Date()): Promise<Promotion> {
|
||||
const promotion = await this.promotions.findByCode(code);
|
||||
if (!promotion) {
|
||||
throw new PromotionInvalidError('Promotion not found');
|
||||
}
|
||||
if (!isActiveAt(promotion, now)) {
|
||||
throw new PromotionInvalidError('Promotion not active');
|
||||
}
|
||||
if (promotion.usageLimit != null && promotion.usageCount >= promotion.usageLimit) {
|
||||
throw new PromotionInvalidError('Promotion usage limit reached');
|
||||
}
|
||||
return promotion;
|
||||
}
|
||||
}
|
||||
13
project/src/modules/promotions/domain/errors.ts
Normal file
13
project/src/modules/promotions/domain/errors.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export class PromotionInvalidError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PromotionInvalidError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PromotionNotFoundError extends PromotionInvalidError {
|
||||
constructor() {
|
||||
super('Promotion code not found');
|
||||
this.name = 'PromotionNotFoundError';
|
||||
}
|
||||
}
|
||||
18
project/src/modules/promotions/domain/ports.ts
Normal file
18
project/src/modules/promotions/domain/ports.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NewPromotion, Promotion, PromotionDiscount } from './promotion.js';
|
||||
|
||||
export interface PromotionService {
|
||||
create(input: NewPromotion): Promise<Promotion>;
|
||||
calculateDiscount(code: string, cartTotalCents: number, now?: Date): Promise<PromotionDiscount>;
|
||||
validateCode(code: string, now?: Date): Promise<Promotion>;
|
||||
list(): Promise<Promotion[]>;
|
||||
update(code: string, patch: Partial<NewPromotion>): Promise<Promotion>;
|
||||
delete(code: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PromotionRepository {
|
||||
findByCode(code: string): Promise<Promotion | undefined>;
|
||||
create(input: NewPromotion): Promise<Promotion>;
|
||||
findAll(): Promise<Promotion[]>;
|
||||
update(code: string, patch: Partial<NewPromotion>): Promise<Promotion>;
|
||||
delete(code: string): Promise<void>;
|
||||
}
|
||||
30
project/src/modules/promotions/domain/promotion.ts
Normal file
30
project/src/modules/promotions/domain/promotion.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type PromotionType = 'percent' | 'fixed_amount';
|
||||
|
||||
export interface Promotion {
|
||||
code: string;
|
||||
type: PromotionType;
|
||||
value: number;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
usageLimit: number | null;
|
||||
usageCount: number;
|
||||
active: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface NewPromotion {
|
||||
code: string;
|
||||
type: PromotionType;
|
||||
value: number;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
usageLimit?: number | null;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface PromotionDiscount {
|
||||
code: string;
|
||||
type: PromotionType;
|
||||
discountCents: number;
|
||||
}
|
||||
19
project/src/modules/promotions/index.ts
Normal file
19
project/src/modules/promotions/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/** Public API of the promotions module. */
|
||||
import type pg from 'pg';
|
||||
import { PromotionServiceImpl } from './application/promotion-service.js';
|
||||
import type { PromotionService, PromotionRepository } from './domain/ports.js';
|
||||
import { PgPromotionRepository } from './infrastructure/pg-promotion-repository.js';
|
||||
|
||||
export { registerPromotionsRoutes, type PromotionsRoutesDeps } from './api/promotions.routes.js';
|
||||
export type { PromotionService as PromotionServicePort } from './domain/ports.js';
|
||||
export type { PromotionRepository } from './domain/ports.js';
|
||||
export type {
|
||||
NewPromotion,
|
||||
Promotion,
|
||||
PromotionDiscount,
|
||||
PromotionType,
|
||||
} from './domain/promotion.js';
|
||||
|
||||
export function createPromotionService(pool: pg.Pool): PromotionService {
|
||||
return new PromotionServiceImpl(new PgPromotionRepository(pool));
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type pg from 'pg';
|
||||
import type { NewPromotion, Promotion, PromotionType } from '../domain/promotion.js';
|
||||
import type { PromotionRepository } from '../domain/ports.js';
|
||||
|
||||
interface PromotionRow {
|
||||
code: string;
|
||||
type: PromotionType;
|
||||
value: number;
|
||||
starts_at: Date;
|
||||
ends_at: Date;
|
||||
usage_limit: number | null;
|
||||
usage_count: number;
|
||||
active: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export class PgPromotionRepository implements PromotionRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async findByCode(code: string): Promise<Promotion | undefined> {
|
||||
const result = await this.pool.query<PromotionRow>(
|
||||
'SELECT * FROM promotions_promotions WHERE code = $1',
|
||||
[code],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toPromotion(row) : undefined;
|
||||
}
|
||||
|
||||
async create(input: NewPromotion): Promise<Promotion> {
|
||||
const result = await this.pool.query<PromotionRow>(
|
||||
`INSERT INTO promotions_promotions (code, type, value, starts_at, ends_at, usage_limit, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET type = EXCLUDED.type, value = EXCLUDED.value, starts_at = EXCLUDED.starts_at,
|
||||
ends_at = EXCLUDED.ends_at, usage_limit = EXCLUDED.usage_limit,
|
||||
active = EXCLUDED.active, updated_at = now()
|
||||
RETURNING *`,
|
||||
[
|
||||
input.code,
|
||||
input.type,
|
||||
input.value,
|
||||
input.startsAt,
|
||||
input.endsAt,
|
||||
input.usageLimit ?? null,
|
||||
input.active ?? true,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error('promotions_promotions upsert returned no row');
|
||||
return toPromotion(row);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Promotion[]> {
|
||||
const result = await this.pool.query<PromotionRow>(
|
||||
'SELECT * FROM promotions_promotions ORDER BY created_at DESC',
|
||||
);
|
||||
return result.rows.map(toPromotion);
|
||||
}
|
||||
|
||||
async update(code: string, patch: Partial<NewPromotion>): Promise<Promotion> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (patch.code !== undefined) {
|
||||
fields.push(`code = $${idx++}`);
|
||||
values.push(patch.code);
|
||||
}
|
||||
if (patch.type !== undefined) {
|
||||
fields.push(`type = $${idx++}`);
|
||||
values.push(patch.type);
|
||||
}
|
||||
if (patch.value !== undefined) {
|
||||
fields.push(`value = $${idx++}`);
|
||||
values.push(patch.value);
|
||||
}
|
||||
if (patch.startsAt !== undefined) {
|
||||
fields.push(`starts_at = $${idx++}`);
|
||||
values.push(patch.startsAt);
|
||||
}
|
||||
if (patch.endsAt !== undefined) {
|
||||
fields.push(`ends_at = $${idx++}`);
|
||||
values.push(patch.endsAt);
|
||||
}
|
||||
if (patch.usageLimit !== undefined) {
|
||||
fields.push(`usage_limit = $${idx++}`);
|
||||
values.push(patch.usageLimit);
|
||||
}
|
||||
if (patch.active !== undefined) {
|
||||
fields.push(`active = $${idx++}`);
|
||||
values.push(patch.active);
|
||||
}
|
||||
|
||||
fields.push('updated_at = now()');
|
||||
values.push(code);
|
||||
|
||||
const result = await this.pool.query<PromotionRow>(
|
||||
`UPDATE promotions_promotions SET ${fields.join(', ')} WHERE code = $${idx} RETURNING *`,
|
||||
values,
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error('Promotion not found');
|
||||
return toPromotion(row);
|
||||
}
|
||||
|
||||
async delete(code: string): Promise<void> {
|
||||
await this.pool.query('DELETE FROM promotions_promotions WHERE code = $1', [code]);
|
||||
}
|
||||
}
|
||||
|
||||
function toPromotion(row: PromotionRow): Promotion {
|
||||
return {
|
||||
code: row.code,
|
||||
type: row.type,
|
||||
value: row.value,
|
||||
startsAt: row.starts_at,
|
||||
endsAt: row.ends_at,
|
||||
usageLimit: row.usage_limit,
|
||||
usageCount: row.usage_count,
|
||||
active: row.active,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PromotionServiceImpl } from '../application/promotion-service.js';
|
||||
import { PromotionInvalidError } from '../domain/errors.js';
|
||||
import type { PromotionRepository } from '../domain/ports.js';
|
||||
import type { NewPromotion, Promotion } from '../domain/promotion.js';
|
||||
|
||||
const PROMO: Promotion = {
|
||||
code: 'SAVE10',
|
||||
type: 'percent',
|
||||
value: 1000,
|
||||
startsAt: new Date('2026-01-01T00:00:00Z'),
|
||||
endsAt: new Date('2026-12-31T00:00:00Z'),
|
||||
usageLimit: null,
|
||||
usageCount: 0,
|
||||
active: true,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
function repo(promotion: Promotion = PROMO): PromotionRepository {
|
||||
return {
|
||||
findByCode: async () => promotion,
|
||||
create: async (input: NewPromotion) => ({
|
||||
...promotion,
|
||||
...input,
|
||||
usageLimit: input.usageLimit ?? null,
|
||||
usageCount: 0,
|
||||
active: input.active ?? true,
|
||||
}),
|
||||
findAll: async () => [promotion],
|
||||
update: async (_code: string, _patch: Partial<NewPromotion>) => promotion,
|
||||
delete: async (_code: string) => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('PromotionService', () => {
|
||||
it('calculates percent and fixed discounts capped by cart total', async () => {
|
||||
await expect(
|
||||
new PromotionServiceImpl(repo()).calculateDiscount(
|
||||
'save10',
|
||||
2000,
|
||||
new Date('2026-06-01T00:00:00Z'),
|
||||
),
|
||||
).resolves.toMatchObject({ code: 'SAVE10', discountCents: 200 });
|
||||
await expect(
|
||||
new PromotionServiceImpl(repo({ ...PROMO, type: 'fixed_amount', value: 5000 })).calculateDiscount(
|
||||
'x',
|
||||
1200,
|
||||
new Date('2026-06-01T00:00:00Z'),
|
||||
),
|
||||
).resolves.toMatchObject({ discountCents: 1200 });
|
||||
});
|
||||
|
||||
it('rejects expired or exhausted promotions', async () => {
|
||||
await expect(
|
||||
new PromotionServiceImpl(repo()).validateCode('save10', new Date('2027-01-01T00:00:00Z')),
|
||||
).rejects.toBeInstanceOf(PromotionInvalidError);
|
||||
await expect(
|
||||
new PromotionServiceImpl(repo({ ...PROMO, usageLimit: 1, usageCount: 1 })).validateCode(
|
||||
'save10',
|
||||
new Date('2026-06-01T00:00:00Z'),
|
||||
),
|
||||
).rejects.toBeInstanceOf(PromotionInvalidError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user