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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user