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>
This commit is contained in:
2026-07-15 10:57:00 +00:00
parent 0be3dc5280
commit fb28e97307
8 changed files with 190 additions and 30 deletions
@@ -3,7 +3,7 @@ import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { createCheckoutSession } from '@/lib/stripe'
import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup'
import { getPaidService } from '@/lib/paid-services'
import { getPaidService, fulfillOk } from '@/lib/paid-services'
import { payServiceWithDPoints } from '@/lib/pay-with-dpoints'
/** Locale seguro (es/en) para construir la URL de retorno. */
@@ -59,7 +59,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser
// (sin pasarela). Devuelve la URL de éxito para que el form redirija igual que
// con Stripe/SumUp; la entrega ya se ha realizado aquí.
if (String(body.provider) === 'pd') {
const r = await payServiceWithDPoints(session.accountId, price, () => cfg.fulfill(character, metadata))
const r = await payServiceWithDPoints(session.accountId, price, () => cfg.fulfill(character, metadata).then(fulfillOk))
if (!r.success) return Response.json({ success: false, error: r.error })
const locale = safeLocale(body.locale)
return Response.json({
@@ -2,7 +2,7 @@ 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 { getPaidService, fulfillOk } from '@/lib/paid-services'
import { payServiceWithDPoints } from '@/lib/pay-with-dpoints'
import { checkGuildRenameEligibility, GUILD_RENAME_EUR } from '@/lib/guild'
@@ -46,7 +46,7 @@ export async function POST(request: Request) {
// 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))
const r = await payServiceWithDPoints(session.accountId, price, () => cfg.fulfill(newName, metadata).then(fulfillOk))
if (!r.success) return Response.json({ success: false, error: r.error })
const locale = safeLocale(body.locale)
return Response.json({
+4 -1
View File
@@ -56,7 +56,10 @@ export async function POST(request: Request) {
const site = process.env.SITE_URL || ''
const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0'
const productName = `Compra en la tienda (${cart.lines.length} objeto${cart.lines.length === 1 ? '' : 's'})`
// Objetos comprados = suma de copias, no número de líneas: 15 copias de un
// mismo ítem son "15 objetos", no "1 objeto".
const units = cart.lines.reduce((s, l) => s + l.copies, 0)
const productName = `Compra en la tienda (${units} objeto${units === 1 ? '' : 's'})`
const metadata = { service: 'store', order_ref: ref }
if (provider === 'sumup') {
+17 -4
View File
@@ -1,5 +1,5 @@
import { claimPaidCheckout } from './stripe'
import { getPaidService } from './paid-services'
import { claimPaidCheckout, refundStripeSession } from './stripe'
import { getPaidService, fulfillOk, fulfillRefundEur } from './paid-services'
import { markOrderPaid } from './battlepay'
export interface FulfillResult {
@@ -33,6 +33,19 @@ export async function fulfillCheckoutSession(
const cfg = service ? getPaidService(service) : null
if (!cfg) return { ok: false, service: service || null, character: claim.characterName }
const ok = await cfg.fulfill(claim.characterName, claim.metadata)
return { ok, service, character: claim.characterName }
const outcome = await cfg.fulfill(claim.characterName, claim.metadata)
// Entrega parcial: el pago ya está cobrado, así que se le devuelve por Stripe
// lo que no se ha podido entregar. No se puede reintentar (el cobro se reclama
// una sola vez), así que o se devuelve ahora o el cliente paga de más.
const refundEur = fulfillRefundEur(outcome)
if (refundEur > 0) {
const refunded = await refundStripeSession(sessionId, refundEur)
if (!refunded) {
console.error(
`[fulfill] ${service}: entrega parcial y el reembolso de ${refundEur} € FALLÓ en la sesión` +
` ${sessionId}. Hay que devolverlo a mano desde el panel de Stripe.`,
)
}
}
return { ok: fulfillOk(outcome), service, character: claim.characterName }
}
+21 -1
View File
@@ -18,10 +18,30 @@ import {
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<boolean>
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`.
+48 -16
View File
@@ -292,7 +292,13 @@ export async function createStoreOrder(
character: string,
cart: PricedCart,
): Promise<boolean> {
const items = cartEntries(cart).map((e) => `${e.i}:${e.q}`).join(',')
// Formato `itemId:qty:precio:moneda` por entrada. El precio va guardado (y no
// se relee del catálogo al entregar) por dos razones: 114 item_id son
// ambiguos —el mismo objeto se vende a 50 PV y a 100 PD—, y aunque no lo
// fueran, hay que reembolsar lo que se COBRÓ, no lo que valga el ítem el día
// de la entrega. Se guarda en PD/PV, no en céntimos, para que el reembolso
// redondee igual que el cobro (storeEuroTotal, una sola vez sobre el total).
const items = cartEntries(cart).map((e) => `${e.i}:${e.q}:${e.price}:${e.currency}`).join(',')
const amount = storeEuroTotal(cart.pdTotal, cart.vpTotal)
try {
await db(DB.default).query(
@@ -309,34 +315,60 @@ export async function createStoreOrder(
* Entrega un pedido pagado con tarjeta: busca el carrito por `ref` y envía los
* ítems por correo. Lo llama el fulfillment del servicio `store` (webhook/return).
*
* Aquí no se puede reembolsar lo no entregado como en el pago con saldo: el
* dinero ya lo cobró la pasarela. Devuelve false si no salió TODO, que es lo
* honesto; no puede duplicar porque `claimPaidCheckout` reclama el pago una
* sola vez (y por eso mismo tampoco hay reintento: un envío a medias deja el
* pedido incompleto y hay que rescatarlo a mano desde `home_store_order`).
* Aquí no se puede devolver saldo como en el pago con PD/PV: el dinero lo tiene
* la pasarela. Por eso devuelve cuánto hay que reembolsarle al cliente
* (`refundEur`), y quien llama —que es quien sabe si fue Stripe o SumUp— pide el
* reembolso por su API. No puede duplicar entregas porque `claimPaidCheckout` /
* `fulfillSumUpCheckout` reclaman el pago una sola vez.
*/
export async function fulfillStoreOrder(character: string, ref: string): Promise<boolean> {
if (!ref) return false
export async function fulfillStoreOrder(
character: string,
ref: string,
): Promise<{ ok: boolean; refundEur?: number }> {
if (!ref) return { ok: false }
let rows: RowDataPacket[] = []
try {
;[rows] = await db(DB.default).query<RowDataPacket[]>(
'SELECT character_name, items FROM home_store_order WHERE ref = ?',
'SELECT character_name, items, amount FROM home_store_order WHERE ref = ?',
[ref],
)
} catch {
return false
return { ok: false }
}
const order = rows[0]
if (!order) return false
if (!order) return { ok: false }
const target = character || String(order.character_name)
const items = String(order.items)
// `itemId:qty:precio:moneda`. Los pedidos anteriores a este formato solo traen
// `itemId:qty`: se entregan igual, pero de esos no se puede calcular cuánto
// reembolsar (no llevan precio), así que se avisa y se deja a mano.
const entries = String(order.items)
.split(',')
.map((p) => {
const [i, q] = p.split(':')
return { i: Number(i), q: Number(q) }
const [i, q, price, cur] = p.split(':')
return { i: Number(i), q: Number(q), price: Number(price), currency: cur as Currency }
})
.filter((it) => it.i > 0 && it.q >= 1)
return (await sendStoreItems(target, items)) === items.length
.filter((e) => e.i > 0 && e.q >= 1)
if (entries.length === 0) return { ok: false }
const priced = entries.every((e) => e.price > 0 && (e.currency === 'pd' || e.currency === 'pv'))
const sent = await sendStoreItems(target, entries)
if (sent === entries.length) return { ok: true }
if (!priced) {
console.error(
`[store] pedido ${ref}: entregadas ${sent}/${entries.length} entradas, pero es de formato antiguo` +
` (sin precios) y no se puede calcular el reembolso. Revisar a mano.`,
)
return { ok: false }
}
const left = entries.slice(sent)
const back = (c: Currency) => left.filter((e) => e.currency === c).reduce((s, e) => s + e.price, 0)
// Nunca más de lo cobrado: los dos importes se redondean por separado y podrían
// descuadrar un céntimo si se entregó justo la mitad de un ítem de PV impar.
const refundEur = Math.min(storeEuroTotal(back('pd'), back('pv')), Number(order.amount))
console.error(`[store] pedido ${ref}: entregadas ${sent}/${entries.length}; se reembolsan ${refundEur}`)
return { ok: false, refundEur }
}
/**
+23
View File
@@ -4,6 +4,29 @@ import { db, DB } from './db'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
/**
* Reembolsa un pago de Stripe, entero o en parte.
*
* `amountEur` omitido = todo. Stripe cobra en la unidad mínima, así que el
* importe va en céntimos. El reembolso se pide sobre el PaymentIntent de la
* sesión de checkout, que es lo que guardamos en `home_stripelog`.
*/
export async function refundStripeSession(sessionId: string, amountEur?: number): Promise<boolean> {
if (!sessionId) return false
try {
const s = await stripe.checkout.sessions.retrieve(sessionId)
const pi = typeof s.payment_intent === 'string' ? s.payment_intent : s.payment_intent?.id
if (!pi) return false
const cents = amountEur === undefined ? undefined : Math.round(amountEur * 100)
if (cents !== undefined && cents <= 0) return false
await stripe.refunds.create({ payment_intent: pi, ...(cents === undefined ? {} : { amount: cents }) })
return true
} catch (e) {
console.error(`[stripe] no se pudo reembolsar ${sessionId}:`, e)
return false
}
}
export async function isSessionPaid(sessionId: string): Promise<boolean> {
if (!sessionId) return false
try {
+73 -4
View File
@@ -1,9 +1,16 @@
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
import { db, DB } from './db'
import { creditDPoints, PD_PER_UNIT } from './dpoints'
import { getPaidService } from './paid-services'
import { getPaidService, fulfillOk, fulfillRefundEur } from './paid-services'
const API = 'https://api.sumup.com/v0.1'
// Host de la API de SumUp, configurable por si hay que apuntar a otro entorno.
// Conviven dos versiones y NO son intercambiables: los checkouts van por v0.1 y
// los reembolsos por v1.0 (el `/v0.1/me/refund/{txn}` antiguo responde 409 a
// cualquier reembolso parcial). Ojo también con las unidades: v0.1 usa euros
// (1.30) y v1.0 céntimos (130).
const SUMUP_HOST = process.env.SUMUP_API_BASE || 'https://api.sumup.com'
const API = `${SUMUP_HOST}/v0.1`
const API_V1 = `${SUMUP_HOST}/v1.0`
/** SumUp está disponible sólo si hay API key y merchant code configurados. */
export function sumupConfigured(): boolean {
@@ -98,6 +105,59 @@ async function isReferencePaid(reference: string): Promise<boolean> {
}
}
/**
* Reembolsa un pago de SumUp, entero o en parte. `amountEur` omitido = todo.
*
* Tres cosas que hay que saber, todas comprobadas contra la API en sandbox:
* - No se reembolsa por referencia de checkout sino por TRANSACCIÓN, así que
* primero hay que sacar su id del checkout pagado.
* - El endpoint bueno es el de v1.0 (`API_V1`, ver arriba). El viejo
* `/v0.1/me/refund/{txn}` responde 409 a cualquier reembolso parcial.
* - ⚠️ El importe va en CÉNTIMOS y ENTERO (como Stripe, y al revés que el resto
* de la API v0.1, que usa unidades). 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.
*
* Además hay un mínimo por reembolso (la API lo devuelve en `min_refundable_amount`;
* son 20 céntimos en esta cuenta): por debajo responde 400 y no se puede devolver
* a medias. Se registra para hacerlo a mano.
*/
export async function refundSumUpCheckout(reference: string, amountEur?: number): Promise<boolean> {
if (!sumupConfigured() || !reference) return false
if (amountEur !== undefined && !(amountEur > 0)) return false
const merchant = process.env.SUMUP_MERCHANT_CODE ?? ''
try {
const res = await fetch(`${API}/checkouts?checkout_reference=${encodeURIComponent(reference)}`, {
headers: authHeaders(),
})
if (!res.ok) return false
const list: { status?: string; transaction_id?: string; transactions?: { id?: string }[] }[] = await res.json()
const paid = Array.isArray(list) ? list.find((c) => c.status === 'PAID') : null
const txn = paid?.transaction_id || paid?.transactions?.find((t) => t.id)?.id
if (!txn) {
console.error(`[sumup] ${reference}: pagado pero sin id de transacción; no se puede reembolsar`)
return false
}
const cents = amountEur === undefined ? undefined : Math.round(amountEur * 100)
const r = await fetch(
`${API_V1}/merchants/${encodeURIComponent(merchant)}/payments/${encodeURIComponent(txn)}/refunds`,
{
method: 'POST',
headers: authHeaders(),
// Sin `amount` = reembolso total. Nunca mandar decimales: ver arriba.
body: JSON.stringify(cents === undefined ? {} : { amount: cents }),
},
)
if (!r.ok) {
console.error(`[sumup] reembolso de ${reference} (${cents ?? 'total'} cts) rechazado: ${r.status} ${await r.text()}`)
return false
}
return true
} catch (e) {
console.error(`[sumup] no se pudo reembolsar ${reference}:`, e)
return false
}
}
/**
* Entrega un checkout SumUp pagado: verifica el estado, reclama de forma atómica
* (fulfilled 0->1) y acredita los PD. Idempotente. Devuelve true si acreditó ahora.
@@ -133,8 +193,17 @@ export async function fulfillSumUpCheckout(
} catch {
/* metadata corrupto: seguimos con lo básico */
}
const ok = await cfg.fulfill(String(log.character_name), meta)
return { ok, paid: true, service, character: String(log.character_name) }
const outcome = await cfg.fulfill(String(log.character_name), meta)
// Entrega parcial: el pago ya está cobrado y no hay reintento (se reclama una
// sola vez), así que se devuelve por SumUp lo que no se pudo entregar.
const refundEur = fulfillRefundEur(outcome)
if (refundEur > 0 && !(await refundSumUpCheckout(reference, refundEur))) {
console.error(
`[sumup] ${service}: entrega parcial y el reembolso de ${refundEur} € FALLÓ en ${reference}.` +
' Hay que devolverlo a mano desde el panel de SumUp.',
)
}
return { ok: fulfillOk(outcome), paid: true, service, character: String(log.character_name) }
}
// Sin servicio: compra de PD (comportamiento por defecto).