Files
NightSpire/web-next/lib/store.ts
T
Inna f232f0fc5f store: poder cambiar la cantidad de un mismo artículo en el carrito
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>
2026-07-15 10:10:33 +00:00

333 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}