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 `store_category` (árbol por `code` "1-1-1") + `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}$/ /** Nombre de personaje válido de WoW. Evita inyección en el comando SOAP. */ export function isValidCharName(name: string): boolean { return SAFE_NAME.test(name) } const MAIL_ITEM_LIMIT = 12 // máximo de ítems por correo en AzerothCore export interface StoreItem { id: number // fila de store_item (clave del carrito) itemId: number name: string icon: string | null qty: number currency: Currency price: number preview: string | null } /** * Un trozo del nombre de una categoría: texto y, si lo lleva, la clase de color * con la que hay que pintarlo (`warrior`, `second-brown`…). * * El nombre viene de BD como HTML (así lo hace el original: colorea los nombres * de clase y pone las etiquetas en otro tono). Se trocea AQUÍ, en el servidor, y * al cliente le llega ya estructurado: así se pinta con React y no hace falta * meter HTML de la BD en el DOM. */ export interface NamePart { text: string cls?: string } export interface StoreCategory { code: string name: NamePart[] /** Clase de la cabecera; a veces sustituye a first-brown para colorearla entera. */ css: string depth: number children: StoreCategory[] items: StoreItem[] } interface CatRow extends RowDataPacket { code: string parent_code: string | null name: string css: 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 } /** `` — la única etiqueta que usa el original. */ const NAME_SPAN = /([^<]*)<\/span>/g function unescapeHtml(s: string): string { return s .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') } /** * Trocea el nombre HTML de una categoría en partes con su color. * * Solo entiende ``, que es lo único que usa el * catálogo. Cualquier otra etiqueta se ignora y queda su texto: si alguien mete * HTML raro en la BD, aquí NO puede acabar inyectado (al cliente solo le llegan * texto y un nombre de clase). */ export function parseCategoryName(html: string): NamePart[] { const parts: NamePart[] = [] let last = 0 for (const m of html.matchAll(NAME_SPAN)) { const plain = html.slice(last, m.index).replace(/<[^>]*>/g, '') if (plain) parts.push({ text: unescapeHtml(plain) }) if (m[2]) parts.push({ text: unescapeHtml(m[2]), cls: m[1] }) last = m.index + m[0].length } const tail = html.slice(last).replace(/<[^>]*>/g, '') if (tail) parts.push({ text: unescapeHtml(tail) }) return parts.length ? parts : [{ text: unescapeHtml(html.replace(/<[^>]*>/g, '')) }] } /** * Árbol completo de la tienda (categorías anidadas con sus ítems en las hojas). * * El nombre del ítem sale de `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 * `store_item.name` del catálogo original es solo español. El de la * categoría sale de `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, css, depth, sort FROM 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 store_item i LEFT JOIN 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: parseCategoryName(c.name), css: c.css || 'first-brown', 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 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 (store_item.quantity); // `copies` = cuántas veces se compra la línea. Total de unidades = qty*copies. // `name` = nombre del catálogo; lo usa el concepto del cobro con tarjeta. lines: { id: number; itemId: number; name: string; qty: number; currency: Currency; price: number; copies: number }[] pdTotal: number vpTotal: number } /** * Concepto del cobro con tarjeta: quién compra y qué. Sale en la pasarela y en * su panel, así que es lo único que tendrán Stripe/SumUp para identificar un * pago si hay que reclamar o devolver algo a mano. * * Formato: `Tienda · BNet · Cuenta · · IP · Nombre (id) x2, …` * * OJO: la IP va aquí a propósito (identificar al pagador si hay fraude o una * devolución a mano), pero esto se la manda a la pasarela; hasta ahora solo se * guardaba en `stripelog.acore_ip`. Se omite si no se conoce, y también el * 0.0.0.0 de relleno, que no dice nada. * * Se recorta a `MAX_CONCEPT`: Stripe y SumUp limitan el campo (~250-255), y un * carrito de 100 líneas se pasaría de largo. Se recortan solo los ÍTEMS, nunca * la cabecera: quién ha pagado importa más que el listado completo, que además * queda entero en `store_order`. */ const MAX_CONCEPT = 200 export function storeConcept( cart: PricedCart, opts: { bnetEmail?: string; account?: string; character: string; ip?: string }, ): string { const ip = opts.ip && opts.ip !== '0.0.0.0' ? opts.ip : '' const head = [ 'Tienda', opts.bnetEmail ? `BNet ${opts.bnetEmail}` : null, opts.account ? `Cuenta ${opts.account}` : null, opts.character, ip ? `IP ${ip}` : null, ] .filter(Boolean) .join(' · ') const parts = cart.lines.map((l) => `${l.name} (${l.itemId})${l.copies > 1 ? ` x${l.copies}` : ''}`) // Se van quitando ítems por el final hasta que quepa TODO, sufijo incluido: si // se comprueba antes de añadir el "+N más" el resultado se pasa igualmente. const fits = (s: string) => `${head} · ${s}`.length <= MAX_CONCEPT let list = parts.join(', ') for (let n = parts.length - 1; n >= 0 && !fits(list); n--) { list = n > 0 ? `${parts.slice(0, n).join(', ')}, +${parts.length - n} más` : `${parts.length} ítems` } return fits(list) ? `${head} · ${list}` : head } /** * Valida el carrito contra la BD (precios/cantidades/moneda REALES, nunca del * cliente) a partir de las filas `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, name, quantity, currency, price FROM 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), name: String(r.name), 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') } } /** * Texto del correo. El regalo lo cambia para decir quién lo envía; sin esto el * destinatario recibiría un correo de "Tienda" sin saber de quién viene. */ export interface Mail { subject: string body: string } /** Correo de un regalo: dice quién lo manda (como el original). */ export function giftMail(sender: string): Mail { return { subject: 'Regalo', body: `¡Has recibido un regalo de ${sender}! ¡Que lo disfrutes!`, } } /** Entrada de correo: un lote de un ítem, con lo que costó (para reembolsar). */ interface CartEntry { i: number q: number currency: Currency price: number } /** * 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`. Cada entrada * lleva su precio para poder reembolsar SOLO lo que no llegue a enviarse. */ function cartEntries(cart: PricedCart): CartEntry[] { return cart.lines.flatMap((l) => Array.from({ length: l.copies }, () => ({ i: l.itemId, q: l.qty, currency: l.currency, price: l.price })), ) } /** * 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, mail?: Mail, ): 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 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 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 a medias, se reembolsa SOLO // lo que no se envió: los correos que ya salieron no se pueden recuperar, y // devolver el carrito entero regalaría lo ya entregado. const entries = cartEntries(cart) const sent = await sendStoreItems(character, entries, mail) if (sent < entries.length) { const left = entries.slice(sent) const back = (c: Currency) => left.filter((e) => e.currency === c).reduce((s, e) => s + e.price, 0) const pdBack = back('pd') const vpBack = back('pv') if (charged && (pdBack > 0 || vpBack > 0)) { await db(DB.default) .query('UPDATE api_points SET dp = dp + ?, vp = vp + ? WHERE accountID = ?', [pdBack, vpBack, accountId]) .catch(() => {}) } // Si no salió ni un correo es el fallo de siempre (SOAP caído). Si salió // alguno, el jugador tiene parte de la compra y solo se le devuelve el resto. return { success: false, error: sent === 0 ? 'deliveryFailed' : 'partialDelivery' } } 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 { // Formato `itemId:qty:precio:moneda` por entrada. El precio va guardado (y no // se relee del catálogo al entregar) por dos razones: 114 item_id son // ambiguos —el mismo objeto se vende a 50 PV y a 100 PD—, y aunque no lo // fueran, hay que reembolsar lo que se COBRÓ, no lo que valga el ítem el día // de la entrega. Se guarda en PD/PV, no en céntimos, para que el reembolso // redondee igual que el cobro (storeEuroTotal, una sola vez sobre el total). const items = cartEntries(cart).map((e) => `${e.i}:${e.q}:${e.price}:${e.currency}`).join(',') const amount = storeEuroTotal(cart.pdTotal, cart.vpTotal) try { await db(DB.default).query( 'INSERT INTO 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). * * Aquí no se puede devolver saldo como en el pago con PD/PV: el dinero lo tiene * la pasarela. Por eso devuelve cuánto hay que reembolsarle al cliente * (`refundEur`), y quien llama —que es quien sabe si fue Stripe o SumUp— pide el * reembolso por su API. No puede duplicar entregas porque `claimPaidCheckout` / * `fulfillSumUpCheckout` reclaman el pago una sola vez. */ export async function fulfillStoreOrder( character: string, ref: string, mail?: Mail, ): Promise<{ ok: boolean; refundEur?: number }> { if (!ref) return { ok: false } let rows: RowDataPacket[] = [] try { ;[rows] = await db(DB.default).query( 'SELECT character_name, items, amount FROM store_order WHERE ref = ?', [ref], ) } catch { return { ok: false } } const order = rows[0] if (!order) return { ok: false } const target = character || String(order.character_name) // `itemId:qty:precio:moneda`. Los pedidos anteriores a este formato solo traen // `itemId:qty`: se entregan igual, pero de esos no se puede calcular cuánto // reembolsar (no llevan precio), así que se avisa y se deja a mano. const entries = String(order.items) .split(',') .map((p) => { const [i, q, price, cur] = p.split(':') return { i: Number(i), q: Number(q), price: Number(price), currency: cur as Currency } }) .filter((e) => e.i > 0 && e.q >= 1) if (entries.length === 0) return { ok: false } const priced = entries.every((e) => e.price > 0 && (e.currency === 'pd' || e.currency === 'pv')) const sent = await sendStoreItems(target, entries, mail) if (sent === entries.length) return { ok: true } if (!priced) { console.error( `[store] pedido ${ref}: entregadas ${sent}/${entries.length} entradas, pero es de formato antiguo` + ` (sin precios) y no se puede calcular el reembolso. Revisar a mano.`, ) return { ok: false } } const left = entries.slice(sent) const back = (c: Currency) => left.filter((e) => e.currency === c).reduce((s, e) => s + e.price, 0) // Nunca más de lo cobrado: los dos importes se redondean por separado y podrían // descuadrar un céntimo si se entregó justo la mitad de un ítem de PV impar. const refundEur = Math.min(storeEuroTotal(back('pd'), back('pv')), Number(order.amount)) console.error(`[store] pedido ${ref}: entregadas ${sent}/${entries.length}; se reembolsan ${refundEur} €`) return { ok: false, refundEur } } /** * Envía los ítems de la tienda por correo (SOAP `.send items`), troceado a 12 * por correo. * * Devuelve CUÁNTAS entradas se entregaron, no un booleano: un correo enviado ya * no se puede deshacer, así que si el 3.º de 9 falla, los 2 primeros están en el * buzón del jugador y quien llame tiene que saberlo para reembolsar solo el * resto. Cada correo es un `.send items`, así que el troceo es atómico por * correo: un chunk sale entero o no sale. */ async function sendStoreItems( character: string, items: { i: number; q: number }[], mail?: Mail, ): Promise { if (!SAFE_NAME.test(character)) return 0 // Los datos salen de la BD: si algo no cuadra es un bug, y se prefiere no // enviar nada a enviar de menos y descuadrar el reembolso por índice. if (items.length === 0 || items.some((it) => !(Number(it.i) > 0) || !(Number(it.q) >= 1))) return 0 const subject = mail?.subject ?? 'Tienda' const body = mail?.body ?? 'Has recibido los objetos de la tienda. ¡Que los disfrutes!' let sent = 0 for (let n = 0; n < items.length; n += MAIL_ITEM_LIMIT) { const chunk = items.slice(n, n + MAIL_ITEM_LIMIT) const itemsStr = chunk.map((it) => `${Math.floor(it.i)}:${Math.floor(it.q)}`).join(' ') const res = await executeSoapCommand(`.send items "${character}" "${subject}" "${body}" ${itemsStr}`) if (res === null) return sent sent += chunk.length } return sent }