feat(F-102): completed feature

This commit is contained in:
chattie
2026-08-21 12:02:15 +02:00
parent e46faa869a
commit 027cacd871
31 changed files with 510 additions and 80 deletions

View File

@@ -29,6 +29,8 @@ const methodBodySchema = z.object({
baseCostCents: z.number().int().min(0),
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
description: z.string().max(500).optional().nullable(),
maxWeightKg: z.number().positive().max(100000).optional().nullable(),
freeShippingMaxWeightKg: z.number().positive().max(100000).optional().nullable(),
active: z.boolean().optional(),
});
@@ -37,6 +39,7 @@ const calculateBodySchema = z
cartTotalCents: z.number().int().min(0),
country: z.string().min(2).max(80),
postalCode: z.string().min(1).max(20),
cartWeightKg: z.number().min(0).max(100000).optional(),
})
.strip();
@@ -75,14 +78,16 @@ export async function registerShippingRoutes(
requireRole(user, 'admin');
const input = parseJson(methodBodySchema, request.body);
const result = await deps.pool.query<{ id: string }>(
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, description, active)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, description, max_weight_kg, free_shipping_max_weight_kg, active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
[
input.zoneId,
input.name,
input.baseCostCents,
input.freeShippingThresholdCents ?? null,
input.description ?? null,
input.maxWeightKg ?? null,
input.freeShippingMaxWeightKg ?? null,
input.active ?? true,
],
);
@@ -202,6 +207,8 @@ export async function registerShippingRoutes(
base_cost_cents: number;
free_shipping_threshold_cents: number | null;
description: string | null;
max_weight_kg: string | number | null;
free_shipping_max_weight_kg: string | number | null;
active: boolean;
}>(
`SELECT sm.*, sz.name as zone_name FROM shipping_methods sm
@@ -217,6 +224,9 @@ export async function registerShippingRoutes(
baseCostCents: r.base_cost_cents,
freeShippingThresholdCents: r.free_shipping_threshold_cents,
description: r.description,
maxWeightKg: r.max_weight_kg === null ? null : Number(r.max_weight_kg),
freeShippingMaxWeightKg:
r.free_shipping_max_weight_kg === null ? null : Number(r.free_shipping_max_weight_kg),
active: r.active,
})),
});
@@ -227,13 +237,15 @@ export async function registerShippingRoutes(
requireRole(user, 'admin');
const input = parseJson(methodBodySchema, request.body);
const result = await deps.pool.query<{ id: string }>(
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, active)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, max_weight_kg, free_shipping_max_weight_kg, active)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[
input.zoneId,
input.name,
input.baseCostCents,
input.freeShippingThresholdCents ?? null,
input.maxWeightKg ?? null,
input.freeShippingMaxWeightKg ?? null,
input.active ?? true,
],
);
@@ -264,6 +276,8 @@ export async function registerShippingRoutes(
baseCostCents: z.number().int().min(0).optional(),
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
description: z.string().max(500).optional().nullable(),
maxWeightKg: z.number().positive().max(100000).optional().nullable(),
freeShippingMaxWeightKg: z.number().positive().max(100000).optional().nullable(),
active: z.boolean().optional(),
}),
request.body,
@@ -287,6 +301,14 @@ export async function registerShippingRoutes(
sets.push(`description = $${i++}`);
values.push(patch.description);
}
if (patch.maxWeightKg !== undefined) {
sets.push(`max_weight_kg = $${i++}`);
values.push(patch.maxWeightKg);
}
if (patch.freeShippingMaxWeightKg !== undefined) {
sets.push(`free_shipping_max_weight_kg = $${i++}`);
values.push(patch.freeShippingMaxWeightKg);
}
if (patch.active !== undefined) {
sets.push(`active = $${i++}`);
values.push(patch.active);
@@ -342,10 +364,14 @@ export async function registerShippingRoutes(
app.post('/shipping/calculate', { schema: calcShippingSchema }, async (request, reply) => {
const input = parseJson(calculateBodySchema, request.body);
try {
const quote = await service.calculate(input.cartTotalCents, {
country: input.country,
postalCode: input.postalCode,
});
const quote = await service.calculate(
input.cartTotalCents,
{
country: input.country,
postalCode: input.postalCode,
},
input.cartWeightKg ?? 0,
);
return reply.send(serializeQuote(quote));
} catch (error) {
throw mapShippingError(error);
@@ -375,9 +401,12 @@ export async function registerShippingRoutes(
base_cost_cents: number;
free_shipping_threshold_cents: number | null;
description: string | null;
max_weight_kg: string | number | null;
free_shipping_max_weight_kg: string | number | null;
}>(
`SELECT sm.id, sm.zone_id, sm.name, sm.base_cost_cents,
sm.free_shipping_threshold_cents, sm.description
sm.free_shipping_threshold_cents, sm.description,
sm.max_weight_kg, sm.free_shipping_max_weight_kg
FROM shipping_methods sm
JOIN shipping_zones sz ON sz.id = sm.zone_id
WHERE sm.active = true
@@ -395,6 +424,9 @@ export async function registerShippingRoutes(
baseCostCents: r.base_cost_cents,
freeShippingThresholdCents: r.free_shipping_threshold_cents,
description: r.description,
maxWeightKg: r.max_weight_kg === null ? null : Number(r.max_weight_kg),
freeShippingMaxWeightKg:
r.free_shipping_max_weight_kg === null ? null : Number(r.free_shipping_max_weight_kg),
})),
});
});

View File

@@ -8,7 +8,11 @@ import type { ShippingAddress, ShippingQuote } from '../domain/shipping.js';
export class ShippingService implements ShippingServicePort {
constructor(private readonly repo: ShippingRepository) {}
async calculate(cartTotalCents: number, address: ShippingAddress): Promise<ShippingQuote> {
async calculate(
cartTotalCents: number,
address: ShippingAddress,
cartWeightKg = 0,
): Promise<ShippingQuote> {
ensureValidAddress(address);
ensureNonNegativeTotal(cartTotalCents);
@@ -17,10 +21,19 @@ export class ShippingService implements ShippingServicePort {
const candidates = match.methods
.filter((method) => method.active)
// Max weight per method: carts above the limit cannot use the method.
.filter(
(method) => method.maxWeightKg === null || cartWeightKg <= method.maxWeightKg,
)
.map((method) => {
// Free shipping applies by total and only within the free-shipping
// weight range configured on the method.
const withinFreeWeightRange =
method.freeShippingMaxWeightKg === null || cartWeightKg <= method.freeShippingMaxWeightKg;
const freeApplied =
method.freeShippingThresholdCents !== null &&
cartTotalCents >= method.freeShippingThresholdCents;
cartTotalCents >= method.freeShippingThresholdCents &&
withinFreeWeightRange;
return { method, freeApplied, costCents: freeApplied ? 0 : method.baseCostCents };
});
if (candidates.length === 0) throw new ShippingZoneNotFoundError();

View File

@@ -1,7 +1,11 @@
import type { ShippingAddress, ShippingQuote } from './shipping.js';
export interface ShippingService {
calculate(cartTotalCents: number, address: ShippingAddress): Promise<ShippingQuote>;
calculate(
cartTotalCents: number,
address: ShippingAddress,
cartWeightKg?: number,
): Promise<ShippingQuote>;
}
export interface ShippingRepository {
@@ -16,6 +20,8 @@ export interface ShippingRepository {
name: string;
baseCostCents: number;
freeShippingThresholdCents: number | null;
maxWeightKg: number | null;
freeShippingMaxWeightKg: number | null;
active: boolean;
}>;
}

View File

@@ -17,6 +17,10 @@ export interface ShippingMethod {
name: string;
baseCostCents: number;
freeShippingThresholdCents: number | null;
/** Peso máximo (kg) que admite el método; null = sin límite. */
maxWeightKg: number | null;
/** Peso máximo (kg) para aplicar envío gratuito; null = sin límite. */
freeShippingMaxWeightKg: number | null;
active: boolean;
}

View File

@@ -10,6 +10,8 @@ interface MethodRow {
name: string;
base_cost_cents: number;
free_shipping_threshold_cents: number | null;
max_weight_kg: string | number | null;
free_shipping_max_weight_kg: string | number | null;
active: boolean;
}
@@ -20,6 +22,8 @@ interface ZoneMatch {
name: string;
baseCostCents: number;
freeShippingThresholdCents: number | null;
maxWeightKg: number | null;
freeShippingMaxWeightKg: number | null;
active: boolean;
}>;
}
@@ -40,7 +44,8 @@ export class PgShippingRepository implements ShippingRepository {
if (!best) return undefined;
const methods = await this.pool.query<MethodRow>(
`SELECT id, name, base_cost_cents, free_shipping_threshold_cents, active
`SELECT id, name, base_cost_cents, free_shipping_threshold_cents,
max_weight_kg, free_shipping_max_weight_kg, active
FROM shipping_methods WHERE zone_id = $1`,
[best.id],
);
@@ -51,6 +56,9 @@ export class PgShippingRepository implements ShippingRepository {
name: row.name,
baseCostCents: row.base_cost_cents,
freeShippingThresholdCents: row.free_shipping_threshold_cents,
maxWeightKg: row.max_weight_kg === null ? null : Number(row.max_weight_kg),
freeShippingMaxWeightKg:
row.free_shipping_max_weight_kg === null ? null : Number(row.free_shipping_max_weight_kg),
active: row.active,
})),
};

View File

@@ -11,10 +11,22 @@ function repo(
baseCostCents: number;
freeShippingThresholdCents: number | null;
active: boolean;
maxWeightKg?: number | null;
freeShippingMaxWeightKg?: number | null;
}>,
): ShippingRepository {
return {
findMatchingZone: async () => (zoneId ? { zoneId, methods } : undefined),
findMatchingZone: async () =>
zoneId
? {
zoneId,
methods: methods.map((method) => ({
maxWeightKg: null,
freeShippingMaxWeightKg: null,
...method,
})),
}
: undefined,
};
}
@@ -79,4 +91,48 @@ describe('ShippingService', () => {
service.calculate(-1, { country: 'ES', postalCode: '28001' }),
).rejects.toBeInstanceOf(InvalidShippingAddressError);
});
it('excludes methods whose max weight is below the cart weight (F-102)', async () => {
const service = new ShippingService(
repo('zone-1', [
{
id: 'm-light',
name: 'Ligero',
baseCostCents: 300,
freeShippingThresholdCents: null,
active: true,
maxWeightKg: 2,
},
{
id: 'm-heavy',
name: 'Pesado',
baseCostCents: 900,
freeShippingThresholdCents: null,
active: true,
maxWeightKg: 30,
},
]),
);
const quote = await service.calculate(1000, { country: 'ES', postalCode: '28001' }, 5);
expect(quote).toMatchObject({ methodId: 'm-heavy', costCents: 900 });
});
it('denies free shipping when cart weight exceeds the free-shipping range (F-102)', async () => {
const service = new ShippingService(
repo('zone-1', [
{
id: 'm-1',
name: 'Standard',
baseCostCents: 500,
freeShippingThresholdCents: 3000,
active: true,
freeShippingMaxWeightKg: 4,
},
]),
);
const free = await service.calculate(5000, { country: 'ES', postalCode: '28001' }, 3);
expect(free).toMatchObject({ costCents: 0, freeApplied: true });
const paid = await service.calculate(5000, { country: 'ES', postalCode: '28001' }, 6);
expect(paid).toMatchObject({ costCents: 500, freeApplied: false });
});
});