rename-guild: permitir pagar con PD, Stripe o SumUp
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<PageShell title={t('renameGuild.title')}>
|
||||
@@ -67,7 +71,7 @@ export default async function RenameGuildPage({ params }: { params: Promise<{ lo
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<RenameGuildForm guilds={guilds} />
|
||||
<RenameGuildForm guilds={guilds} price={GUILD_RENAME_EUR} pdBalance={pdBalance} />
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
|
||||
@@ -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<string, unknown> = {}
|
||||
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<string, string> = { 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 })
|
||||
}
|
||||
@@ -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<string, unknown> = {}
|
||||
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)
|
||||
}
|
||||
@@ -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<PayMethod>(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<HTMLFormElement>) {
|
||||
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[] }) {
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="rename-guild-button" disabled={busy || done} style={done ? { color: '#d79602' } : undefined}>
|
||||
{done ? t('renameGuild.renamed') : busy ? t('renameGuild.renaming') : t('renameGuild.renameButton')}
|
||||
<PaymentMethodSelect value={method} onChange={setMethod} priceEur={price} pdBalance={pdBalance} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="rename-guild-button" disabled={busy}>
|
||||
{busy ? t('renameGuild.renaming') : t('renameGuild.renameButton')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+42
-42
@@ -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<GmGuild[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
success: boolean
|
||||
error?: string
|
||||
cost?: number
|
||||
newName?: string
|
||||
}
|
||||
|
||||
export async function renameGuild(params: {
|
||||
/**
|
||||
* Comprueba que la cuenta puede renombrar la hermandad (antes de cobrar):
|
||||
* nombre válido, token de seguridad correcto, es Maestro de esa hermandad y el
|
||||
* nuevo nombre no está en uso. Devuelve el nombre antiguo si todo es correcto.
|
||||
*/
|
||||
export async function checkGuildRenameEligibility(params: {
|
||||
accountId: number
|
||||
guildId: number
|
||||
newName: string
|
||||
token: string
|
||||
}): Promise<Result> {
|
||||
}): 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<ResultSetHeader>(
|
||||
'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<boolean> {
|
||||
const newName = (newNameRaw || '').trim()
|
||||
if (!guildId || !NAME_RE.test(newName)) return false
|
||||
let oldName: string
|
||||
try {
|
||||
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'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
|
||||
}
|
||||
|
||||
@@ -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<string, PaidServiceConfig> = {
|
||||
},
|
||||
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: {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user