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:
@@ -2,6 +2,7 @@ import { executeSoapCommand } from './soap'
|
||||
import { creditDPoints } from './dpoints'
|
||||
import { sendGiftByMail } from './gift'
|
||||
import { renameGuildBySoap, GUILD_RENAME_EUR } from './guild'
|
||||
import { fulfillStoreOrder } from './store'
|
||||
import { checkFactionChangeEligibility } from './change-faction'
|
||||
import { checkTransferEligibility } from './transfer-character'
|
||||
import {
|
||||
@@ -129,6 +130,14 @@ export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
|
||||
fulfill: (_c, m) => renameGuildBySoap(Number(m.guildId), m.newName),
|
||||
extraFields: ['guildId', 'newName'],
|
||||
},
|
||||
// Tienda pagada con tarjeta: al confirmarse el pago, envía por correo los ítems
|
||||
// del pedido (guardado en home_store_order) referenciado en metadata.order_ref.
|
||||
store: {
|
||||
price: (m) => Promise.resolve(Number(m.amount)),
|
||||
productName: () => 'Compra en la tienda',
|
||||
fulfill: (c, m) => fulfillStoreOrder(c, m.order_ref || ''),
|
||||
extraFields: [],
|
||||
},
|
||||
// Compra de PD (Adquirir PD): acredita puntos a la cuenta que hizo el pago.
|
||||
// El importe lo elige el usuario, por eso `price` lee metadata.amount.
|
||||
dpoints: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user