Servicios de personaje de pago (Stripe): patrón + rename

- lib/stripe.ts: createCheckoutSession (crea sesión Stripe + StripeLog en
  home_stripelog, success_url con {CHECKOUT_SESSION_ID}), isSessionPaid,
  claimPaidCheckout (verifica pagado + no entregado, marca fulfilled -> evita
  re-entregas; port de require_paid_stripe).
- lib/prices.ts: precios desde home_*price (rename/customize/race/faction/level).
- components/PaidServiceForm.tsx: selector + botón de pago -> redirige a la URL de
  Checkout de Stripe (session.url, sin stripe.js).
- Rename: route /api/character/rename/checkout (guard + valida personaje + crea
  checkout), página /rename (PaidServiceForm) y /rename/success (verifica pago y
  ejecuta SOAP .char rename, i18n). STRIPE_* + NEXT_PUBLIC_STRIPE_PUBLIC_KEY en
  .env.local. Namespace Rename.

Verificado: checkout sin sesión 401, /rename redirige a login, /rename/success con
session_id inválido no entrega el servicio (muestra error). Patrón para los demás
servicios de pago (cambiar precio + comando SOAP).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:21:30 +00:00
parent 96e996e9c2
commit e7cff56954
10 changed files with 323 additions and 2 deletions
+31
View File
@@ -0,0 +1,31 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getRenamePrice } from '@/lib/prices'
import { PaidServiceForm } from '@/components/PaidServiceForm'
export const dynamic = 'force-dynamic'
export default async function RenamePage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('Rename')
const session = await getSession()
if (!session.bnetId) redirect({ href: '/login', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getRenamePrice()])
return (
<main className="mx-auto max-w-3xl px-4 py-8">
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('title')}</h1>
<PaidServiceForm
characters={chars.map((c) => c.name)}
checkoutEndpoint="/api/character/rename/checkout"
payLabel={t('pay', { price })}
/>
</main>
)
}
@@ -0,0 +1,39 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { claimPaidCheckout } from '@/lib/stripe'
import { executeSoapCommand } from '@/lib/soap'
export const dynamic = 'force-dynamic'
export default async function RenameSuccessPage({
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('Rename')
// Verifica el pago (una sola vez) y ejecuta el comando SOAP.
let okName: string | null = null
if (session_id) {
const claim = await claimPaidCheckout(session_id)
if (claim) {
const resp = await executeSoapCommand(`.char rename ${claim.characterName}`)
if (resp !== null) okName = claim.characterName
}
}
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('successTitle')}</h1>
{okName ? (
<p className="text-green-400">{t('success', { name: okName })}</p>
) : (
<p className="text-red-400">{t('error')}</p>
)}
</main>
)
}
@@ -0,0 +1,41 @@
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getRenamePrice } from '@/lib/prices'
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 character = ''
try {
const body = await request.json()
character = String(body.character ?? '')
} catch {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
if (!character) return Response.json({ success: false, error: 'invalidCharacter' })
const chars = await getGameCharacters(session.accountId)
if (!chars.find((c) => c.name === character)) {
return Response.json({ success: false, error: 'invalidCharacter' })
}
const price = await getRenamePrice()
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: price,
characterName: character,
productName: `Renombrar personaje: ${character}`,
successUrl: `${site}/rename/success`,
cancelUrl: `${site}/rename`,
})
if (!r.success || !r.url) return Response.json({ success: false, error: 'stripeError' })
return Response.json({ success: true, url: r.url })
}