Tienda Battlepay: pago de órdenes pendientes vía Stripe

Reutiliza la infraestructura Stripe (sin depender de SumUp/credenciales externas):
- lib/battlepay.ts: getPendingOrders/getPendingOrder/markOrderPaid sobre
  acore_auth.battlepay_orders (PENDING->PAID, el mod-battlepay entrega en juego).
- fulfill.ts: rama battlepay (metadata.battlepay_reference -> markOrderPaid),
  idempotente vía el claim atómico compartido con el webhook.
- API POST /api/battlepay/pay (valida orden PENDING de la cuenta, crea checkout).
- UI: /battlepay (lista + pagar) y /battlepay-success (entrega compartida),
  enlace "Tienda" en la cabecera para usuarios logueados.
- i18n es/en: namespace Battlepay + Nav.store.

Verificado: build OK, /battlepay 307->login, API 401 sin sesión, success 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 01:12:12 +00:00
parent 00c03d84e1
commit f721aac7e5
9 changed files with 268 additions and 2 deletions
@@ -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 (
<main className="mx-auto max-w-lg px-4 py-12 text-center">
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('title')}</h1>
{status === 'paid' ? (
<p className="text-green-400">{t('paidOk')}</p>
) : status === 'already' ? (
<p className="text-green-400">{t('alreadyPaid')}</p>
) : (
<p className="text-red-400">{t('genericError')}</p>
)}
<p className="mt-6">
<Link href="/battlepay" className="text-sky-400 hover:underline">
{t('backToStore')}
</Link>
</p>
</main>
)
}
+26
View File
@@ -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 (
<main className="mx-auto max-w-2xl px-4 py-8">
<h1 className="mb-2 text-2xl font-bold text-amber-500">{t('title')}</h1>
<p className="mb-6 text-nw-muted">{t('intro')}</p>
<BattlepayList orders={orders} />
</main>
)
}
+35
View File
@@ -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<string, string> = {}
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 })
}
+67
View File
@@ -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<string | null>(null)
const [error, setError] = useState<string | null>(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 <p className="text-amber-200/70">{t('noOrders')}</p>
return (
<div>
{error && <p className="mb-4 text-red-400">{error}</p>}
<ul className="space-y-3">
{orders.map((o) => (
<li key={o.id} className="flex flex-wrap items-center justify-between gap-3 nw-card">
<div>
<p className="font-semibold text-amber-300">{o.product_name}</p>
<p className="text-xs text-amber-200/50">
{t('reference')}: {o.reference}
{o.created_at ? ` · ${new Date(o.created_at * 1000).toLocaleDateString()}` : ''}
</p>
</div>
<div className="flex items-center gap-3">
<span className="font-semibold text-nw-gold-light">{o.price_eur.toFixed(2)} </span>
<button
type="button"
onClick={() => pay(o.reference)}
disabled={busy !== null}
className="nw-btn text-sm disabled:opacity-60"
>
{busy === o.reference ? t('redirecting') : t('pay')}
</button>
</div>
</li>
))}
</ul>
</div>
)
}
+3
View File
@@ -29,6 +29,9 @@ export async function Header() {
</Link>
{loggedIn ? (
<>
<Link href="/battlepay" className="text-amber-200 hover:text-amber-400">
{t('store')}
</Link>
<Link href="/account" className="text-amber-200 hover:text-amber-400">
{t('account')}
</Link>
+55
View File
@@ -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<BattlepayOrder[]> {
try {
const [rows] = await db(DB.auth).query<RowDataPacket[]>(
"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<RowDataPacket[]>(
"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<boolean> {
const [res] = await db(DB.auth).query<ResultSetHeader>(
"UPDATE battlepay_orders SET status = 'PAID', paid_at = UNIX_TIMESTAMP() WHERE reference = ? AND status = 'PENDING'",
[reference],
)
return res.affectedRows > 0
}
+7
View File
@@ -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 }
+15 -1
View File
@@ -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."
}
}
+15 -1
View File
@@ -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."
}
}