Skip to content

Commit 719de82

Browse files
authoredMar 7, 2024
Merge pull request #81 from mustachebash/feature/vip-promo-upgrade
VIP promo upgrades
2 parents fba2e44 + a8beae4 commit 719de82

File tree

4 files changed

+232
-17
lines changed

4 files changed

+232
-17
lines changed
 

‎lib/routes/orders.ts

+17-12
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Router from '@koa/router';
22
import { authorizeUser, requiresPermission } from '../middleware/auth.js';
33
import { getOrderTickets, transferTickets } from '../services/tickets.js';
44
import { createOrder, getOrders, getOrder, getOrderTransfers, refundOrder, generateOrderToken } from '../services/orders.js';
5-
import { sendReceipt, upsertEmailSubscriber, sendTransfereeConfirmation } from '../services/email.js';
5+
import { sendReceipt, upsertEmailSubscriber, sendTransfereeConfirmation, sendUpgradeReceipt } from '../services/email.js';
66
import { getTransactions } from '../services/transactions.js';
77

88
// TODO: make this configurable at some point
@@ -31,19 +31,24 @@ ordersRouter
3131
{ email, firstName, lastName } = customer;
3232

3333
let orderToken;
34-
try {
35-
orderToken = await generateOrderToken(order.id);
36-
} catch(e) {
37-
ctx.state.log.error(e, 'Error creating order token');
34+
// Quick n dirty logic to check if an upgrade was purchased instead of a ticket
35+
if(ctx.request.body.targetGuestId) {
36+
sendUpgradeReceipt(firstName, lastName, email, transaction.processorTransactionId, order.id, order.amount);
37+
} else {
38+
try {
39+
orderToken = await generateOrderToken(order.id);
40+
} catch(e) {
41+
ctx.state.log.error(e, 'Error creating order token');
42+
}
43+
44+
// Send a receipt email
45+
sendReceipt(firstName, lastName, email, transaction.processorTransactionId, order.id, orderToken, order.amount);
46+
// Add them to the mailing list and tag as an attendee
47+
const emailTags = [EMAIL_TAG];
48+
if(ctx.request.body.customer.marketingOptIn) emailTags.push('Partner Marketing');
49+
upsertEmailSubscriber(EMAIL_LIST, {email, firstName, lastName, tags: emailTags});
3850
}
3951

40-
// Send a receipt email
41-
sendReceipt(firstName, lastName, email, transaction.processorTransactionId, order.id, orderToken, order.amount);
42-
// Add them to the mailing list and tag as an attendee
43-
const emailTags = [EMAIL_TAG];
44-
if(ctx.request.body.customer.marketingOptIn) emailTags.push('Partner Marketing');
45-
upsertEmailSubscriber(EMAIL_LIST, {email, firstName, lastName, tags: emailTags});
46-
4752
ctx.set('Location', `https://${ctx.host}${ctx.path}/${order.id}`);
4853
ctx.status = 201;
4954
return ctx.body = {confirmationId: transaction.processorTransactionId, orderId: order.id, token: orderToken};

‎lib/services/email.ts

+154
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,160 @@ table[class=body] .article {
206206
.catch(err => log.error({err, guestEmail, confirmation}, 'Receipt email failed to send'));
207207
}
208208

209+
export function sendUpgradeReceipt(customerFirstName: string, customerLastName: string, customerEmail: string, confirmation: string, orderId: string, amount: number) {
210+
mailgun.messages().send({
211+
from: 'Mustache Bash Tickets <contact@mustachebash.com>',
212+
to: customerFirstName + ' ' + customerLastName + ' <' + customerEmail + '> ',
213+
subject: 'Your VIP Upgrade Confirmation For San Diego Mustache Bash 2024',
214+
html: `
215+
<!doctype html>
216+
<html>
217+
<head>
218+
<meta name="viewport" content="width=device-width">
219+
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
220+
<title>The Mustache Bash SD 2024 Confirmation</title>
221+
<style>
222+
@media only screen and (max-width: 620px) {
223+
table[class=body] h1 {
224+
font-size: 28px !important;
225+
margin-bottom: 10px !important;
226+
}
227+
228+
table[class=body] p,
229+
table[class=body] ul,
230+
table[class=body] ol,
231+
table[class=body] td,
232+
table[class=body] span,
233+
table[class=body] a {
234+
font-size: 16px !important;
235+
}
236+
237+
table[class=body] .wrapper,
238+
table[class=body] .article {
239+
padding: 10px !important;
240+
}
241+
242+
table[class=body] .content {
243+
padding: 0 !important;
244+
}
245+
246+
table[class=body] .container {
247+
padding: 0 !important;
248+
width: 100% !important;
249+
}
250+
251+
table[class=body] .main {
252+
border-left-width: 0 !important;
253+
border-radius: 0 !important;
254+
border-right-width: 0 !important;
255+
}
256+
257+
table[class=body] .btn table {
258+
width: 100% !important;
259+
}
260+
261+
table[class=body] .btn a {
262+
width: 100% !important;
263+
}
264+
265+
table[class=body] .img-responsive {
266+
height: auto !important;
267+
max-width: 100% !important;
268+
width: auto !important;
269+
}
270+
}
271+
@media all {
272+
.ExternalClass {
273+
width: 100%;
274+
}
275+
276+
.ExternalClass,
277+
.ExternalClass p,
278+
.ExternalClass span,
279+
.ExternalClass font,
280+
.ExternalClass td,
281+
.ExternalClass div {
282+
line-height: 100%;
283+
}
284+
}
285+
</style>
286+
</head>
287+
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
288+
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f6f6f6; width: 100%;" width="100%" bgcolor="#f6f6f6">
289+
<tr>
290+
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">&nbsp;</td>
291+
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; max-width: 580px; padding: 10px; width: 580px; margin: 0 auto;" width="580" valign="top">
292+
<div class="content" style="box-sizing: border-box; display: block; margin: 0 auto; max-width: 580px; padding: 10px;">
293+
294+
<!-- START CENTERED WHITE CONTAINER -->
295+
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">The Mustache Bash San Diego - Tickets and Confirmation #${confirmation}. Thanks for ordering a Bash Pass!</span>
296+
<table role="presentation" class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;" width="100%">
297+
298+
<!-- START MAIN CONTENT AREA -->
299+
<tr>
300+
<td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;" valign="top">
301+
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" width="100%">
302+
<tr>
303+
<td class="align-center" style="font-family: sans-serif; font-size: 14px; vertical-align: top; text-align: center;" valign="top" align="center">
304+
<img src="https://static.mustachebash.com/img/fro-man.png" alt="The Mustache Bash" style="border: none; -ms-interpolation-mode: bicubic; max-width: 100%;">
305+
</td>
306+
</tr>
307+
<tr>
308+
<td class="align-center" style="font-family: sans-serif; font-size: 14px; vertical-align: top; text-align: center;" valign="top" align="center">
309+
&nbsp;
310+
</td>
311+
</tr>
312+
<tr>
313+
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">
314+
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">Hi ${customerFirstName}! Thanks for upgrading your Bash Pass. See details below!</p>
315+
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
316+
<strong>Confirmation Number:</strong> ${confirmation}<br>
317+
<strong>Order Number:</strong> ${orderId.slice(0, 8)}<br>
318+
<strong>Total:</strong> $${amount}
319+
</p>
320+
321+
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">We're thrilled you're coming to the 2024 SD Mustache Bash in VIP style! Use the ticket link in your original order to access your newly upgraded tickets.</p>
322+
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">Check out our <a href="https://mustachebash.com/info?utm_source=confirmation-email">FAQ page</a> for more info, and reply here or email us at <a href="mailto:contact@mustachebash.com">contact@mustachebash.com</a> if you have questions about your purchase.</p>
323+
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">Thanks again, we can’t wait to boogie with you.</p>
324+
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
325+
Stay Funky,<br>
326+
Team Mustache Bash
327+
</p>
328+
</td>
329+
</tr>
330+
</table>
331+
</td>
332+
</tr>
333+
334+
<!-- END MAIN CONTENT AREA -->
335+
</table>
336+
337+
<!-- START FOOTER -->
338+
<div class="footer" style="clear: both; margin-top: 10px; text-align: center; width: 100%;">
339+
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" width="100%">
340+
<tr>
341+
<td class="content-block" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;" valign="top" align="center">
342+
<span style="color: #999999; font-size: 12px; text-align: center;"><a href="https://mustachebash.com">The Mustache Bash</a></span>
343+
</td>
344+
</tr>
345+
</table>
346+
</div>
347+
<!-- END FOOTER -->
348+
349+
<!-- END CENTERED WHITE CONTAINER -->
350+
</div>
351+
</td>
352+
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">&nbsp;</td>
353+
</tr>
354+
</table>
355+
</body>
356+
</html>
357+
`
358+
})
359+
.then(mailgunResponse => log.info({mailgunResponse, customerEmail, confirmation}, 'Receipt email sent'))
360+
.catch(err => log.error({err, customerEmail, confirmation}, 'Receipt email failed to send'));
361+
}
362+
209363
export function sendTransfereeConfirmation(transfereeFirstName, transfereeLastName, transfereeEmail, parentOrderId, orderToken) {
210364
mailgun.messages().send({
211365
from: 'Mustache Bash Tickets <contact@mustachebash.com>',

‎lib/services/orders.ts

+60-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import jwt from 'jsonwebtoken';
77
import { v4 as uuidV4 } from 'uuid';
88
import log from '../utils/log.js';
99
import { sql } from '../utils/db.js';
10-
import { createGuest } from '../services/guests.js';
10+
import { createGuest, updateGuest } from '../services/guests.js';
11+
import type { Product } from '../services/products.js';
1112
import { braintree as btConfig, jwt as jwtConfig } from '../config.js';
1213

1314
const { orderSecret } = jwtConfig;
@@ -20,7 +21,10 @@ const gateway = new braintree.BraintreeGateway({
2021
});
2122

2223
class OrdersServiceError extends Error {
23-
constructor(message = 'An unknown error occured', code = 'UNKNOWN', context) {
24+
code: string;
25+
context: unknown;
26+
27+
constructor(message = 'An unknown error occured', code = 'UNKNOWN', context?: unknown) {
2428
super(message);
2529

2630
this.name = this.constructor.name;
@@ -130,11 +134,29 @@ export async function getOrders({ eventId, productId, status, limit, orderBy = '
130134
}
131135
}
132136

133-
export async function createOrder({ paymentMethodNonce, cart = [], customer = {}, promoId }) {
137+
type OrderInput = {
138+
paymentMethodNonce: string;
139+
140+
cart: {
141+
productId: string;
142+
quantity: number;
143+
}[];
144+
145+
customer: {
146+
firstName: string;
147+
lastName: string;
148+
email: string;
149+
};
150+
151+
promoId?: string;
152+
153+
targetGuestId?: string;
154+
};
155+
export async function createOrder({ paymentMethodNonce, cart = [], customer = {}, promoId, targetGuestId }: OrderInput) {
134156
// First do some validation
135157
if (!cart.length || !paymentMethodNonce || !customer.firstName || !customer.lastName || !customer.email) throw new OrdersServiceError('Invalid payment parameters', 'INVALID');
136158

137-
const products = (await sql`
159+
const products = (await sql<(Product & {totalSold: string})[]>`
138160
SELECT p.*, COALESCE(SUM(oi.quantity), 0) as total_sold
139161
FROM products as p
140162
LEFT JOIN order_items as oi
@@ -164,6 +186,19 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {}
164186
if(!promo || promo.status !== 'active') throw new OrdersServiceError('Invalid promo code', 'INVALID');
165187
}
166188

189+
let targetGuest;
190+
if(targetGuestId) {
191+
if(products.length > 1 || products[0].type !== 'upgrade') throw new OrdersServiceError('Missing/incorrect Upgrade Product', 'INVALID');
192+
193+
[targetGuest] = await sql`
194+
SELECT *
195+
FROM guests
196+
WHERE id = ${targetGuestId}
197+
`;
198+
199+
if(!targetGuest || targetGuest.status !== 'active') throw new OrdersServiceError('Invalid target guest', 'INVALID');
200+
}
201+
167202
// Ensure we have the all the products attempting to be purchased
168203
// For now, use a slug of `GONE` since this should only occur when a product has become inactive since page load
169204
// if(!products.length || products.length !== cart.length) throw new OrdersServiceError('Empty/Invalid items in cart', 'INVALID');
@@ -182,6 +217,15 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {}
182217
promo.productQuantity < i.quantity
183218
) throw new OrdersServiceError('Promo quantity exceeded', 'INVALID');
184219

220+
// Special upgrade quantity check
221+
if (
222+
targetGuest &&
223+
(
224+
targetGuest.admissionTier === product.admissionTier ||
225+
i.quantity > 1
226+
)
227+
) throw new OrdersServiceError('Upgrade quantity exceeded', 'INVALID');
228+
185229
if(remaining !== null) {
186230
// So long as the product was active at start, don't worry if this individual order goes over the max
187231
// This minimizes the amount of customers who are buying and get failed orders
@@ -383,6 +427,18 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {}
383427
}
384428
})();
385429
}
430+
} else if(i.product.type === 'upgrade') {
431+
for (let j = 0; j < i.quantity; j++) {
432+
(async () => {
433+
try {
434+
await updateGuest(targetGuest.id, {
435+
admissionTier: i.product.admissionTier
436+
});
437+
} catch(e) {
438+
log.error(e, 'Error creating guest');
439+
}
440+
})();
441+
}
386442
}
387443
});
388444

‎lib/services/products.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const convertPriceToNumber = (p: Record<string, unknown>) => ({...p, ...(typeof
4343

4444
type ProductType = 'ticket' | 'upgrade' | 'accomodation';
4545
type AdmissionTier = 'general' | 'vip' | 'sponsor' | 'stachepass';
46-
type Product = {
46+
export type Product = {
4747
id: string;
4848
price: number;
4949
name: string;

0 commit comments

Comments
 (0)
Please sign in to comment.