Files
NightSpire/web-next/app/api/store/send/route.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

106 lines
4.4 KiB
TypeScript

import { randomUUID } from 'crypto'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { priceStoreCart, purchaseStoreCart, storeEuroTotal, createStoreOrder, type StoreCartLine } from '@/lib/store'
import { CARD_MIN_EUR } from '@/lib/store-pricing'
import { createCheckoutSession } from '@/lib/stripe'
import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup'
function safeLocale(v: unknown): string {
return v === 'en' ? 'en' : 'es'
}
/**
* Compra de la tienda. `provider`:
* - ausente/'balance' → cobra del saldo PD/PV y envía los ítems al momento.
* - 'stripe'/'sumup' → pago con tarjeta (euros); el pedido se guarda y se
* entrega tras confirmarse el pago (webhook/reconciliación).
* El precio se recalcula siempre en el servidor; nunca se confía en el cliente.
*/
export async function POST(request: Request) {
const session = await getSession()
if (!session.accountId || !session.username) {
return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
}
// `items` viene del cliente y NO se cree nada de él salvo los ids y las
// copias: precios, moneda y lote los relee priceStoreCart de la BD.
let body: { character?: string; items?: StoreCartLine[]; provider?: string; locale?: string } = {}
try {
body = await request.json()
} catch {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
const character = String(body.character ?? '')
const chars = await getGameCharacters(session.accountId)
if (!character || !chars.find((c) => c.name === character)) {
return Response.json({ success: false, error: 'invalidCharacter' })
}
const cart = await priceStoreCart(Array.isArray(body.items) ? body.items : [])
if (!cart) return Response.json({ success: false, error: 'emptyCart' })
const provider = String(body.provider ?? 'balance')
// Pago con tarjeta: guarda el pedido y crea el checkout de la pasarela.
if (provider === 'stripe' || provider === 'sumup') {
const amount = storeEuroTotal(cart.pdTotal, cart.vpTotal)
const minEur = CARD_MIN_EUR[provider]
if (amount < minEur) return Response.json({ success: false, error: 'amountTooLow', min: minEur })
const ref = randomUUID()
if (!(await createStoreOrder(ref, session.accountId, character, cart))) {
return Response.json({ success: false, error: 'genericError' })
}
const site = process.env.SITE_URL || ''
const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0'
// 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') {
if (!sumupConfigured()) return Response.json({ success: false, error: 'notConfigured' })
const r = await createSumUpCheckout({
accountId: session.accountId,
username: session.username || '',
email: session.bnetEmail || '',
acoreIp: ip,
amount,
points: 0,
reference: ref,
description: productName,
returnUrl: `${site}/service-success?service=store&provider=sumup&ref=${ref}`,
service: 'store',
characterName: character,
metadata,
})
if (!r.success || !r.url) return Response.json({ success: false, error: r.error || 'sumupError' })
return Response.json({ success: true, url: r.url })
}
const r = await createCheckoutSession({
accountId: session.accountId,
username: session.username || '',
email: session.bnetEmail || '',
acoreIp: ip,
amount,
characterName: character,
productName,
successUrl: `${site}/service-success?service=store`,
cancelUrl: `${site}/${safeLocale(body.locale)}/store`,
metadata,
})
if (!r.success || !r.url) return Response.json({ success: false, error: 'stripeError' })
return Response.json({ success: true, url: r.url })
}
// Pago con saldo PD/PV: cobra y envía al momento.
const r = await purchaseStoreCart(session.accountId, character, cart)
if (!r.success) return Response.json({ success: false, error: r.error })
return Response.json({ success: true })
}