From fb28e97307f920d266566cc2a547399fe451f88e Mon Sep 17 00:00:00 2001 From: adevopg Date: Wed, 15 Jul 2026 10:57:00 +0000 Subject: [PATCH] store: devolver por Stripe/SumUp lo no entregado de una compra con tarjeta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../api/character/[service]/checkout/route.ts | 4 +- .../app/api/guild/rename/checkout/route.ts | 4 +- web-next/app/api/store/send/route.ts | 5 +- web-next/lib/fulfill.ts | 21 ++++- web-next/lib/paid-services.ts | 22 +++++- web-next/lib/store.ts | 64 +++++++++++---- web-next/lib/stripe.ts | 23 ++++++ web-next/lib/sumup.ts | 77 ++++++++++++++++++- 8 files changed, 190 insertions(+), 30 deletions(-) diff --git a/web-next/app/api/character/[service]/checkout/route.ts b/web-next/app/api/character/[service]/checkout/route.ts index 91581b9..ad6a80e 100644 --- a/web-next/app/api/character/[service]/checkout/route.ts +++ b/web-next/app/api/character/[service]/checkout/route.ts @@ -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({ diff --git a/web-next/app/api/guild/rename/checkout/route.ts b/web-next/app/api/guild/rename/checkout/route.ts index 94eab32..729e8f8 100644 --- a/web-next/app/api/guild/rename/checkout/route.ts +++ b/web-next/app/api/guild/rename/checkout/route.ts @@ -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({ diff --git a/web-next/app/api/store/send/route.ts b/web-next/app/api/store/send/route.ts index 3776f01..ecc8d07 100644 --- a/web-next/app/api/store/send/route.ts +++ b/web-next/app/api/store/send/route.ts @@ -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') { diff --git a/web-next/lib/fulfill.ts b/web-next/lib/fulfill.ts index 3d8fdec..ea0b404 100644 --- a/web-next/lib/fulfill.ts +++ b/web-next/lib/fulfill.ts @@ -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 } } diff --git a/web-next/lib/paid-services.ts b/web-next/lib/paid-services.ts index 64b919d..ea9187e 100644 --- a/web-next/lib/paid-services.ts +++ b/web-next/lib/paid-services.ts @@ -18,10 +18,30 @@ import { type Meta = Record +/** + * 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 productName: (character: string, meta: Meta) => string - fulfill: (character: string, meta: Meta) => Promise + fulfill: (character: string, meta: Meta) => Promise 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`. diff --git a/web-next/lib/store.ts b/web-next/lib/store.ts index 6a4776e..fa974a0 100644 --- a/web-next/lib/store.ts +++ b/web-next/lib/store.ts @@ -292,7 +292,13 @@ export async function createStoreOrder( character: string, cart: PricedCart, ): Promise { - 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 { - 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( - '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 } } /** diff --git a/web-next/lib/stripe.ts b/web-next/lib/stripe.ts index 19f2c1f..7d90103 100644 --- a/web-next/lib/stripe.ts +++ b/web-next/lib/stripe.ts @@ -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 { + 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 { if (!sessionId) return false try { diff --git a/web-next/lib/sumup.ts b/web-next/lib/sumup.ts index ecbd9c4..1fc6f81 100644 --- a/web-next/lib/sumup.ts +++ b/web-next/lib/sumup.ts @@ -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 { } } +/** + * 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 { + 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).