diff --git a/web-next/app/[locale]/battlepay-success/page.tsx b/web-next/app/[locale]/battlepay-success/page.tsx new file mode 100644 index 0000000..7fbfa00 --- /dev/null +++ b/web-next/app/[locale]/battlepay-success/page.tsx @@ -0,0 +1,45 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { Link } from '@/i18n/navigation' +import { fulfillCheckoutSession } from '@/lib/fulfill' +import { isSessionPaid } from '@/lib/stripe' + +export const dynamic = 'force-dynamic' + +export default async function BattlepaySuccessPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }> + searchParams: Promise<{ session_id?: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const { session_id } = await searchParams + const t = await getTranslations('Battlepay') + + // Entrega compartida con el webhook (marca la orden PAID; idempotente). + let status: 'paid' | 'already' | 'error' = 'error' + if (session_id) { + const r = await fulfillCheckoutSession(session_id) + if (r.ok) status = 'paid' + else if (await isSessionPaid(session_id)) status = 'already' + } + + return ( +
+

{t('title')}

+ {status === 'paid' ? ( +

{t('paidOk')}

+ ) : status === 'already' ? ( +

{t('alreadyPaid')}

+ ) : ( +

{t('genericError')}

+ )} +

+ + ← {t('backToStore')} + +

+
+ ) +} diff --git a/web-next/app/[locale]/battlepay/page.tsx b/web-next/app/[locale]/battlepay/page.tsx new file mode 100644 index 0000000..3a84c67 --- /dev/null +++ b/web-next/app/[locale]/battlepay/page.tsx @@ -0,0 +1,26 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { redirect } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { getPendingOrders } from '@/lib/battlepay' +import { BattlepayList } from '@/components/BattlepayList' + +export const dynamic = 'force-dynamic' + +export default async function BattlepayPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations('Battlepay') + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!session.username) redirect({ href: '/select-account', locale }) + + const orders = await getPendingOrders(session.accountId ?? 0) + + return ( +
+

{t('title')}

+

{t('intro')}

+ +
+ ) +} diff --git a/web-next/app/api/battlepay/pay/route.ts b/web-next/app/api/battlepay/pay/route.ts new file mode 100644 index 0000000..e18642a --- /dev/null +++ b/web-next/app/api/battlepay/pay/route.ts @@ -0,0 +1,35 @@ +import { getSession } from '@/lib/session' +import { getPendingOrder } from '@/lib/battlepay' +import { createCheckoutSession } from '@/lib/stripe' + +export async function POST(request: Request) { + const session = await getSession() + if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + + let b: Record = {} + try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } + const reference = String(b.reference ?? '').trim() + if (!reference) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + + const order = await getPendingOrder(reference, session.accountId) + if (!order) return Response.json({ success: false, error: 'orderNotFound' }) + if (!(order.price_eur > 0)) return Response.json({ success: false, error: 'invalidRequest' }) + + const site = process.env.SITE_URL || '' + const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0' + + const r = await createCheckoutSession({ + accountId: session.accountId, + username: session.username || '', + email: session.bnetEmail || '', + acoreIp: ip, + amount: order.price_eur, + characterName: reference, // en battlepay guardamos la referencia como identificador + productName: order.product_name, + successUrl: `${site}/battlepay-success`, + cancelUrl: `${site}/battlepay`, + metadata: { battlepay_reference: reference }, + }) + if (!r.success || !r.url) return Response.json({ success: false, error: 'stripeError' }) + return Response.json({ success: true, url: r.url }) +} diff --git a/web-next/components/BattlepayList.tsx b/web-next/components/BattlepayList.tsx new file mode 100644 index 0000000..df4057b --- /dev/null +++ b/web-next/components/BattlepayList.tsx @@ -0,0 +1,67 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import type { BattlepayOrder } from '@/lib/battlepay' + +export function BattlepayList({ orders }: { orders: BattlepayOrder[] }) { + const t = useTranslations('Battlepay') + const [busy, setBusy] = useState(null) + const [error, setError] = useState(null) + + async function pay(reference: string) { + if (busy) return + setBusy(reference) + setError(null) + try { + const res = await fetch('/api/battlepay/pay', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ reference }), + }) + const d: { success?: boolean; url?: string; error?: string } = await res.json() + if (d.success && d.url) { + window.location.href = d.url + } else { + setError(t(d.error === 'orderNotFound' ? 'orderNotFound' : 'genericError')) + setBusy(null) + } + } catch { + setError(t('genericError')) + setBusy(null) + } + } + + if (orders.length === 0) return

{t('noOrders')}

+ + return ( +
+ {error &&

{error}

} +
    + {orders.map((o) => ( +
  • +
    +

    {o.product_name}

    +

    + {t('reference')}: {o.reference} + {o.created_at ? ` · ${new Date(o.created_at * 1000).toLocaleDateString()}` : ''} +

    +
    +
    + {o.price_eur.toFixed(2)} € + +
    +
  • + ))} +
+
+ ) +} diff --git a/web-next/components/Header.tsx b/web-next/components/Header.tsx index f4ad221..789944d 100644 --- a/web-next/components/Header.tsx +++ b/web-next/components/Header.tsx @@ -29,6 +29,9 @@ export async function Header() { {loggedIn ? ( <> + + {t('store')} + {t('account')} diff --git a/web-next/lib/battlepay.ts b/web-next/lib/battlepay.ts new file mode 100644 index 0000000..a29c9ea --- /dev/null +++ b/web-next/lib/battlepay.ts @@ -0,0 +1,55 @@ +import type { RowDataPacket, ResultSetHeader } from 'mysql2' +import { db, DB } from './db' + +export interface BattlepayOrder { + id: number + reference: string + product_id: number + product_name: string + price_eur: number + status: string + created_at: number | null +} + +/** Órdenes Battlepay PENDING de una cuenta de juego. */ +export async function getPendingOrders(accountId: number): Promise { + try { + const [rows] = await db(DB.auth).query( + "SELECT id, reference, product_id, product_name, price_eur, status, created_at " + + "FROM battlepay_orders WHERE account_id = ? AND status = 'PENDING' ORDER BY created_at DESC", + [accountId], + ) + return rows.map((r) => ({ + id: r.id, + reference: r.reference, + product_id: r.product_id, + product_name: r.product_name, + price_eur: Number(r.price_eur), + status: r.status, + created_at: r.created_at ?? null, + })) + } catch { + return [] + } +} + +/** Orden PENDING concreta que pertenece a la cuenta (para crear el pago). */ +export async function getPendingOrder( + reference: string, + accountId: number, +): Promise<{ product_name: string; price_eur: number } | null> { + const [rows] = await db(DB.auth).query( + "SELECT product_name, price_eur FROM battlepay_orders WHERE reference = ? AND account_id = ? AND status = 'PENDING'", + [reference, accountId], + ) + return rows[0] ? { product_name: rows[0].product_name, price_eur: Number(rows[0].price_eur) } : null +} + +/** Marca una orden como PAID solo si estaba PENDING. Devuelve true si cambió algo. */ +export async function markOrderPaid(reference: string): Promise { + const [res] = await db(DB.auth).query( + "UPDATE battlepay_orders SET status = 'PAID', paid_at = UNIX_TIMESTAMP() WHERE reference = ? AND status = 'PENDING'", + [reference], + ) + return res.affectedRows > 0 +} diff --git a/web-next/lib/fulfill.ts b/web-next/lib/fulfill.ts index bf46f27..3d8fdec 100644 --- a/web-next/lib/fulfill.ts +++ b/web-next/lib/fulfill.ts @@ -1,5 +1,6 @@ import { claimPaidCheckout } from './stripe' import { getPaidService } from './paid-services' +import { markOrderPaid } from './battlepay' export interface FulfillResult { ok: boolean @@ -22,6 +23,12 @@ export async function fulfillCheckoutSession( const claim = await claimPaidCheckout(sessionId) if (!claim) return { ok: false, service: fallbackService ?? null, character: null } + // Rama Battlepay: marca la orden como PAID (el mod-battlepay la entrega en juego). + if (claim.metadata.battlepay_reference) { + const ok = await markOrderPaid(claim.metadata.battlepay_reference) + return { ok, service: 'battlepay', character: null } + } + const service = claim.metadata.service || fallbackService || '' const cfg = service ? getPaidService(service) : null if (!cfg) return { ok: false, service: service || null, character: claim.characterName } diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 3fd4702..0f31b88 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -12,7 +12,8 @@ "language": "Language", "forum": "Forums", "vote": "Vote", - "recruit": "Recruit" + "recruit": "Recruit", + "store": "Store" }, "Home": { "brand": "Nova WoW", @@ -422,5 +423,18 @@ "linkText": "link text", "quoteText": "quote", "listItem": "item" + }, + "Battlepay": { + "title": "Store (Battlepay)", + "intro": "Pay your pending in-game store purchases here. They are delivered in-game automatically.", + "noOrders": "You have no pending purchases.", + "reference": "Reference", + "pay": "Pay", + "redirecting": "Redirecting…", + "paidOk": "Payment confirmed! Your purchase will be delivered in-game.", + "alreadyPaid": "This purchase was already paid.", + "backToStore": "Back to the store", + "orderNotFound": "Order not found or already processed.", + "genericError": "An error occurred. Please try again." } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index d657ac8..562b1b5 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -12,7 +12,8 @@ "language": "Idioma", "forum": "Foros", "vote": "Votar", - "recruit": "Reclutar" + "recruit": "Reclutar", + "store": "Tienda" }, "Home": { "brand": "Nova WoW", @@ -422,5 +423,18 @@ "linkText": "texto del enlace", "quoteText": "cita", "listItem": "elemento" + }, + "Battlepay": { + "title": "Tienda (Battlepay)", + "intro": "Paga aquí tus compras pendientes de la tienda del juego. Se entregan en el juego automáticamente.", + "noOrders": "No tienes compras pendientes.", + "reference": "Referencia", + "pay": "Pagar", + "redirecting": "Redirigiendo…", + "paidOk": "¡Pago confirmado! Tu compra se entregará en el juego.", + "alreadyPaid": "Esta compra ya estaba pagada.", + "backToStore": "Volver a la tienda", + "orderNotFound": "Orden no encontrada o ya procesada.", + "genericError": "Ha ocurrido un error. Inténtalo de nuevo." } }