web-next: /transfer-character (transferir personaje por SumUp con todas las condiciones)
- Nueva ruta /transfer-character (10€ SumUp); borra /transfer (Stripe) y actualiza
el link del panel.
- lib/transfer-character.ts: precheck con todas las condiciones — personaje offline,
contraseña, token de seguridad, 2FA activo, cuenta destino válida (≠ propia) y,
para Caballeros de la Muerte, la cuenta destino debe tener un personaje nivel >=55
y no tener ya un DK; + bloqueo de 5s tras transferencia reciente.
- El hook precheck de PaidServiceConfig ahora recibe {accountId,email,character,body}.
- TransferForm: contraseña + token (con ojos), provider sumup, confirm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { executeSoapCommand } from './soap'
|
||||
import { creditDPoints } from './dpoints'
|
||||
import { checkFactionChangeEligibility } from './change-faction'
|
||||
import { checkTransferEligibility } from './transfer-character'
|
||||
import {
|
||||
getRenamePrice,
|
||||
getCustomizePrice,
|
||||
@@ -18,9 +19,16 @@ export interface PaidServiceConfig {
|
||||
productName: (character: string, meta: Meta) => string
|
||||
fulfill: (character: string, meta: Meta) => Promise<boolean>
|
||||
extraFields: string[] // campos (además de character) que el form envía y se guardan en metadata
|
||||
// Comprobación previa al pago (p.ej. condiciones de cambio de facción). Si `ok`
|
||||
// es false, se bloquea el checkout y se muestra `message`.
|
||||
precheck?: (accountId: number, character: string) => Promise<{ ok: boolean; message?: string }>
|
||||
// Comprobación previa al pago (p.ej. condiciones de cambio de facción / transferencia).
|
||||
// Si `ok` es false, se bloquea el checkout y se muestra `message`.
|
||||
precheck?: (ctx: PrecheckCtx) => Promise<{ ok: boolean; message?: string }>
|
||||
}
|
||||
|
||||
export interface PrecheckCtx {
|
||||
accountId: number
|
||||
email: string
|
||||
character: string
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function soapFulfill(command: string): Promise<boolean> {
|
||||
@@ -60,7 +68,7 @@ export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
|
||||
price: () => getChangeFactionPrice(),
|
||||
productName: (c) => `Cambiar facción: ${c}`,
|
||||
fulfill: (c) => soapFulfill(`.char changef ${c}`),
|
||||
precheck: async (accountId, character) => {
|
||||
precheck: async ({ accountId, character }) => {
|
||||
const r = await checkFactionChangeEligibility(accountId, character)
|
||||
return { ok: r.ok, message: r.ok ? undefined : `No se puede cambiar la facción: el personaje ${r.reasons.join('; ')}.` }
|
||||
},
|
||||
@@ -83,6 +91,7 @@ export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
|
||||
productName: (c, m) => `Transferir ${c} a la cuenta ${m.destination_account}`,
|
||||
fulfill: (c, m) => soapFulfill(`.char changeaccount ${m.destination_account} ${c}`),
|
||||
extraFields: ['destination_account'],
|
||||
precheck: ({ accountId, email, character, body }) => checkTransferEligibility(accountId, email, character, body),
|
||||
},
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
import { authenticate } from './auth'
|
||||
import { checkSecurityToken } from './security-token'
|
||||
import { is2faEnabled } from './two-factor'
|
||||
|
||||
/**
|
||||
* Condiciones para transferir un personaje a otra cuenta (/transfer-character):
|
||||
* personaje desconectado, contraseña correcta, token de seguridad, 2FA activo,
|
||||
* cuenta destino válida, y —para Caballeros de la Muerte— la cuenta destino debe
|
||||
* tener un personaje de nivel 55+ y no tener ya un DK. Bloqueo de 5 s tras una
|
||||
* transferencia reciente. Se comprueba ANTES del pago (SumUp).
|
||||
*/
|
||||
export async function checkTransferEligibility(
|
||||
accountId: number,
|
||||
email: string,
|
||||
character: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
const destination = String(body.destination_account ?? '').trim()
|
||||
const password = String(body.password ?? '')
|
||||
const token = String(body.security_token ?? '').trim()
|
||||
if (!destination || !password || !token) {
|
||||
return { ok: false, message: 'Completa la cuenta destino, la contraseña y el token de seguridad.' }
|
||||
}
|
||||
|
||||
// Personaje: de la cuenta, desconectado, clase.
|
||||
const [chRows] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT class, online FROM characters WHERE name = ? AND account = ? LIMIT 1',
|
||||
[character, accountId],
|
||||
)
|
||||
const ch = chRows[0]
|
||||
if (!ch) return { ok: false, message: 'El personaje no pertenece a tu cuenta.' }
|
||||
if (ch.online === 1) return { ok: false, message: 'El personaje debe estar desconectado.' }
|
||||
const isDK = Number(ch.class) === 6
|
||||
|
||||
// Contraseña + token de seguridad + 2FA activo.
|
||||
if (!(await authenticate(email, password))) return { ok: false, message: 'La contraseña de tu cuenta es incorrecta.' }
|
||||
if (!(await checkSecurityToken(accountId, token))) return { ok: false, message: 'El token de seguridad no es válido.' }
|
||||
if (!(await is2faEnabled(accountId))) return { ok: false, message: 'Debes tener el 2FA activado en tu cuenta.' }
|
||||
|
||||
// Cuenta destino (por nombre de cuenta).
|
||||
let destId = 0
|
||||
try {
|
||||
const [acc] = await db(DB.auth).query<RowDataPacket[]>('SELECT id FROM account WHERE username = ? LIMIT 1', [destination])
|
||||
destId = acc[0] ? Number(acc[0].id) : 0
|
||||
} catch {
|
||||
return { ok: false, message: 'No se pudo verificar la cuenta de destino.' }
|
||||
}
|
||||
if (!destId) return { ok: false, message: 'La cuenta de destino no existe.' }
|
||||
if (destId === accountId) return { ok: false, message: 'La cuenta de destino no puede ser la tuya.' }
|
||||
|
||||
// Condiciones de Caballero de la Muerte.
|
||||
if (isDK) {
|
||||
const [lvl] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT 1 FROM characters WHERE account = ? AND level >= 55 LIMIT 1',
|
||||
[destId],
|
||||
)
|
||||
if (!lvl[0]) {
|
||||
return { ok: false, message: 'La cuenta destino debe tener un personaje de nivel 55 o superior para recibir un Caballero de la Muerte.' }
|
||||
}
|
||||
const [dk] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT 1 FROM characters WHERE account = ? AND class = 6 LIMIT 1',
|
||||
[destId],
|
||||
)
|
||||
if (dk[0]) return { ok: false, message: 'La cuenta destino ya tiene un Caballero de la Muerte.' }
|
||||
}
|
||||
|
||||
// Bloqueo de 5 s tras una transferencia reciente de esta cuenta.
|
||||
try {
|
||||
const [recent] = await db(DB.default).query<RowDataPacket[]>(
|
||||
"SELECT id FROM home_stripelog WHERE account_id = ? AND service = 'transfer' AND fulfilled = 1 AND timestamp > (NOW() - INTERVAL 5 SECOND) LIMIT 1",
|
||||
[accountId],
|
||||
)
|
||||
if (recent[0]) return { ok: false, message: 'Espera unos segundos antes de transferir otro personaje.' }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
Reference in New Issue
Block a user