Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions lib/routes/promos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,23 @@ promosRouter
try {
const promo = await getPromo(ctx.params.id);

if(!promo) throw ctx.throw(404);
// If the promo has been used, return 410 GONE
if(promo.status !== 'active') throw ctx.throw(410);
if(promo.type === 'single-use') {
if(!promo) throw ctx.throw(404);
// If the promo has been used, return 410 GONE
if(promo.status !== 'active') throw ctx.throw(410);

const product = await getProduct(promo.productId);
delete promo.productId;
const product = await getProduct(promo.productId);

// if the product is no longer available, return 410 GONE
if(product.status !== 'active') throw ctx.throw(410);
// if the product is no longer available, return 410 GONE
if(product.status !== 'active') throw ctx.throw(410);

promo.product = {
id: product.id,
price: product.price,
description: product.description,
name: product.name
};
promo.product = {
id: product.id,
price: product.price,
description: product.description,
name: product.name
};
}

return ctx.body = promo;
} catch(e) {
Expand Down
24 changes: 20 additions & 4 deletions lib/services/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import log from '../utils/log.js';
import { sql } from '../utils/db.js';
import { createGuest, updateGuest } from '../services/guests.js';
import type { Product } from '../services/products.js';
import type { Promo } from '../services/promos.js';
import { braintree as btConfig, jwt as jwtConfig } from '../config.js';

const { orderSecret } = jwtConfig;
Expand Down Expand Up @@ -182,20 +183,28 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {}
...(typeof p.price === 'string' ? {price: Number(p.price)} : {})
}));

let promo;
let promo: Promo, promoUses: number;
if(promoId) {
[promo] = (await sql`
[promo] = (await sql<Promo[]>`
SELECT *
FROM promos
WHERE id = ${promoId}
AND status = 'active'
`).map(p => ({
...p,
...(typeof p.price === 'string' ? {price: Number(p.price)} : {}),
...(typeof p.percentDiscount === 'string' ? {percentDiscount: Number(p.percentDiscount)} : {}),
...(typeof p.flatDiscount === 'string' ? {flatDiscount: Number(p.flatDiscount)} : {})
}));

if(!promo || promo.status !== 'active') throw new OrdersServiceError('Invalid promo code', 'INVALID');
[promoUses] = (await sql`
SELECT count(id) as promoUses
FROM orders
WHERE promo_id = ${promoId}
`).map(pu => (pu.promoUses));

if(!promo) throw new OrdersServiceError('Invalid promo code', 'INVALID');
if(typeof promo.maxUses === 'number' && promo.maxUses <= promoUses) throw new OrdersServiceError('Promo code no longer available', 'GONE');
}

let targetGuest;
Expand Down Expand Up @@ -301,11 +310,18 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {}
itemPrice = (promo.price);
}

// If there's a percent discount
if(promo.flatDiscount && promo.type === 'coupon') {
itemPrice = i.product.price - promo.flatDiscount;
// Round to 2 decimal places
itemPrice = Math.round(itemPrice * 100) / 100;
}

// If there's a percent discount
if(promo.percentDiscount && promo.type === 'coupon') {
itemPrice = i.product.price - (i.product.price * (promo.percentDiscount / 100));
// Round to 2 decimal places
itemPrice = Math.round(amount * 100) / 100;
itemPrice = Math.round(itemPrice * 100) / 100;
}
}

Expand Down
63 changes: 51 additions & 12 deletions lib/services/promos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { sql } from '../utils/db.js';
import { v4 as uuidV4 } from 'uuid';

class PromoServiceError extends Error {
constructor(message = 'An unknown error occured', code = 'UNKNOWN', context) {
code: string;
context: unknown;

constructor(message = 'An unknown error occured', code = 'UNKNOWN', context?: unknown) {
super(message);

this.name = this.constructor.name;
Expand All @@ -18,6 +21,23 @@ class PromoServiceError extends Error {
}
}

type PromoType = 'single-use' | 'coupon';
export type Promo = {
id: string;
created: Date;
createdBy: string;
price?: number;
percentDiscount?: number;
flatDiscount?: number;
productId: string;
productQuantity?: number;
recipientName?: string;
maxUses?: number;
status: string;
type: PromoType;
meta: Record<string, unknown>;
};

const promoColumns = [
'id',
'created',
Expand All @@ -35,22 +55,35 @@ const promoColumns = [
'meta'
];

const convertPriceAndDiscountsToNumbers = p => ({
const convertPriceAndDiscountsToNumbers = (p: Promo): Promo => ({
...p,
...(typeof p.price === 'string' ? {price: Number(p.price)} : {}),
...(typeof p.percentDiscount === 'string' ? {percentDiscount: Number(p.percentDiscount)} : {}),
...(typeof p.flatDiscount === 'string' ? {flatDiscount: Number(p.flatDiscount)} : {})
});

export async function createPromo({ price, type, productId, productQuantity = 1, recipientName, meta, createdBy }) {
type PromoInput = {
price?: number;
flatDiscount?: number;
percentDiscount?: number;
type: PromoType;
productId: string;
productQuantity?: number;
maxUses?: number;
recipientName?: string;
meta: Record<string, unknown>;
createdBy: string;
};
type PromoInsert = Omit<Promo, 'created' | 'updated'>;
export async function createPromo({ price, flatDiscount, percentDiscount, maxUses, type, productId, productQuantity = 1, recipientName, meta, createdBy }: PromoInput) {
if(!productId || !type) throw new PromoServiceError('Missing promo data', 'INVALID');
if(type === 'single-use') {
if(typeof productQuantity !== 'number' || productQuantity < 1 || productQuantity > 5) throw new PromoServiceError('Invalid product quantity for single-use promo', 'INVALID');
if(!price || !recipientName) throw new PromoServiceError('Single use promos require price and recipient', 'INVALID');
if(typeof price !== 'number') throw new PromoServiceError('Price must be a number', 'INVALID');
}

const promo = {
const promo: PromoInsert = {
id: uuidV4(),
status: 'active',
createdBy,
Expand All @@ -67,8 +100,14 @@ export async function createPromo({ price, type, productId, productQuantity = 1,
promo.productQuantity = productQuantity;
}

if(type === 'coupon') {
if(percentDiscount) promo.percentDiscount = percentDiscount;
if(flatDiscount) promo.flatDiscount = flatDiscount;
promo.maxUses = maxUses;
}

try {
const [createdPromo] = (await sql`
const [createdPromo] = (await sql<Promo[]>`
INSERT INTO promos ${sql(promo)}
RETURNING ${sql(promoColumns)}
`).map(convertPriceAndDiscountsToNumbers);
Expand All @@ -79,19 +118,19 @@ export async function createPromo({ price, type, productId, productQuantity = 1,
}
}

export async function getPromos({ eventId } = {}) {
export async function getPromos({ eventId }: {eventId?: string;} = {}) {
try {
let promos;
if(eventId) {
promos = await sql`
promos = await sql<Promo[]>`
SELECT ${sql(promoColumns.map(c => `p.${c}`))}
FROM promos as p
JOIN products as pr
ON pr.id = p.product_id
WHERE pr.event_id = ${eventId}
`;
} else {
promos = await sql`
promos = await sql<Promo[]>`
SELECT ${sql(promoColumns)}
FROM promos
`;
Expand All @@ -103,10 +142,10 @@ export async function getPromos({ eventId } = {}) {
}
}

export async function getPromo(id) {
export async function getPromo(id: string) {
let promo;
try {
[promo] = (await sql`
[promo] = (await sql<Promo[]>`
SELECT ${sql(promoColumns)}
FROM promos
WHERE id = ${id}
Expand All @@ -120,7 +159,7 @@ export async function getPromo(id) {
return promo;
}

export async function updatePromo(id, updates) {
export async function updatePromo(id: string, updates: Record<string, unknown>) {
for(const u in updates) {
// Update whitelist
if(![
Expand All @@ -136,7 +175,7 @@ export async function updatePromo(id, updates) {

let promo;
try {
[promo] = (await sql`
[promo] = (await sql<Promo[]>`
UPDATE promos
SET ${sql(updates)}, updated = now()
WHERE id = ${id}
Expand Down
Loading