Files
NightSpire/web-next/lib/paid-services.ts
T
Inna fb28e97307 store: devolver por Stripe/SumUp lo no entregado de una compra con tarjeta
Faltaba la otra mitad de 0be3dc5: con saldo ya se reembolsaba lo que no llegaba
a enviarse, pero con tarjeta el dinero lo tiene la pasarela y el cliente se
quedaba pagando de más. Ambas tienen API de reembolso parcial, así que ahora
`fulfill` puede pedir que se devuelva un importe y lo hace quien conoce la
pasarela (lib/fulfill para Stripe, lib/sumup para SumUp).

Para saber CUÁNTO devolver hay que saber qué costó cada entrada, así que el
pedido pasa a guardarse como `itemId:qty:precio:moneda`. Releer el precio del
catálogo al entregar no vale por dos razones: 114 item_id son ambiguos (el mismo
objeto se vende a 50 PV y a 100 PD), y hay que devolver lo que se COBRÓ, no lo
que valga el ítem el día de la entrega. Los pedidos con el formato viejo se
entregan igual, pero no se puede calcular su reembolso: se registra para hacerlo
a mano.

`fulfill` devuelve ahora `boolean | {ok, refundEur}`; los servicios que no
entregan a medias siguen devolviendo boolean y no se tocan.

Sobre SumUp, todo comprobado contra su API en sandbox y nada de esto está en
sitios obvios:
 - El reembolso NO va por referencia de checkout sino por transacción.
 - Hay que usar el endpoint de v1.0; el viejo `/v0.1/me/refund/{txn}` responde
   409 a CUALQUIER reembolso parcial (el total sí funciona).
 - v1.0 quiere el importe en CÉNTIMOS y ENTERO, al revés que el resto de la API
   v0.1, que usa euros. Y mandar 0.10 en vez de 10 no da error: se trunca a 0,
   SumUp lo lee como "sin importe" y DEVUELVE EL PAGO ENTERO. Casi me lo comí.
 - Hay un mínimo por reembolso (20 cts en esta cuenta, la API lo dice en
   `min_refundable_amount`): por debajo responde 400 y se registra para hacerlo
   a mano.
El host sale de SUMUP_API_BASE (por defecto api.sumup.com) en vez de ir a fuego.

Verificado de punta a punta con pagos reales de prueba, parcheando el SOAP para
que solo saliera el primer correo:
 - Stripe (sk_test): pagados 1,30 €, entregadas 12/13 -> reembolso de 0,10 €,
   status succeeded en la API de Stripe.
 - SumUp (sandbox): pagados 1,50 €, entregadas 12/15 -> evento REFUND 0.3
   REFUNDED en la API de SumUp.

De paso: el concepto del cobro decía "1 objeto" al comprar 15 copias (contaba
líneas, no copias).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:57:00 +00:00

174 lines
6.8 KiB
TypeScript

import { executeSoapCommand } from './soap'
import { creditDPoints } from './dpoints'
import { sendGiftByMail } from './gift'
import { renameGuildBySoap, GUILD_RENAME_EUR } from './guild'
import { fulfillStoreOrder } from './store'
import { checkFactionChangeEligibility } from './change-faction'
import { checkTransferEligibility } from './transfer-character'
import {
getRenamePrice,
getCustomizePrice,
getChangeRacePrice,
getChangeFactionPrice,
getLevelUpPrice,
getTransferPrice,
getRestoreItemPrice,
goldPriceFor,
} from './prices'
type Meta = Record<string, string>
/**
* Resultado de una entrega. `boolean` para el caso normal (entregado o no).
*
* Un servicio que solo pueda cumplir PARTE de lo pagado devuelve además cuánto
* hay que devolverle al cliente (`refundEur`); lo pide por su API quien conoce
* la pasarela (lib/fulfill para Stripe, lib/sumup para SumUp). Lo usa la tienda:
* un carrito grande va en varios correos y el worldserver puede caerse a medias.
*/
export type FulfillOutcome = boolean | { ok: boolean; refundEur?: number }
/** Normaliza el resultado de `fulfill` para quien no distinga los dos casos. */
export function fulfillOk(o: FulfillOutcome): boolean {
return typeof o === 'boolean' ? o : o.ok
}
/** Euros a devolver de una entrega parcial (0 = nada que devolver). */
export function fulfillRefundEur(o: FulfillOutcome): number {
return typeof o === 'boolean' ? 0 : o.refundEur ?? 0
}
export interface PaidServiceConfig {
price: (meta: Meta) => Promise<number>
productName: (character: string, meta: Meta) => string
fulfill: (character: string, meta: Meta) => Promise<FulfillOutcome>
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 / 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> {
return (await executeSoapCommand(command)) !== null
}
/** Nombre de personaje válido (evita inyección en el comando SOAP). */
const SAFE_NAME = /^[A-Za-z]{1,12}$/
/** Envía el oro por correo al personaje (como el diseño). Requiere el worldserver. */
async function sendGoldByMail(character: string, goldAmount: number): Promise<boolean> {
if (!SAFE_NAME.test(character) || !(goldAmount > 0)) return false
const copper = goldAmount * 10000
return soapFulfill(`.send money "${character}" "Adquirir oro" "Has recibido ${goldAmount} de oro." ${copper}`)
}
export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
rename: {
price: () => getRenamePrice(),
productName: (c) => `Renombrar personaje: ${c}`,
fulfill: (c) => soapFulfill(`.char rename ${c}`),
extraFields: [],
},
customize: {
price: () => getCustomizePrice(),
productName: (c) => `Personalizar personaje: ${c}`,
fulfill: (c) => soapFulfill(`.char customi ${c}`),
extraFields: [],
},
'change-race': {
price: () => getChangeRacePrice(),
productName: (c) => `Cambiar raza: ${c}`,
fulfill: (c) => soapFulfill(`.char changerace ${c}`),
extraFields: [],
},
'change-faction': {
price: () => getChangeFactionPrice(),
productName: (c) => `Cambiar facción: ${c}`,
fulfill: (c) => soapFulfill(`.char changef ${c}`),
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('; ')}.` }
},
extraFields: [],
},
'level-up': {
price: () => getLevelUpPrice(),
productName: (c) => `Subir a nivel 80: ${c}`,
fulfill: (c) => soapFulfill(`.char level ${c} 80`),
extraFields: [],
},
gold: {
price: (m) => goldPriceFor(Number(m.gold_amount)),
productName: (c, m) => `Adquirir oro: ${m.gold_amount} al personaje ${c}`,
fulfill: (c, m) => sendGoldByMail(c, Number(m.gold_amount)),
extraFields: ['gold_amount'],
},
// Recuperar un ítem borrado (por SumUp): al pagar se ejecuta `.item restore`.
'restore-item': {
price: () => getRestoreItemPrice(),
productName: (c, m) => `Recuperar ítem #${m.recover_id} de ${c}`,
fulfill: (c, m) =>
/^\d+$/.test(String(m.recover_id ?? '')) ? soapFulfill(`.item restore ${m.recover_id} ${c}`) : Promise.resolve(false),
extraFields: ['recover_id'],
},
transfer: {
price: () => getTransferPrice(),
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),
},
// Enviar regalo: al pagar por SumUp, envía por correo los ítems del carrito al
// personaje destino (a un amigo). `sender` = personaje de origen; `items` = JSON
// [{ i: item_id, q: cantidad }] que guardó el checkout en metadata.
'send-gift': {
price: (m) => Promise.resolve(Number(m.amount)),
productName: (c) => `Regalo para ${c}`,
fulfill: (c, m) => {
let items: { i: number; q: number }[] = []
try {
items = JSON.parse(m.items || '[]')
} catch {
return Promise.resolve(false)
}
return sendGiftByMail(c, m.sender || '', items)
},
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'],
},
// Tienda pagada con tarjeta: al confirmarse el pago, envía por correo los ítems
// del pedido (guardado en home_store_order) referenciado en metadata.order_ref.
store: {
price: (m) => Promise.resolve(Number(m.amount)),
productName: () => 'Compra en la tienda',
fulfill: (c, m) => fulfillStoreOrder(c, m.order_ref || ''),
extraFields: [],
},
// 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: {
price: (m) => Promise.resolve(Number(m.amount)),
productName: (_c, m) => `${Number(m.points)} PD`,
fulfill: (_c, m) => creditDPoints(Number(m.accountId), Number(m.points)),
extraFields: [],
},
}
export function getPaidService(slug: string): PaidServiceConfig | null {
return PAID_SERVICES[slug] ?? null
}