cccc70338d
El prefijo venía del nombre de la app Django (`home`), jubilada y borrada hoy, así que ya no significaba nada: home_stripelog -> stripelog. La BD se sigue llamando django_wow por historia (renombrarla es otra operación). Comprobado antes de tocar nada: 0 colisiones con nombres existentes, 0 palabras reservadas (contrastado contra las 260 de MySQL), sin vistas ni triggers que dependieran de ellas. El RENAME va en una sola sentencia porque así es atómico, y MySQL reapunta solo las 4 claves ajenas entre estas tablas. 170 referencias actualizadas en 37 ficheros. Se dejó a propósito sql/drop_home_securitytoken_authuser_fk.sql sin tocar: es una migración ya aplicada y reescribirla sería falsear el historial. De paso salió un fallo previo: prices.ts consultaba `home_restoreitemprice`, una tabla que NO existe. El try/catch de priceFrom se comía el error y devolvía siempre el precio por defecto, así que el precio de restaurar objetos nunca fue configurable. Se deja documentado en el código; crear la tabla es otra decisión. Verificado tras aplicar: 0 tablas home_, las 51 siguen ahí, y el conteo exacto de filas cuadra con el volcado previo (store_item 3068, item_data 44873, stripelog 35, store_order 26, noticia 50). La web responde 200 en todas las rutas y la portada carga las noticias sin errores. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
534 lines
20 KiB
TypeScript
534 lines
20 KiB
TypeScript
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
|
||
}
|
||
|
||
/** `<span class="no-toggle X">…</span>` — la única etiqueta que usa el original. */
|
||
const NAME_SPAN = /<span class="no-toggle ([a-z0-9- ]+)">([^<]*)<\/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 `<span class="no-toggle X">`, 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<StoreCategory[]> {
|
||
// 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<CatRow[]>(
|
||
`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<ItemRow[]>(
|
||
`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<string, StoreCategory>()
|
||
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<RowDataPacket[]>(
|
||
'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 <email> · Cuenta <cuenta> · <personaje> · IP <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<PricedCart | null> {
|
||
const copiesById = new Map<number, number>()
|
||
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<ItemRow[]>(
|
||
`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<RowDataPacket[]>(
|
||
'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<boolean> {
|
||
// 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<RowDataPacket[]>(
|
||
'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<number> {
|
||
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
|
||
}
|