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:
2026-07-14 21:56:44 +00:00
parent 1b0920d11f
commit 20ee5a83c8
8 changed files with 189 additions and 92 deletions
+42 -42
View File
@@ -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
}
+9
View File
@@ -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: {