import type { RowDataPacket } from 'mysql2' import { db, DB } from './db' import { executeSoapCommand } from './soap' import { PD_PER_UNIT } from './dpoints' import { VP_PRICE_FACTOR } from './pay-with-dpoints' import { MAX_COPIES, PD_PER_EUR, PV_PER_EUR, storeEuroTotal as pureEuroTotal } from './store-pricing' /** * Tienda de ítems (transfiguración/equipo), portada del sistema antiguo (BENNU). * Catálogo en `home_store_category` (árbol por `code` "1-1-1") + `home_store_item` * (precio en PD o PV, cantidad, icono). Se paga con el saldo PD/PV de la cuenta y * los ítems se envían por correo al personaje elegido (SOAP `.send items`). */ export type Currency = 'pd' | 'pv' const SAFE_NAME = /^[A-Za-z]{1,12}$/ const MAIL_ITEM_LIMIT = 12 // máximo de ítems por correo en AzerothCore export interface StoreItem { id: number // fila de home_store_item (clave del carrito) itemId: number name: string icon: string | null qty: number currency: Currency price: number preview: string | null } export interface StoreCategory { code: string name: string depth: number children: StoreCategory[] items: StoreItem[] } interface CatRow extends RowDataPacket { code: string parent_code: string | null name: string depth: number sort: number } interface ItemRow extends RowDataPacket { id: number category_code: string item_id: number name: string icon: string | null quantity: number currency: Currency price: number preview: string | null sort: number } /** * Árbol completo de la tienda (categorías anidadas con sus ítems en las hojas). * * El nombre del ítem sale de `home_item_data` (extraído de los DB2 del cliente * 3.4.3, ver sql/db2/), que es el ÚNICO sitio con el nombre en inglés: el * `home_store_item.name` del catálogo original es solo español. El de la * categoría sale de `home_store_category.name_en` (ver sql/store_category_en.sql). * En ambos casos, si falta el nombre en ese idioma se cae al del catálogo, que * siempre está. */ export async function getStoreCatalog(locale: string): Promise { // Solo se interpola el nombre de la COLUMNA, y sale de una lista cerrada: // nunca del locale crudo (que viene de la URL). const en = locale === 'en' const nameCol = en ? 'name_en' : 'name_es' const catNameCol = en ? 'name_en' : 'name' let cats: CatRow[] = [] let items: ItemRow[] = [] try { ;[cats] = await db(DB.default).query( `SELECT code, parent_code, COALESCE(NULLIF(${catNameCol}, ''), name) AS name, depth, sort FROM home_store_category ORDER BY sort`, ) ;[items] = await db(DB.default).query( `SELECT i.id, i.category_code, i.item_id, COALESCE(NULLIF(d.${nameCol}, ''), i.name) AS name, i.icon, i.quantity, i.currency, i.price, i.preview, i.sort FROM home_store_item i LEFT JOIN home_item_data d ON d.entry = i.item_id ORDER BY i.sort`, ) } catch { return [] } const nodes = new Map() for (const c of cats) { nodes.set(c.code, { code: c.code, name: c.name, depth: c.depth, children: [], items: [] }) } const roots: StoreCategory[] = [] for (const c of cats) { const node = nodes.get(c.code)! if (c.parent_code && nodes.has(c.parent_code)) nodes.get(c.parent_code)!.children.push(node) else roots.push(node) } for (const it of items) { const leaf = nodes.get(it.category_code) if (!leaf) continue leaf.items.push({ id: it.id, itemId: it.item_id, name: it.name, icon: it.icon, qty: it.quantity, currency: it.currency, price: it.price, preview: it.preview, }) } return roots } /** Saldos PD y PV de la cuenta (para mostrar y validar). */ export async function getStoreBalances(accountId: number): Promise<{ dp: number; vp: number }> { if (!accountId) return { dp: 0, vp: 0 } try { const [rows] = await db(DB.default).query( 'SELECT dp, vp FROM home_api_points WHERE accountID = ?', [accountId], ) return rows[0] ? { dp: Number(rows[0].dp), vp: Number(rows[0].vp) } : { dp: 0, vp: 0 } } catch { return { dp: 0, vp: 0 } } } /** Línea que manda el cliente: fila del catálogo + cuántas copias quiere. */ export interface StoreCartLine { id: number copies: number } export interface PricedCart { // `qty` = lote que entrega CADA copia (home_store_item.quantity); // `copies` = cuántas veces se compra la línea. Total de unidades = qty*copies. lines: { id: number; itemId: number; qty: number; currency: Currency; price: number; copies: number }[] pdTotal: number vpTotal: number } /** * Valida el carrito contra la BD (precios/cantidades/moneda REALES, nunca del * cliente) a partir de las filas `home_store_item.id` y las copias pedidas. * Devuelve null si está vacío, si alguna copia no es válida o si algún id no * existe en el catálogo (mismo criterio que `priceCart` de send-gift). */ export async function priceStoreCart(cart: StoreCartLine[]): Promise { const copiesById = new Map() for (const l of Array.isArray(cart) ? cart : []) { const id = Math.floor(Number(l?.id)) const copies = Math.floor(Number(l?.copies)) if (!Number.isInteger(id) || id <= 0) return null if (!Number.isInteger(copies) || copies < 1 || copies > MAX_COPIES) return null copiesById.set(id, Math.min(MAX_COPIES, (copiesById.get(id) ?? 0) + copies)) } if (copiesById.size === 0) return null const ids = [...copiesById.keys()] let rows: ItemRow[] = [] try { ;[rows] = await db(DB.default).query( `SELECT id, item_id, quantity, currency, price FROM home_store_item WHERE id IN (${ids.map(() => '?').join(',')})`, ids, ) } catch { return null } if (rows.length !== ids.length) return null // algún id no existe en el catálogo const lines = rows.map((r) => ({ id: Number(r.id), itemId: Number(r.item_id), qty: Number(r.quantity), currency: r.currency, price: Number(r.price), copies: copiesById.get(Number(r.id))!, })) const sum = (c: Currency) => lines.filter((l) => l.currency === c).reduce((s, l) => s + l.price * l.copies, 0) return { lines, pdTotal: sum('pd'), vpTotal: sum('pv') } } /** * Entradas para el SOAP/el pedido: cada copia va como una entrada propia * (`2589:20 2589:20` = 2 lotes de 20), que es justo lo que ya sabe trocear * `sendStoreItems` (12 por correo) y parsear `fulfillStoreOrder`. */ function cartEntries(cart: PricedCart): { i: number; q: number }[] { return cart.lines.flatMap((l) => Array.from({ length: l.copies }, () => ({ i: l.itemId, q: l.qty }))) } /** * Cobra el carrito (descuenta PD y PV de forma atómica, ambos a la vez) y envía * los ítems por correo al personaje. Reembolsa si el envío por SOAP falla. * Devuelve un código de error si algo no cuadra. */ export async function purchaseStoreCart( accountId: number, character: string, cart: PricedCart, ): Promise<{ success: boolean; error?: string }> { if (!SAFE_NAME.test(character)) return { success: false, error: 'invalidCharacter' } if (cart.pdTotal <= 0 && cart.vpTotal <= 0) return { success: false, error: 'emptyCart' } // Cobro atómico: bloquea la fila y exige saldo suficiente en AMBAS monedas. const conn = await db(DB.default).getConnection() let charged = false try { await conn.beginTransaction() const [pts] = await conn.query( 'SELECT dp, vp FROM home_api_points WHERE accountID = ? FOR UPDATE', [accountId], ) const dp = pts[0] ? Number(pts[0].dp) : 0 const vp = pts[0] ? Number(pts[0].vp) : 0 if (dp < cart.pdTotal) { await conn.rollback(); return { success: false, error: 'insufficientPd' } } if (vp < cart.vpTotal) { await conn.rollback(); return { success: false, error: 'insufficientVp' } } await conn.query('UPDATE home_api_points SET dp = dp - ?, vp = vp - ? WHERE accountID = ?', [cart.pdTotal, cart.vpTotal, accountId]) await conn.commit() charged = true } catch { try { await conn.rollback() } catch { /* ignore */ } return { success: false, error: 'genericError' } } finally { conn.release() } // Envío por correo (troceado a 12 ítems). Si falla, se reembolsa el saldo. const ok = await sendStoreItems(character, cartEntries(cart)) if (!ok) { if (charged) { await db(DB.default) .query('UPDATE home_api_points SET dp = dp + ?, vp = vp + ? WHERE accountID = ?', [cart.pdTotal, cart.vpTotal, accountId]) .catch(() => {}) } return { success: false, error: 'deliveryFailed' } } return { success: true } } /** * Coste del carrito en euros para pagar con tarjeta. Inverso exacto de cómo se * derivan los precios PD/PV de un precio en euros en el resto de servicios: * 100 PD = 1 € y los PV cuestan VP_PRICE_FACTOR× (200 PV = 1 €). * * La implementación vive en `store-pricing` (módulo puro) para que el carrito * del cliente enseñe EXACTAMENTE el importe que aquí se valida y se cobra. Este * `if` es la red de seguridad de esa duplicación: si alguien cambia PD_PER_UNIT * o VP_PRICE_FACTOR y no toca store-pricing, salta al primer uso en vez de * cobrar mal en silencio. */ export function storeEuroTotal(pdTotal: number, vpTotal: number): number { if (PD_PER_UNIT !== PD_PER_EUR || PD_PER_UNIT * VP_PRICE_FACTOR !== PV_PER_EUR) { throw new Error( `store-pricing desincronizado: PD_PER_UNIT=${PD_PER_UNIT}, VP_PRICE_FACTOR=${VP_PRICE_FACTOR}` + ` pero store-pricing tiene PD_PER_EUR=${PD_PER_EUR}, PV_PER_EUR=${PV_PER_EUR}`, ) } return pureEuroTotal(pdTotal, vpTotal) } /** Guarda el carrito como pedido pendiente (para entregarlo tras el pago con tarjeta). */ export async function createStoreOrder( ref: string, accountId: number, character: string, cart: PricedCart, ): Promise { const items = cartEntries(cart).map((e) => `${e.i}:${e.q}`).join(',') const amount = storeEuroTotal(cart.pdTotal, cart.vpTotal) try { await db(DB.default).query( 'INSERT INTO home_store_order (ref, account_id, character_name, items, amount) VALUES (?, ?, ?, ?, ?)', [ref, accountId, character, items, amount], ) return true } catch { return false } } /** * Entrega un pedido pagado con tarjeta: busca el carrito por `ref` y envía los * ítems por correo. Lo llama el fulfillment del servicio `store` (webhook/return). */ export async function fulfillStoreOrder(character: string, ref: string): Promise { if (!ref) return false let rows: RowDataPacket[] = [] try { ;[rows] = await db(DB.default).query( 'SELECT character_name, items FROM home_store_order WHERE ref = ?', [ref], ) } catch { return false } const order = rows[0] if (!order) return false const target = character || String(order.character_name) const items = String(order.items) .split(',') .map((p) => { const [i, q] = p.split(':') return { i: Number(i), q: Number(q) } }) .filter((it) => it.i > 0 && it.q >= 1) return sendStoreItems(target, items) } /** Envía los ítems de la tienda por correo (SOAP `.send items`), troceado. */ async function sendStoreItems(character: string, items: { i: number; q: number }[]): Promise { if (!SAFE_NAME.test(character)) return false const clean = items .map((it) => ({ i: Math.floor(Number(it.i)), q: Math.floor(Number(it.q)) })) .filter((it) => it.i > 0 && it.q >= 1) if (clean.length === 0) return false const subject = 'Tienda' const body = 'Has recibido los objetos de la tienda. ¡Que los disfrutes!' for (let n = 0; n < clean.length; n += MAIL_ITEM_LIMIT) { const chunk = clean.slice(n, n + MAIL_ITEM_LIMIT) const itemsStr = chunk.map((it) => `${it.i}:${it.q}`).join(' ') const res = await executeSoapCommand(`.send items "${character}" "${subject}" "${body}" ${itemsStr}`) if (res === null) return false } return true }