Files
NightSpire/web-next/lib/store.ts
T
Inna f0b006404c store: pagar también con Stripe y SumUp (tarjeta), además del saldo PD/PV
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>
2026-07-15 00:14:01 +00:00

266 lines
9.1 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'
/**
* 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
}