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:
@@ -1,11 +1,20 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { priceStoreCart, purchaseStoreCart } from '@/lib/store'
|
||||
import { priceStoreCart, purchaseStoreCart, storeEuroTotal, createStoreOrder } from '@/lib/store'
|
||||
import { createCheckoutSession } from '@/lib/stripe'
|
||||
import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup'
|
||||
|
||||
function safeLocale(v: unknown): string {
|
||||
return v === 'en' ? 'en' : 'es'
|
||||
}
|
||||
|
||||
/**
|
||||
* Compra de la tienda: cobra el carrito (PD/PV) y envía los ítems por correo al
|
||||
* personaje elegido (uno de la cuenta). El precio se recalcula en el servidor a
|
||||
* partir de las filas de `home_store_item`; nunca se confía en el cliente.
|
||||
* Compra de la tienda. `provider`:
|
||||
* - ausente/'balance' → cobra del saldo PD/PV y envía los ítems al momento.
|
||||
* - 'stripe'/'sumup' → pago con tarjeta (euros); el pedido se guarda y se
|
||||
* entrega tras confirmarse el pago (webhook/reconciliación).
|
||||
* El precio se recalcula siempre en el servidor; nunca se confía en el cliente.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
@@ -13,7 +22,7 @@ export async function POST(request: Request) {
|
||||
return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: { character?: string; items?: number[] } = {}
|
||||
let body: { character?: string; items?: number[]; provider?: string; locale?: string } = {}
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
@@ -29,6 +38,61 @@ export async function POST(request: Request) {
|
||||
const cart = await priceStoreCart(Array.isArray(body.items) ? body.items : [])
|
||||
if (!cart) return Response.json({ success: false, error: 'emptyCart' })
|
||||
|
||||
const provider = String(body.provider ?? 'balance')
|
||||
|
||||
// Pago con tarjeta: guarda el pedido y crea el checkout de la pasarela.
|
||||
if (provider === 'stripe' || provider === 'sumup') {
|
||||
const amount = storeEuroTotal(cart.pdTotal, cart.vpTotal)
|
||||
const minEur = provider === 'sumup' ? 1 : 0.5
|
||||
if (amount < minEur) return Response.json({ success: false, error: 'amountTooLow', min: minEur })
|
||||
|
||||
const ref = randomUUID()
|
||||
if (!(await createStoreOrder(ref, session.accountId, character, cart))) {
|
||||
return Response.json({ success: false, error: 'genericError' })
|
||||
}
|
||||
|
||||
const site = process.env.SITE_URL || ''
|
||||
const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0'
|
||||
const productName = `Compra en la tienda (${cart.lines.length} objeto${cart.lines.length === 1 ? '' : 's'})`
|
||||
const metadata = { service: 'store', order_ref: ref }
|
||||
|
||||
if (provider === 'sumup') {
|
||||
if (!sumupConfigured()) return Response.json({ success: false, error: 'notConfigured' })
|
||||
const r = await createSumUpCheckout({
|
||||
accountId: session.accountId,
|
||||
username: session.username || '',
|
||||
email: session.bnetEmail || '',
|
||||
acoreIp: ip,
|
||||
amount,
|
||||
points: 0,
|
||||
reference: ref,
|
||||
description: productName,
|
||||
returnUrl: `${site}/service-success?service=store&provider=sumup&ref=${ref}`,
|
||||
service: 'store',
|
||||
characterName: character,
|
||||
metadata,
|
||||
})
|
||||
if (!r.success || !r.url) return Response.json({ success: false, error: r.error || 'sumupError' })
|
||||
return Response.json({ success: true, url: r.url })
|
||||
}
|
||||
|
||||
const r = await createCheckoutSession({
|
||||
accountId: session.accountId,
|
||||
username: session.username || '',
|
||||
email: session.bnetEmail || '',
|
||||
acoreIp: ip,
|
||||
amount,
|
||||
characterName: character,
|
||||
productName,
|
||||
successUrl: `${site}/service-success?service=store`,
|
||||
cancelUrl: `${site}/${safeLocale(body.locale)}/store`,
|
||||
metadata,
|
||||
})
|
||||
if (!r.success || !r.url) return Response.json({ success: false, error: 'stripeError' })
|
||||
return Response.json({ success: true, url: r.url })
|
||||
}
|
||||
|
||||
// Pago con saldo PD/PV: cobra y envía al momento.
|
||||
const r = await purchaseStoreCart(session.accountId, character, cart)
|
||||
if (!r.success) return Response.json({ success: false, error: r.error })
|
||||
return Response.json({ success: true })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
|
||||
import { WowheadLink } from '@/components/WowheadLink'
|
||||
import { wowheadIcon } from '@/lib/wowhead'
|
||||
@@ -78,12 +78,16 @@ function CategoryNode({
|
||||
)
|
||||
}
|
||||
|
||||
type PayMethod = 'balance' | 'stripe' | 'sumup'
|
||||
|
||||
export function StoreBrowser({ characters, dp, vp }: { characters: CharOption[]; dp: number; vp: number }) {
|
||||
const t = useTranslations('Store')
|
||||
const locale = useLocale()
|
||||
const [character, setCharacter] = useState('')
|
||||
const [catalog, setCatalog] = useState<StoreCategory[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [cart, setCart] = useState<Map<number, StoreItem>>(new Map())
|
||||
const [method, setMethod] = useState<PayMethod>('balance')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [modal, setModal] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
@@ -121,22 +125,34 @@ export function StoreBrowser({ characters, dp, vp }: { characters: CharOption[];
|
||||
const lines = [...cart.values()]
|
||||
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)
|
||||
// 100 PD = 1 €, 200 PV = 1 € (igual que en lib/store storeEuroTotal).
|
||||
const eurTotal = Math.round((pdTotal / 100 + vpTotal / 200) * 100) / 100
|
||||
|
||||
async function send() {
|
||||
if (sending || cart.size === 0) return
|
||||
if (!window.confirm(t('confirm', { character, pd: pdTotal, vp: vpTotal }))) return
|
||||
const card = method === 'stripe' || method === 'sumup'
|
||||
const ok = card
|
||||
? window.confirm(t('confirmCard', { eur: eurTotal, character }))
|
||||
: window.confirm(t('confirm', { character, pd: pdTotal, vp: vpTotal }))
|
||||
if (!ok) return
|
||||
setSending(true)
|
||||
try {
|
||||
const res = await fetch('/api/store/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ character, items: [...cart.keys()] }),
|
||||
body: JSON.stringify({ character, items: [...cart.keys()], provider: method, locale }),
|
||||
})
|
||||
const data: { success?: boolean; error?: string } = await res.json()
|
||||
const data: { success?: boolean; error?: string; url?: string; min?: number } = await res.json()
|
||||
if (data.success && data.url) {
|
||||
window.location.href = data.url // pasarela (Stripe/SumUp)
|
||||
return
|
||||
}
|
||||
if (data.success) {
|
||||
setModal({ ok: true, text: t('successMsg', { character }) })
|
||||
setCart(new Map())
|
||||
} else if (data.error === 'amountTooLow') {
|
||||
setModal({ ok: false, text: t('errors.amountTooLow', { min: data.min ?? 1 }) })
|
||||
} else {
|
||||
setModal({ ok: false, text: t.has(`errors.${data.error}`) ? t(`errors.${data.error}`) : t('errors.generic') })
|
||||
}
|
||||
@@ -171,6 +187,24 @@ export function StoreBrowser({ characters, dp, vp }: { characters: CharOption[];
|
||||
|
||||
{/* Carrito */}
|
||||
<div id="cart-list">
|
||||
{lines.length > 0 && (
|
||||
<div className="pay-method-select">
|
||||
<p className="second-brown">{t('payMethod')}</p>
|
||||
<div className="pay-method-options">
|
||||
{([
|
||||
{ id: 'balance' as PayMethod, label: t('payBalance'), sub: `${pdTotal} PD · ${vpTotal} PV` },
|
||||
{ id: 'stripe' as PayMethod, label: t('payStripe'), sub: `${eurTotal} €` },
|
||||
{ id: 'sumup' as PayMethod, label: t('paySumUp'), sub: `${eurTotal} €` },
|
||||
]).map((o) => (
|
||||
<label key={o.id} className={`pay-method-option${method === o.id ? ' selected' : ''}`}>
|
||||
<input type="radio" name="store-pay" value={o.id} checked={method === o.id} onChange={() => setMethod(o.id)} />
|
||||
<span className="pay-method-label">{o.label}</span>
|
||||
<span className="pay-method-sub yellow-info">{o.sub}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<table className="max-left-table2">
|
||||
<tbody>
|
||||
<tr>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -364,6 +364,11 @@
|
||||
"title": "Rename guild",
|
||||
"pay": "Rename for {price} €",
|
||||
"success": "The guild has been renamed to \"{name}\". Online members may need to reconnect to see it."
|
||||
},
|
||||
"store": {
|
||||
"title": "Store",
|
||||
"pay": "Buy for {price} €",
|
||||
"success": "Items sent to character {name}! Check your in-game mail."
|
||||
}
|
||||
},
|
||||
"SecurityToken": {
|
||||
@@ -1955,10 +1960,17 @@
|
||||
"deliveryFailed": "The items could not be sent (game server unavailable). Your balance has been refunded.",
|
||||
"invalidCharacter": "Invalid character.",
|
||||
"emptyCart": "Your cart is empty.",
|
||||
"generic": "An error occurred. Please try again."
|
||||
"generic": "An error occurred. Please try again.",
|
||||
"amountTooLow": "The minimum amount to pay by card is {min} €."
|
||||
},
|
||||
"newsTitle": "News",
|
||||
"newsIntro": "List of additions made based on the community's suggestions in our forum.",
|
||||
"newsDate": "November 2024"
|
||||
"newsDate": "November 2024",
|
||||
"payMethod": "Payment method",
|
||||
"payBalance": "PD/VP balance",
|
||||
"payStripe": "Card (Stripe)",
|
||||
"paySumUp": "Card (SumUp)",
|
||||
"confirmCard": "Pay {eur} € by card and send the items to {character}?",
|
||||
"amountTooLow": "The minimum amount to pay by card is {min} €."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +364,11 @@
|
||||
"title": "Renombrar hermandad",
|
||||
"pay": "Renombrar por {price} €",
|
||||
"success": "La hermandad ha sido renombrada a \"{name}\". Puede que los miembros conectados deban reconectar para verlo."
|
||||
},
|
||||
"store": {
|
||||
"title": "Tienda",
|
||||
"pay": "Comprar por {price} €",
|
||||
"success": "¡Objetos enviados al personaje {name}! Revisa el correo dentro del juego."
|
||||
}
|
||||
},
|
||||
"SecurityToken": {
|
||||
@@ -1955,10 +1960,17 @@
|
||||
"deliveryFailed": "No se pudieron enviar los objetos (servidor de juego no disponible). Se te ha devuelto el saldo.",
|
||||
"invalidCharacter": "El personaje no es válido.",
|
||||
"emptyCart": "Tu carrito está vacío.",
|
||||
"generic": "Ha ocurrido un error. Inténtalo de nuevo."
|
||||
"generic": "Ha ocurrido un error. Inténtalo de nuevo.",
|
||||
"amountTooLow": "El importe mínimo para pagar con tarjeta es {min} €."
|
||||
},
|
||||
"newsTitle": "Novedades",
|
||||
"newsIntro": "Lista de adiciones realizadas en base a las sugerencias de la comunidad en nuestro foro.",
|
||||
"newsDate": "Noviembre 2024"
|
||||
"newsDate": "Noviembre 2024",
|
||||
"payMethod": "Forma de pago",
|
||||
"payBalance": "Saldo PD/PV",
|
||||
"payStripe": "Tarjeta (Stripe)",
|
||||
"paySumUp": "Tarjeta (SumUp)",
|
||||
"confirmCard": "¿Pagar {eur} € con tarjeta y enviar los objetos a {character}?",
|
||||
"amountTooLow": "El importe mínimo para pagar con tarjeta es {min} €."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Pedido de tienda pagado con tarjeta (Stripe/SumUp): guarda el carrito para
|
||||
-- entregarlo tras confirmarse el pago (evita el límite de metadata de la pasarela).
|
||||
CREATE TABLE IF NOT EXISTS home_store_order (
|
||||
ref VARCHAR(64) PRIMARY KEY,
|
||||
account_id INT NOT NULL,
|
||||
character_name VARCHAR(12) NOT NULL,
|
||||
items TEXT NOT NULL, -- "itemId:qty,itemId:qty,..."
|
||||
amount DECIMAL(10,2) NOT NULL, -- euros
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY k_acc (account_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
Reference in New Issue
Block a user