diff --git a/web-next/app/api/store/send/route.ts b/web-next/app/api/store/send/route.ts index d70f0f1..a25ea76 100644 --- a/web-next/app/api/store/send/route.ts +++ b/web-next/app/api/store/send/route.ts @@ -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 }) diff --git a/web-next/components/StoreBrowser.tsx b/web-next/components/StoreBrowser.tsx index d6235de..1c4e2fb 100644 --- a/web-next/components/StoreBrowser.tsx +++ b/web-next/components/StoreBrowser.tsx @@ -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(null) const [loading, setLoading] = useState(false) const [cart, setCart] = useState>(new Map()) + const [method, setMethod] = useState('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 */}
+ {lines.length > 0 && ( +
+

{t('payMethod')}

+
+ {([ + { 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) => ( + + ))} +
+
+ )} diff --git a/web-next/lib/paid-services.ts b/web-next/lib/paid-services.ts index aedd8a6..64b919d 100644 --- a/web-next/lib/paid-services.ts +++ b/web-next/lib/paid-services.ts @@ -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 = { 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: { diff --git a/web-next/lib/store.ts b/web-next/lib/store.ts index 782f79a..6310eb3 100644 --- a/web-next/lib/store.ts +++ b/web-next/lib/store.ts @@ -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 { + 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 { + if (!ref) return false + let rows: RowDataPacket[] = [] + try { + ;[rows] = await db(DB.default).query( + '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 { if (!SAFE_NAME.test(character)) return false diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 8bf6b01..a0d0aab 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -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} €." } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 4f3b139..e38c60d 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -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} €." } } diff --git a/web-next/sql/home_store_order.sql b/web-next/sql/home_store_order.sql new file mode 100644 index 0000000..080d55b --- /dev/null +++ b/web-next/sql/home_store_order.sql @@ -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;