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
}