From 7c9916f18c5909635fabb4e43f9b1ea861caba14 Mon Sep 17 00:00:00 2001 From: Joe Furfaro Date: Mon, 3 Feb 2025 22:29:39 -0800 Subject: [PATCH] added final support for coupon promos --- lib/routes/promos.ts | 27 +++++++++--------- lib/services/orders.ts | 24 +++++++++++++--- lib/services/promos.ts | 63 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 85 insertions(+), 29 deletions(-) diff --git a/lib/routes/promos.ts b/lib/routes/promos.ts index bbaacd5..dba61a0 100644 --- a/lib/routes/promos.ts +++ b/lib/routes/promos.ts @@ -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) { diff --git a/lib/services/orders.ts b/lib/services/orders.ts index 8694005..237a0ba 100644 --- a/lib/services/orders.ts +++ b/lib/services/orders.ts @@ -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; @@ -182,12 +183,13 @@ 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` SELECT * FROM promos WHERE id = ${promoId} + AND status = 'active' `).map(p => ({ ...p, ...(typeof p.price === 'string' ? {price: Number(p.price)} : {}), @@ -195,7 +197,14 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} ...(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; @@ -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; } } diff --git a/lib/services/promos.ts b/lib/services/promos.ts index b6c05ff..62c6104 100644 --- a/lib/services/promos.ts +++ b/lib/services/promos.ts @@ -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; @@ -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; +}; + const promoColumns = [ 'id', 'created', @@ -35,14 +55,27 @@ 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; + createdBy: string; +}; +type PromoInsert = Omit; +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'); @@ -50,7 +83,7 @@ export async function createPromo({ price, type, productId, productQuantity = 1, if(typeof price !== 'number') throw new PromoServiceError('Price must be a number', 'INVALID'); } - const promo = { + const promo: PromoInsert = { id: uuidV4(), status: 'active', createdBy, @@ -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` INSERT INTO promos ${sql(promo)} RETURNING ${sql(promoColumns)} `).map(convertPriceAndDiscountsToNumbers); @@ -79,11 +118,11 @@ 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` SELECT ${sql(promoColumns.map(c => `p.${c}`))} FROM promos as p JOIN products as pr @@ -91,7 +130,7 @@ export async function getPromos({ eventId } = {}) { WHERE pr.event_id = ${eventId} `; } else { - promos = await sql` + promos = await sql` SELECT ${sql(promoColumns)} FROM promos `; @@ -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` SELECT ${sql(promoColumns)} FROM promos WHERE id = ${id} @@ -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) { for(const u in updates) { // Update whitelist if(![ @@ -136,7 +175,7 @@ export async function updatePromo(id, updates) { let promo; try { - [promo] = (await sql` + [promo] = (await sql` UPDATE promos SET ${sql(updates)}, updated = now() WHERE id = ${id}