Añade gold y transfer: patrón de pago unificado con fulfill + metadata

- lib/stripe.ts: createCheckoutSession acepta metadata; claimPaidCheckout la
  recupera de la sesión Stripe y la devuelve.
- lib/paid-services.ts: config unificado con fulfill(character, meta) + extraFields.
  gold -> UPDATE characters money (BD directa, no SOAP), transfer -> SOAP
  .char changeaccount. Los 5 simples usan soapFulfill.
- checkout [service]: lee extraFields -> metadata, precio depende de metadata (gold),
  valida precio>0. service-success llama cfg.fulfill(name, metadata).
- prices.ts: getTransferPrice, getGoldOptions/goldPriceFor (home_goldprice).
- GoldForm (personaje + cantidad) y TransferForm (personaje + destino); páginas
  /gold y /transfer. Catálogos Paid.gold/transfer.

Verificado: /gold /transfer redirigen a login, checkout 401 sin sesión, home OK.
NOTA: transfer no valida aún el token de seguridad ni reglas AC (nivel>=55, DK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:32:12 +00:00
parent 87f9b0b003
commit 548fca16e6
12 changed files with 310 additions and 29 deletions
+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 { getGameCharacters } from '@/lib/characters'
import { getGoldOptions } from '@/lib/prices'
import { GoldForm } from '@/components/GoldForm'
export const dynamic = 'force-dynamic'
export default async function GoldPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('Paid')
const session = await getSession()
if (!session.bnetId) redirect({ href: '/login', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, options] = await Promise.all([getGameCharacters(session.accountId!), getGoldOptions()])
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('gold.title')}</h1>
<GoldForm characters={chars.map((c) => c.name)} options={options} />
</main>
)
}
@@ -1,6 +1,5 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { claimPaidCheckout } from '@/lib/stripe'
import { executeSoapCommand } from '@/lib/soap'
import { getPaidService } from '@/lib/paid-services'
export const dynamic = 'force-dynamic'
@@ -23,8 +22,8 @@ export default async function ServiceSuccessPage({
if (cfg && session_id) {
const claim = await claimPaidCheckout(session_id)
if (claim) {
const resp = await executeSoapCommand(cfg.command(claim.characterName))
if (resp !== null) okName = claim.characterName
const ok = await cfg.fulfill(claim.characterName, claim.metadata)
if (ok) okName = claim.characterName
}
}
+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 { getGameCharacters } from '@/lib/characters'
import { getTransferPrice } from '@/lib/prices'
import { TransferForm } from '@/components/TransferForm'
export const dynamic = 'force-dynamic'
export default async function TransferPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('Paid')
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!), getTransferPrice()])
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('transfer.title')}</h1>
<TransferForm characters={chars.map((c) => c.name)} price={price} />
</main>
)
}
@@ -11,13 +11,13 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser
const session = await getSession()
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
let character = ''
let body: Record<string, string> = {}
try {
const body = await request.json()
character = String(body.character ?? '')
body = await request.json()
} catch {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
const character = String(body.character ?? '')
if (!character) return Response.json({ success: false, error: 'invalidCharacter' })
const chars = await getGameCharacters(session.accountId)
@@ -25,7 +25,17 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser
return Response.json({ success: false, error: 'invalidCharacter' })
}
const price = await cfg.getPrice()
// Campos extra del servicio (gold_amount, destination_account...) -> metadata
const metadata: Record<string, string> = {}
for (const f of cfg.extraFields) {
const v = String(body[f] ?? '').trim()
if (!v) return Response.json({ success: false, error: 'missingFields' })
metadata[f] = v
}
const price = await cfg.price(metadata)
if (!price || price <= 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'
@@ -36,9 +46,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser
acoreIp: ip,
amount: price,
characterName: character,
productName: cfg.productName(character),
productName: cfg.productName(character, metadata),
successUrl: `${site}/service-success?service=${service}`,
cancelUrl: `${site}/${service}`,
metadata,
})
if (!r.success || !r.url) return Response.json({ success: false, error: 'stripeError' })
return Response.json({ success: true, url: r.url })