f0b006404c
Selector de forma de pago en el carrito (Saldo PD/PV · Stripe · SumUp), como el resto de servicios. Con tarjeta, el carrito se convierte a euros (100 PD = 1 €, 200 PV = 1 €), se guarda como pedido y se entrega tras confirmarse el pago. - BD: tabla home_store_order (ref, cuenta, personaje, items, importe) para no depender del límite de metadata de la pasarela (sql/home_store_order.sql). - lib/store: storeEuroTotal, createStoreOrder, fulfillStoreOrder. - paid-services: servicio 'store' (fulfill = enviar el pedido por order_ref); la entrega la dispara service-success/reconciliación/webhook como los demás. - /api/store/send: rama provider stripe/sumup (crea pedido + checkout, mínimo 1 €/0,50 €) además del pago con saldo. - StoreBrowser: selector Saldo/Stripe/SumUp; con tarjeta redirige a la pasarela. - i18n Store (payMethod/payBalance/payStripe/paySumUp/confirmCard/amountTooLow) y Paid.store (title/success). Verificado: euros 200PD+28PV=2,14 €, pedido y fulfill por order_ref (SOAP real pendiente, worldserver caído). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
266 lines
9.1 KiB
TypeScript
266 lines
9.1 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'
|
||
|
||
/**
|
||
* 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). */
|
||
export async function getStoreCatalog(): Promise<StoreCategory[]> {
|
||
let cats: CatRow[] = []
|
||
let items: ItemRow[] = []
|
||
try {
|
||
;[cats] = await db(DB.default).query<CatRow[]>(
|
||
'SELECT code, parent_code, name, depth, sort FROM home_store_category ORDER BY sort',
|
||
)
|
||
;[items] = await db(DB.default).query<ItemRow[]>(
|
||
'SELECT id, category_code, item_id, name, icon, quantity, currency, price, preview, sort FROM home_store_item ORDER BY 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 €).
|
||
*/
|
||
export function storeEuroTotal(pdTotal: number, vpTotal: number): number {
|
||
const eur = pdTotal / PD_PER_UNIT + vpTotal / (PD_PER_UNIT * VP_PRICE_FACTOR)
|
||
return Math.round(eur * 100) / 100
|
||
}
|
||
|
||
/** 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
|
||
}
|