|
| 1 | +import { APIError, type APIHandler } from './helpers/endpoint' |
| 2 | +import { runTxnOutsideBetQueue, type TxnData } from 'shared/txn/run-txn' |
| 3 | +import { runTransactionWithRetries } from 'shared/transact-with-retries' |
| 4 | +import { getUser } from 'shared/utils' |
| 5 | +import { getShopItem, getTicketItems, isTicketItem } from 'common/shop/items' |
| 6 | +import { betsQueue } from 'shared/helpers/fn-queue' |
| 7 | +import { getActiveSupporterEntitlements } from 'shared/supabase/entitlements' |
| 8 | +import { getBenefit } from 'common/supporter-config' |
| 9 | + |
| 10 | +export const shopPurchaseTicket: APIHandler<'shop-purchase-ticket'> = async ( |
| 11 | + { itemId }, |
| 12 | + auth |
| 13 | +) => { |
| 14 | + const item = getShopItem(itemId) |
| 15 | + if (!item) throw new APIError(404, 'Item not found') |
| 16 | + if (!isTicketItem(item)) throw new APIError(400, 'Item is not a ticket') |
| 17 | + if (item.comingSoon) throw new APIError(403, 'This ticket is not yet available') |
| 18 | + if (!auth) throw new APIError(401, 'Must be logged in') |
| 19 | + |
| 20 | + // Serialize ALL ticket purchases for this item globally (not per-user) so two |
| 21 | + // different users racing for the last slot can't both pass the stock check. |
| 22 | + const { orderId, remainingStock } = await betsQueue.enqueueFn( |
| 23 | + () => |
| 24 | + runTransactionWithRetries(async (tx) => { |
| 25 | + const user = await getUser(auth.uid, tx) |
| 26 | + if (!user) throw new APIError(401, 'Your account was not found') |
| 27 | + if (user.isBannedFromPosting) |
| 28 | + throw new APIError(403, 'Your account is banned') |
| 29 | + |
| 30 | + // One-per-user check across ALL ticket variants — buying early-bird |
| 31 | + // blocks standard (and vice versa). Unique partial index handles the |
| 32 | + // same-item case; this handles cross-variant. |
| 33 | + const allTicketIds = getTicketItems().map((t) => t.id) |
| 34 | + const existing = await tx.oneOrNone( |
| 35 | + `SELECT item_id FROM shop_orders |
| 36 | + WHERE user_id = $1 AND item_id = ANY($2::text[]) |
| 37 | + AND status NOT IN ('FAILED', 'REFUNDED', 'CANCELLED') |
| 38 | + LIMIT 1`, |
| 39 | + [auth.uid, allTicketIds] |
| 40 | + ) |
| 41 | + if (existing) { |
| 42 | + throw new APIError( |
| 43 | + 403, |
| 44 | + 'You have already purchased a Manifest ticket (limit 1 per person)' |
| 45 | + ) |
| 46 | + } |
| 47 | + |
| 48 | + // Global stock check. Safe without FOR UPDATE because betsQueue serializes |
| 49 | + // all ticket purchases for this itemId, and runTransactionWithRetries uses |
| 50 | + // serializable isolation as a backstop. |
| 51 | + if (item.maxStock) { |
| 52 | + const { count } = await tx.one<{ count: number }>( |
| 53 | + `SELECT count(*)::int AS count FROM shop_orders |
| 54 | + WHERE item_id = $1 |
| 55 | + AND status NOT IN ('FAILED', 'REFUNDED', 'CANCELLED')`, |
| 56 | + [itemId] |
| 57 | + ) |
| 58 | + if (count >= item.maxStock) { |
| 59 | + throw new APIError(403, 'Sold out — all tickets have been claimed') |
| 60 | + } |
| 61 | + } |
| 62 | + |
| 63 | + const supporterEntitlements = await getActiveSupporterEntitlements( |
| 64 | + tx, |
| 65 | + auth.uid |
| 66 | + ) |
| 67 | + const shopDiscount = getBenefit( |
| 68 | + supporterEntitlements, |
| 69 | + 'shopDiscount', |
| 70 | + 0 |
| 71 | + ) |
| 72 | + const price = |
| 73 | + shopDiscount > 0 |
| 74 | + ? Math.floor(item.price * (1 - shopDiscount)) |
| 75 | + : item.price |
| 76 | + |
| 77 | + if (user.balance < price) { |
| 78 | + throw new APIError(403, 'Insufficient balance') |
| 79 | + } |
| 80 | + |
| 81 | + const discountPct = Math.round(shopDiscount * 100) |
| 82 | + const description = |
| 83 | + discountPct > 0 |
| 84 | + ? `Purchased ${item.name} (${discountPct}% supporter discount)` |
| 85 | + : `Purchased ${item.name}` |
| 86 | + |
| 87 | + const txnData: TxnData = { |
| 88 | + category: 'SHOP_PURCHASE', |
| 89 | + fromType: 'USER', |
| 90 | + fromId: auth.uid, |
| 91 | + toType: 'BANK', |
| 92 | + toId: 'BANK', |
| 93 | + amount: price, |
| 94 | + token: 'M$', |
| 95 | + description, |
| 96 | + data: { itemId, ticketOrder: true, supporterDiscount: shopDiscount }, |
| 97 | + } |
| 98 | + const txn = await runTxnOutsideBetQueue(tx, txnData) |
| 99 | + |
| 100 | + const order = await tx.one<{ id: string }>( |
| 101 | + `INSERT INTO shop_orders (user_id, item_id, price_mana, txn_id, status) |
| 102 | + VALUES ($1, $2, $3, $4, 'COMPLETED') |
| 103 | + RETURNING id`, |
| 104 | + [auth.uid, itemId, price, txn.id] |
| 105 | + ) |
| 106 | + |
| 107 | + const { count: newCount } = await tx.one<{ count: number }>( |
| 108 | + `SELECT count(*)::int AS count FROM shop_orders |
| 109 | + WHERE item_id = $1 |
| 110 | + AND status NOT IN ('FAILED', 'REFUNDED', 'CANCELLED')`, |
| 111 | + [itemId] |
| 112 | + ) |
| 113 | + |
| 114 | + return { |
| 115 | + orderId: order.id, |
| 116 | + remainingStock: Math.max(0, (item.maxStock ?? 0) - newCount), |
| 117 | + } |
| 118 | + }), |
| 119 | + [`ticket:${itemId}`] |
| 120 | + ) |
| 121 | + |
| 122 | + return { |
| 123 | + success: true, |
| 124 | + orderId, |
| 125 | + discountCode: process.env.MANIFEST_DISCOUNT_CODE ?? null, |
| 126 | + remainingStock, |
| 127 | + } |
| 128 | +} |
0 commit comments