From 20ee5a83c8376710c8f912c6a37cf3da8a00520e Mon Sep 17 00:00:00 2001 From: adevopg Date: Tue, 14 Jul 2026 21:56:44 +0000 Subject: [PATCH] rename-guild: permitir pagar con PD, Stripe o SumUp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renombrar hermandad ya no es solo PD: ahora ofrece el mismo selector de forma de pago que el resto de servicios (saldo PD / tarjeta Stripe / tarjeta SumUp). El coste es equivalente: 1000 PD = 10 €. - lib/guild: se separa la validación (checkGuildRenameEligibility) del renombrado por SOAP (renameGuildBySoap); nuevo GUILD_RENAME_EUR (10 €). - paid-services: entrada 'rename-guild' para que la reconciliación/webhook entregue el servicio tras el pago con tarjeta. - nueva ruta /api/guild/rename/checkout con las 3 pasarelas (valida elegibilidad antes de cobrar); se elimina la antigua /api/guild/rename. - RenameGuildForm usa PaymentMethodSelect y redirige a service-success. - i18n: Paid.rename-guild (title/success) para la página de éxito. Co-Authored-By: Claude Opus 4.8 (1M context) --- web-next/app/[locale]/rename-guild/page.tsx | 10 +- .../app/api/guild/rename/checkout/route.ts | 93 +++++++++++++++++++ web-next/app/api/guild/rename/route.ts | 25 ----- web-next/components/RenameGuildForm.tsx | 50 +++++----- web-next/lib/guild.ts | 84 ++++++++--------- web-next/lib/paid-services.ts | 9 ++ web-next/messages/en.json | 5 + web-next/messages/es.json | 5 + 8 files changed, 189 insertions(+), 92 deletions(-) create mode 100644 web-next/app/api/guild/rename/checkout/route.ts delete mode 100644 web-next/app/api/guild/rename/route.ts diff --git a/web-next/app/[locale]/rename-guild/page.tsx b/web-next/app/[locale]/rename-guild/page.tsx index bf0ba41..a40279a 100644 --- a/web-next/app/[locale]/rename-guild/page.tsx +++ b/web-next/app/[locale]/rename-guild/page.tsx @@ -2,7 +2,8 @@ import type { Metadata } from 'next' import { getTranslations, setRequestLocale } from 'next-intl/server' import { redirect } from '@/i18n/navigation' import { getSession } from '@/lib/session' -import { getGuildMasterGuilds, GUILD_RENAME_PD } from '@/lib/guild' +import { getGuildMasterGuilds, GUILD_RENAME_PD, GUILD_RENAME_EUR } from '@/lib/guild' +import { getDPointsBalance } from '@/lib/dpoints' import { PageShell } from '@/components/PageShell' import { ServiceBox } from '@/components/ServiceBox' import { RenameGuildForm } from '@/components/RenameGuildForm' @@ -24,7 +25,10 @@ export default async function RenameGuildPage({ params }: { params: Promise<{ lo if (!session.bnetId) redirect({ href: '/log-in', locale }) if (!session.username) redirect({ href: '/select-account', locale }) - const guilds = await getGuildMasterGuilds(session.accountId!) + const [guilds, pdBalance] = await Promise.all([ + getGuildMasterGuilds(session.accountId!), + getDPointsBalance(session.accountId!), + ]) return ( @@ -67,7 +71,7 @@ export default async function RenameGuildPage({ params }: { params: Promise<{ lo
- +
) diff --git a/web-next/app/api/guild/rename/checkout/route.ts b/web-next/app/api/guild/rename/checkout/route.ts new file mode 100644 index 0000000..94eab32 --- /dev/null +++ b/web-next/app/api/guild/rename/checkout/route.ts @@ -0,0 +1,93 @@ +import { randomUUID } from 'crypto' +import { getSession } from '@/lib/session' +import { createCheckoutSession } from '@/lib/stripe' +import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup' +import { getPaidService } from '@/lib/paid-services' +import { payServiceWithDPoints } from '@/lib/pay-with-dpoints' +import { checkGuildRenameEligibility, GUILD_RENAME_EUR } from '@/lib/guild' + +function safeLocale(v: unknown): string { + return v === 'en' ? 'en' : 'es' +} + +/** + * Inicia el renombrado de hermandad con la forma de pago elegida (PD / Stripe / + * SumUp). Valida la elegibilidad (Maestro, token, nombre libre) antes de cobrar. + * Con PD, ejecuta el renombrado al momento; con Stripe/SumUp, lo hace la + * reconciliación/webhook vía PAID_SERVICES['rename-guild']. + */ +export async function POST(request: Request) { + const cfg = getPaidService('rename-guild')! + + const session = await getSession() + if (!session.accountId || !session.username) { + return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + } + + let body: Record = {} + try { + body = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + + const guildId = Number(body.guildId) + const newName = String(body.newName ?? '').trim() + const token = String(body.token ?? '').trim() + + const elig = await checkGuildRenameEligibility({ accountId: session.accountId, guildId, newName, token }) + if (!elig.ok) return Response.json({ success: false, error: elig.error }) + + const metadata: Record = { service: 'rename-guild', guildId: String(guildId), newName } + const price = GUILD_RENAME_EUR + const site = process.env.SITE_URL || '' + const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0' + const productName = cfg.productName(newName, metadata) + + // Pago con saldo PD: descuenta el saldo y renombra al momento. + if (String(body.provider) === 'pd') { + const r = await payServiceWithDPoints(session.accountId, price, () => cfg.fulfill(newName, metadata)) + if (!r.success) return Response.json({ success: false, error: r.error }) + const locale = safeLocale(body.locale) + return Response.json({ + success: true, + url: `${site}/${locale}/service-success?service=rename-guild&provider=pd&name=${encodeURIComponent(newName)}`, + }) + } + + if (String(body.provider) === 'sumup') { + if (!sumupConfigured()) return Response.json({ success: false, error: 'notConfigured' }) + const reference = randomUUID() + const r = await createSumUpCheckout({ + accountId: session.accountId, + username: session.username || '', + email: session.bnetEmail || '', + acoreIp: ip, + amount: price, + points: 0, + reference, + description: productName, + returnUrl: `${site}/service-success?service=rename-guild&provider=sumup&ref=${reference}`, + service: 'rename-guild', + characterName: newName, + metadata, + }) + if (!r.success || !r.url) return Response.json({ success: false, error: r.error || 'sumupError' }) + return Response.json({ success: true, url: r.url }) + } + + const r = await createCheckoutSession({ + accountId: session.accountId, + username: session.username || '', + email: session.bnetEmail || '', + acoreIp: ip, + amount: price, + characterName: newName, + productName, + successUrl: `${site}/service-success?service=rename-guild`, + cancelUrl: `${site}/rename-guild`, + metadata, + }) + 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/app/api/guild/rename/route.ts b/web-next/app/api/guild/rename/route.ts deleted file mode 100644 index fd7e677..0000000 --- a/web-next/app/api/guild/rename/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getSession } from '@/lib/session' -import { renameGuild } from '@/lib/guild' - -/** Renombra una hermandad de la que la cuenta en sesión es Maestro (cuesta 1000 PD). */ -export async function POST(request: Request) { - const session = await getSession() - if (!session.accountId || !session.username) { - return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) - } - - let body: Record = {} - try { - body = await request.json() - } catch { - return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) - } - - const result = await renameGuild({ - accountId: session.accountId, - guildId: Number(body.guildId), - newName: String(body.newName ?? ''), - token: String(body.token ?? '').trim(), - }) - return Response.json(result) -} diff --git a/web-next/components/RenameGuildForm.tsx b/web-next/components/RenameGuildForm.tsx index 26fab7b..02695f1 100644 --- a/web-next/components/RenameGuildForm.tsx +++ b/web-next/components/RenameGuildForm.tsx @@ -1,9 +1,9 @@ 'use client' import { useState } from 'react' -import { useTranslations } from 'next-intl' -import { useRouter } from '@/i18n/navigation' +import { useTranslations, useLocale } from 'next-intl' import type { GmGuild } from '@/lib/guild' +import { PaymentMethodSelect, pdCostOf, type PayMethod } from '@/components/PaymentMethodSelect' function renameErrorKey(error?: string): string { switch (error) { @@ -12,25 +12,29 @@ function renameErrorKey(error?: string): string { case 'invalidToken': case 'notGuildMaster': case 'nameTaken': - case 'insufficientPoints': case 'soapError': case 'notAuthenticated': return error + case 'insufficientPd': // pago con PD sin saldo -> mensaje específico de hermandad + return 'insufficientPoints' + case 'fulfillFailed': // el renombrado por SOAP no se aplicó (se reembolsó) + return 'soapError' default: return 'generic' } } -export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) { +export function RenameGuildForm({ guilds, price, pdBalance }: { guilds: GmGuild[]; price: number; pdBalance: number }) { const t = useTranslations('Points') + const tpay = useTranslations('Pay') + const locale = useLocale() const renameError = (error?: string) => t(`renameGuild.errors.${renameErrorKey(error)}`) - const router = useRouter() const [guildId, setGuildId] = useState('') const [newName, setNewName] = useState('') const [token, setToken] = useState('') const [showToken, setShowToken] = useState(false) + const [method, setMethod] = useState(pdBalance >= pdCostOf(price) ? 'pd' : 'sumup') const [busy, setBusy] = useState(false) - const [done, setDone] = useState(false) const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) if (guilds.length === 0) { @@ -45,35 +49,32 @@ export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) { async function handleSubmit(e: React.FormEvent) { e.preventDefault() - if (busy || done) return + if (busy) return if (!guildId || !newName.trim() || !token.trim()) { setMessage({ ok: false, text: renameError('missingFields') }) return } - if (!window.confirm(t('renameGuild.confirm'))) return + const amount = method === 'pd' ? tpay('pdCost', { cost: pdCostOf(price) }) : tpay('eur', { price }) + if (!window.confirm(tpay('confirm', { amount }))) return setBusy(true) setMessage(null) try { - const res = await fetch('/api/guild/rename', { + const res = await fetch('/api/guild/rename/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', - body: JSON.stringify({ guildId: Number(guildId), newName: newName.trim(), token: token.trim() }), + body: JSON.stringify({ guildId: Number(guildId), newName: newName.trim(), token: token.trim(), provider: method, locale }), }) - const data: { success?: boolean; error?: string; newName?: string } = await res.json() - if (data.success) { - setDone(true) - setMessage({ ok: true, text: t('renameGuild.success', { newName: data.newName ?? '' }) }) - setNewName('') - setToken('') - setTimeout(() => router.refresh(), 3000) - } else { - setMessage({ ok: false, text: renameError(data.error) }) + const data: { success?: boolean; url?: string; error?: string } = await res.json() + if (data.success && data.url) { + window.location.href = data.url // éxito PD, o Checkout (Stripe/SumUp) + return } + setMessage({ ok: false, text: renameError(data.error) }) + setBusy(false) } catch { setMessage({ ok: false, text: renameError() }) - } finally { setBusy(false) } } @@ -127,8 +128,13 @@ export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) { - diff --git a/web-next/lib/guild.ts b/web-next/lib/guild.ts index aa5e6fa..8d4a4a2 100644 --- a/web-next/lib/guild.ts +++ b/web-next/lib/guild.ts @@ -1,4 +1,4 @@ -import type { RowDataPacket, ResultSetHeader } from 'mysql2' +import type { RowDataPacket } from 'mysql2' import { db, DB } from './db' import { checkSecurityToken } from './security-token' import { executeSoapCommand } from './soap' @@ -10,6 +10,8 @@ import { executeSoapCommand } from './soap' */ export const GUILD_RENAME_PD = 1000 +/** Precio equivalente en euros (100 PD = 1 €) para pagar con Stripe/SumUp. */ +export const GUILD_RENAME_EUR = GUILD_RENAME_PD / 100 /** Nombre de hermandad: A-Za-z y espacio, máx. 24 (regla del juego / diseño). */ const NAME_RE = /^[A-Za-z ]{1,24}$/ @@ -37,32 +39,30 @@ export async function getGuildMasterGuilds(accountId: number): Promise { +}): Promise<{ ok: boolean; error?: string; oldName?: string }> { const { accountId, guildId, token } = params const newName = (params.newName || '').trim() - if (!accountId || !guildId || !newName || !token) return { success: false, error: 'missingFields' } - if (!NAME_RE.test(newName)) return { success: false, error: 'invalidName' } - if (!(await checkSecurityToken(accountId, token))) return { success: false, error: 'invalidToken' } + if (!accountId || !guildId || !newName || !token) return { ok: false, error: 'missingFields' } + if (!NAME_RE.test(newName)) return { ok: false, error: 'invalidName' } + if (!(await checkSecurityToken(accountId, token))) return { ok: false, error: 'invalidToken' } // La hermandad debe ser una de las que la cuenta dirige (Maestro). const guilds = await getGuildMasterGuilds(accountId) const guild = guilds.find((g) => g.guildid === guildId) - if (!guild) return { success: false, error: 'notGuildMaster' } + if (!guild) return { ok: false, error: 'notGuildMaster' } const oldName = guild.name - if (/["\r\n]/.test(oldName)) return { success: false, error: 'genericError' } // nombre antiguo no seguro para SOAP + if (/["\r\n]/.test(oldName)) return { ok: false, error: 'genericError' } // nombre antiguo no seguro para SOAP // El nuevo nombre no puede coincidir con otra hermandad existente. try { @@ -70,34 +70,34 @@ export async function renameGuild(params: { 'SELECT guildid FROM guild WHERE name = ? AND guildid <> ? LIMIT 1', [newName, guildId], ) - if (dup[0]) return { success: false, error: 'nameTaken' } + if (dup[0]) return { ok: false, error: 'nameTaken' } } catch { - return { success: false, error: 'genericError' } + return { ok: false, error: 'genericError' } } - // Cobra 1000 PD de forma atómica (condicional sobre el saldo). - let charged = false - try { - const [res] = await db(DB.default).query( - 'UPDATE home_api_points SET dp = dp - ? WHERE accountID = ? AND dp >= ?', - [GUILD_RENAME_PD, accountId, GUILD_RENAME_PD], - ) - charged = res.affectedRows === 1 - } catch { - return { success: false, error: 'genericError' } - } - if (!charged) return { success: false, error: 'insufficientPoints' } - - // Aplica el renombrado en el servidor por SOAP (convención del proyecto: - // respuesta no nula = aplicado, igual que soapFulfill en paid-services). Si el - // worldserver está caído, devuelve los PD cobrados. - const soap = await executeSoapCommand(`.guild rename "${oldName}" "${newName}"`) - if (soap === null) { - await db(DB.default) - .query('UPDATE home_api_points SET dp = dp + ? WHERE accountID = ?', [GUILD_RENAME_PD, accountId]) - .catch(() => {}) - return { success: false, error: 'soapError' } - } - - return { success: true, cost: GUILD_RENAME_PD, newName } + return { ok: true, oldName } +} + +/** + * Aplica el renombrado de hermandad por SOAP (post-pago). Busca el nombre + * antiguo por `guildId` y ejecuta `.guild rename`. Convención del proyecto: + * respuesta no nula = aplicado (igual que soapFulfill en paid-services). + */ +export async function renameGuildBySoap(guildId: number, newNameRaw: string): Promise { + const newName = (newNameRaw || '').trim() + if (!guildId || !NAME_RE.test(newName)) return false + let oldName: string + try { + const [rows] = await db(DB.characters).query( + 'SELECT name FROM guild WHERE guildid = ? LIMIT 1', + [guildId], + ) + if (!rows[0]) return false + oldName = String(rows[0].name) + } catch { + return false + } + if (/["\r\n]/.test(oldName)) return false + const soap = await executeSoapCommand(`.guild rename "${oldName}" "${newName}"`) + return soap !== null } diff --git a/web-next/lib/paid-services.ts b/web-next/lib/paid-services.ts index 407a35d..aedd8a6 100644 --- a/web-next/lib/paid-services.ts +++ b/web-next/lib/paid-services.ts @@ -1,6 +1,7 @@ import { executeSoapCommand } from './soap' import { creditDPoints } from './dpoints' import { sendGiftByMail } from './gift' +import { renameGuildBySoap, GUILD_RENAME_EUR } from './guild' import { checkFactionChangeEligibility } from './change-faction' import { checkTransferEligibility } from './transfer-character' import { @@ -120,6 +121,14 @@ export const PAID_SERVICES: Record = { }, extraFields: [], }, + // Renombrar hermandad (1000 PD = 10 €): al pagar, aplica `.guild rename`. + // La elegibilidad (Maestro, token, nombre libre) se valida en el checkout. + 'rename-guild': { + price: () => Promise.resolve(GUILD_RENAME_EUR), + productName: (_c, m) => `Renombrar hermandad a ${m.newName}`, + fulfill: (_c, m) => renameGuildBySoap(Number(m.guildId), m.newName), + extraFields: ['guildId', 'newName'], + }, // Compra de PD (Adquirir PD): acredita puntos a la cuenta que hizo el pago. // El importe lo elige el usuario, por eso `price` lee metadata.amount. dpoints: { diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 216b57d..341e1c7 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -359,6 +359,11 @@ "title": "Restore item", "pay": "Restore for {price} €", "success": "The item has been returned to character {name}." + }, + "rename-guild": { + "title": "Rename guild", + "pay": "Rename for {price} €", + "success": "The guild has been renamed to \"{name}\". Online members may need to reconnect to see it." } }, "SecurityToken": { diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 6fe0549..cdf3c2e 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -359,6 +359,11 @@ "title": "Recuperar ítem", "pay": "Recuperar por {price} €", "success": "El ítem ha sido devuelto al personaje {name}." + }, + "rename-guild": { + "title": "Renombrar hermandad", + "pay": "Renombrar por {price} €", + "success": "La hermandad ha sido renombrada a \"{name}\". Puede que los miembros conectados deban reconectar para verlo." } }, "SecurityToken": {