f232f0fc5f
El carrito era un conjunto: añadir dos veces el mismo ítem no hacía nada y el
botón se quedaba en "Añadido". Ahora cada línea lleva sus copias, editables con
un <input number> en la columna Cant, y volver a pulsar Añadir suma una (mismo
patrón que el carrito de send-gift, que ya lo hacía así).
Ojo con los dos "cantidad" que conviven, que no son lo mismo:
`home_store_item.quantity` es el LOTE que entrega cada copia (Paño de lino = 20)
y `copies` es cuántas veces se compra la línea. Por eso la fila enseña "3 × 20"
y el total de la columna Cant sigue siendo unidades (60), como en el original.
Cada copia se manda al SOAP como una entrada propia (`2589:20 2589:20`), que es
justo lo que ya sabía trocear sendStoreItems (12 por correo) y parsear
fulfillStoreOrder: el formato del pedido de tarjeta no cambia.
El servidor no se fía del cliente: priceStoreCart recibe {id, copies}, valida las
copias (1..MAX_COPIES, como el MAX_QTY de send-gift), rechaza el carrito si algún
id no existe y recalcula los totales como precio*copias contra la BD.
Verificado contra la API con saldo de prueba, no solo en la interfaz: con 500 PD
y un ítem de 200, 2 copias (400) pasan el cobro y 3 (600) dan insufficientPd. Si
el servidor ignorase las copias ambas darían lo mismo, así que el corte exacto
entre 2 y 3 prueba que multiplica. Comprobado también que el reembolso del
deliveryFailed devuelve los 400 PD, y que copias=0 o 101 se rechazan.
El CSS del input va con `#cart-list` por especificidad, no por gusto: el tema
define `input[type=number] { width: 290px }` y se carga el último para ganar la
cascada, así que un `.store-copies` a secas perdía y el input salía de 290px
reventando la tabla (send-gift se libra porque usa estilo en línea).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
333 lines
12 KiB
TypeScript
333 lines
12 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 `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<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, depth, sort
|
||
FROM home_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 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<string, StoreCategory>()
|
||
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<RowDataPacket[]>(
|
||
'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<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, 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<RowDataPacket[]>(
|
||
'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<boolean> {
|
||
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<boolean> {
|
||
if (!ref) return false
|
||
let rows: RowDataPacket[] = []
|
||
try {
|
||
;[rows] = await db(DB.default).query<RowDataPacket[]>(
|
||
'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<boolean> {
|
||
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
|
||
}
|