e500bd7a84
El carrito enseñaba PD/PV aunque eligieras tarjeta, y por defecto ya presuponía saldo. Ahora se muestran SIEMPRE las dos monedas y se apaga la que no se va a cobrar con el método elegido, así que no presupone nada ni se contradice. Los euros por línea van con hasta 3 decimales a propósito: un ítem en PV con precio impar cuesta medio céntimo (75 PV = 0,375 €), y redondear cada línea a 2 haría que las líneas no sumaran el total (0,38 + 0,38 = 0,76 contra un total de 0,75). El total sí va a céntimos: es el importe que se cobra de verdad. Y con mínimo 2 decimales, que es dinero: "1,50 €", no "1,5 €". El formateo va por Intl con el locale de la web, así que en español sale con coma; antes el selector interpolaba el número crudo y decía "0.75 €" con punto. Mínimos de las pasarelas: SumUp ya estaba en 1 € y Stripe estaba en 0,50 €, ahora también 1 €. Estaban sueltos en la ruta; se centralizan en `lib/store-pricing`, un módulo PURO que importan tanto la ruta como el componente, para que el carrito enseñe exactamente lo que se valida y se cobra. `storeEuroTotal` pasa a vivir ahí (una sola implementación) y lib/store lo envuelve con un guard: si alguien cambia PD_PER_UNIT o VP_PRICE_FACTOR sin tocar store-pricing, revienta al primer uso en vez de cobrar mal en silencio (esos módulos tocan BD y no pueden llegar al bundle del cliente, de ahí la duplicación, igual que en PaymentMethodSelect). Por debajo del mínimo, la opción de tarjeta se desactiva y dice "Mínimo 1,00 €" en vez de dejarte pulsar Enviar para que la API la rechace. El método efectivo se deriva en el render (si el carrito baja del mínimo con tarjeta ya elegida, cae al saldo) en vez de sincronizarlo con un setState en un efecto, que provocaría renders en cascada. Verificado en el navegador: con 0,375 € las dos tarjetas salen desactivadas y elegido el saldo; con 1,50 € se habilitan. Las dos líneas de 0,375 € suman el total de 0,75 €. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
297 lines
11 KiB
TypeScript
297 lines
11 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 { 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 }
|
||
}
|
||
}
|
||
|
||
export interface PricedCart {
|
||
lines: { id: number; itemId: number; qty: number; currency: Currency; price: 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`. Devuelve null si vacío.
|
||
*/
|
||
export async function priceStoreCart(itemRowIds: number[]): Promise<PricedCart | null> {
|
||
const ids = [...new Set((Array.isArray(itemRowIds) ? itemRowIds : []).map((n) => Math.floor(Number(n))).filter((n) => n > 0))]
|
||
if (ids.length === 0) return null
|
||
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 === 0) return null
|
||
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) }))
|
||
const pdTotal = lines.filter((l) => l.currency === 'pd').reduce((s, l) => s + l.price, 0)
|
||
const vpTotal = lines.filter((l) => l.currency === 'pv').reduce((s, l) => s + l.price, 0)
|
||
return { lines, pdTotal, vpTotal }
|
||
}
|
||
|
||
/**
|
||
* 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, cart.lines.map((l) => ({ i: l.itemId, q: l.qty })))
|
||
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 = cart.lines.map((l) => `${l.itemId}:${l.qty}`).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
|
||
}
|