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).