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>
This commit is contained in:
2026-07-15 00:14:01 +00:00
parent 5a903ef627
commit f0b006404c
7 changed files with 215 additions and 13 deletions
+60
View File
@@ -1,6 +1,8 @@
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).
@@ -186,6 +188,64 @@ export async function purchaseStoreCart(
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