From e7cff5695443ba3a5d6d96d5f1d7553eaa790ccd Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 23:21:30 +0000 Subject: [PATCH] =?UTF-8?q?Servicios=20de=20personaje=20de=20pago=20(Strip?= =?UTF-8?q?e):=20patr=C3=B3n=20+=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- web-next/app/[locale]/rename/page.tsx | 31 +++++++ web-next/app/[locale]/rename/success/page.tsx | 39 +++++++++ .../api/character/rename/checkout/route.ts | 41 ++++++++++ web-next/components/PaidServiceForm.tsx | 77 ++++++++++++++++++ web-next/lib/prices.ts | 17 ++++ web-next/lib/stripe.ts | 80 +++++++++++++++++++ web-next/messages/en.json | 9 +++ web-next/messages/es.json | 9 +++ web-next/package-lock.json | 19 ++++- web-next/package.json | 3 +- 10 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 web-next/app/[locale]/rename/page.tsx create mode 100644 web-next/app/[locale]/rename/success/page.tsx create mode 100644 web-next/app/api/character/rename/checkout/route.ts create mode 100644 web-next/components/PaidServiceForm.tsx create mode 100644 web-next/lib/prices.ts create mode 100644 web-next/lib/stripe.ts diff --git a/web-next/app/[locale]/rename/page.tsx b/web-next/app/[locale]/rename/page.tsx new file mode 100644 index 0000000..bc82548 --- /dev/null +++ b/web-next/app/[locale]/rename/page.tsx @@ -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 ( +
+

{t('title')}

+ c.name)} + checkoutEndpoint="/api/character/rename/checkout" + payLabel={t('pay', { price })} + /> +
+ ) +} diff --git a/web-next/app/[locale]/rename/success/page.tsx b/web-next/app/[locale]/rename/success/page.tsx new file mode 100644 index 0000000..a1d82be --- /dev/null +++ b/web-next/app/[locale]/rename/success/page.tsx @@ -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 ( +
+

{t('successTitle')}

+ {okName ? ( +

{t('success', { name: okName })}

+ ) : ( +

{t('error')}

+ )} +
+ ) +} diff --git a/web-next/app/api/character/rename/checkout/route.ts b/web-next/app/api/character/rename/checkout/route.ts new file mode 100644 index 0000000..5a1fe65 --- /dev/null +++ b/web-next/app/api/character/rename/checkout/route.ts @@ -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 }) +} diff --git a/web-next/components/PaidServiceForm.tsx b/web-next/components/PaidServiceForm.tsx new file mode 100644 index 0000000..fa1fdf3 --- /dev/null +++ b/web-next/components/PaidServiceForm.tsx @@ -0,0 +1,77 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' + +interface Props { + characters: string[] + checkoutEndpoint: string + payLabel: string // ya formateado con el precio +} + +/** Selector de personaje + botón de pago. Redirige al Checkout de Stripe. */ +export function PaidServiceForm({ characters, checkoutEndpoint, payLabel }: Props) { + const t = useTranslations('Services') + const [character, setCharacter] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (busy || !character) return + setBusy(true) + setError(null) + try { + const res = await fetch(checkoutEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ character }), + }) + const data: { success?: boolean; url?: string } = await res.json() + if (data.success && data.url) { + window.location.href = data.url // Checkout de Stripe + } else { + setError(t('genericError')) + setBusy(false) + } + } catch { + setError(t('genericError')) + setBusy(false) + } + } + + if (characters.length === 0) { + return

{t('noCharactersYet')}

+ } + + return ( +
+
+ + +
+ {error &&

{error}

} +
+ ) +} diff --git a/web-next/lib/prices.ts b/web-next/lib/prices.ts new file mode 100644 index 0000000..641c557 --- /dev/null +++ b/web-next/lib/prices.ts @@ -0,0 +1,17 @@ +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' + +async function priceFrom(table: string, fallback: number): Promise { + try { + const [rows] = await db(DB.default).query(`SELECT price FROM ${table} LIMIT 1`) + return rows[0] ? Number(rows[0].price) : fallback + } catch { + return fallback + } +} + +export const getRenamePrice = () => priceFrom('home_renameprice', 2.0) +export const getCustomizePrice = () => priceFrom('home_customizeprice', 1.0) +export const getChangeRacePrice = () => priceFrom('home_changeraceprice', 10.0) +export const getChangeFactionPrice = () => priceFrom('home_changefactionprice', 10.0) +export const getLevelUpPrice = () => priceFrom('home_levelupprice', 10.0) diff --git a/web-next/lib/stripe.ts b/web-next/lib/stripe.ts new file mode 100644 index 0000000..442e099 --- /dev/null +++ b/web-next/lib/stripe.ts @@ -0,0 +1,80 @@ +import Stripe from 'stripe' +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string) + +export async function isSessionPaid(sessionId: string): Promise { + if (!sessionId) return false + try { + const s = await stripe.checkout.sessions.retrieve(sessionId) + return s.payment_status === 'paid' + } catch { + return false + } +} + +export interface CheckoutParams { + accountId: number + username: string + email: string + acoreIp: string + amount: number + characterName: string + productName: string + successUrl: string + cancelUrl: string + currency?: string +} + +/** Crea una sesión de pago Stripe y registra el StripeLog. Devuelve la URL de checkout. */ +export async function createCheckoutSession( + p: CheckoutParams, +): Promise<{ success: boolean; url?: string; error?: string }> { + try { + const sep = p.successUrl.includes('?') ? '&' : '?' + const successUrl = `${p.successUrl}${sep}session_id={CHECKOUT_SESSION_ID}` + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: p.currency || 'eur', + product_data: { name: p.productName }, + unit_amount: Math.round(p.amount * 100), + }, + quantity: 1, + }, + ], + mode: 'payment', + success_url: successUrl, + cancel_url: p.cancelUrl, + }) + const mode = (process.env.STRIPE_SECRET_KEY || '').startsWith('sk_live') ? 'live' : 'test' + await db(DB.default).query( + 'INSERT INTO home_stripelog (account_id, username, email, acore_ip, stripe_ip, product_name, amount, session_id, mode, character_name, timestamp, fulfilled) ' + + 'VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, NOW(), 0)', + [p.accountId, p.username, p.email, p.acoreIp, p.productName, p.amount, session.id, mode, p.characterName], + ) + return { success: true, url: session.url ?? undefined } + } catch (e) { + return { success: false, error: String(e) } + } +} + +/** + * Verifica que la sesión está pagada y aún no entregada; la marca como entregada + * (evita re-entregas). Devuelve el character_name del log o null. (Port de + * require_paid_stripe.) + */ +export async function claimPaidCheckout(sessionId: string): Promise<{ characterName: string } | null> { + if (!(await isSessionPaid(sessionId))) return null + const [rows] = await db(DB.default).query( + 'SELECT id, character_name, fulfilled FROM home_stripelog WHERE session_id = ?', + [sessionId], + ) + const log = rows[0] + if (!log || log.fulfilled) return null + await db(DB.default).query('UPDATE home_stripelog SET fulfilled = 1 WHERE id = ?', [log.id]) + return { characterName: log.character_name } +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index b19c786..4404216 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -135,5 +135,14 @@ "cooldown": "You can only do this once every 12 hours.", "soapError": "Server communication error. Please try again later.", "genericError": "Something went wrong. Please try again later." + }, + "Rename": { + "title": "Rename character", + "pay": "Rename for {price} €", + "successTitle": "Rename character", + "success": "Character {name} has been flagged for rename. The change will apply on next login.", + "error": "The operation could not be completed. If you were charged, contact support.", + "invalidCharacter": "Select a valid character.", + "stripeError": "Could not start the payment. Please try again later." } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index ed0ace3..7825f9b 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -135,5 +135,14 @@ "cooldown": "Solo puedes hacerlo una vez cada 12 horas.", "soapError": "Error de comunicación con el servidor. Inténtalo más tarde.", "genericError": "Algo ha salido mal. Inténtalo más tarde." + }, + "Rename": { + "title": "Renombrar personaje", + "pay": "Renombrar por {price} €", + "successTitle": "Renombrar personaje", + "success": "El personaje {name} ha sido marcado para renombrar. El cambio se aplicará en el próximo inicio de sesión.", + "error": "No se pudo completar la operación. Si se te ha cobrado, contacta con soporte.", + "invalidCharacter": "Selecciona un personaje válido.", + "stripeError": "No se pudo iniciar el pago. Inténtalo más tarde." } } diff --git a/web-next/package-lock.json b/web-next/package-lock.json index 5461101..ae83199 100644 --- a/web-next/package-lock.json +++ b/web-next/package-lock.json @@ -14,7 +14,8 @@ "next-intl": "^4.13.2", "nodemailer": "^9.0.3", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "stripe": "^22.3.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.2", @@ -7008,6 +7009,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "22.3.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.1.tgz", + "integrity": "sha512-6XSLN0KK0IdfXqDHqMg6CDoO3eO62ye9dhzw0fZp55AUOzHR1YRJOqYHTsuCi4QJ4Ni/usEmXtq69c9ghKDmYw==", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", diff --git a/web-next/package.json b/web-next/package.json index 803f233..8041da4 100644 --- a/web-next/package.json +++ b/web-next/package.json @@ -15,7 +15,8 @@ "next-intl": "^4.13.2", "nodemailer": "^9.0.3", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "stripe": "^22.3.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.2",