Files
NightSpire/web-next/app/api/store/send/route.ts
T
Inna e500bd7a84 store: precios en euros en el carrito y mínimo real de las pasarelas
El carrito enseñaba PD/PV aunque eligieras tarjeta, y por defecto ya presuponía
saldo. Ahora se muestran SIEMPRE las dos monedas y se apaga la que no se va a
cobrar con el método elegido, así que no presupone nada ni se contradice.

Los euros por línea van con hasta 3 decimales a propósito: un ítem en PV con
precio impar cuesta medio céntimo (75 PV = 0,375 €), y redondear cada línea a 2
haría que las líneas no sumaran el total (0,38 + 0,38 = 0,76 contra un total de
0,75). El total sí va a céntimos: es el importe que se cobra de verdad. Y con
mínimo 2 decimales, que es dinero: "1,50 €", no "1,5 €". El formateo va por
Intl con el locale de la web, así que en español sale con coma; antes el
selector interpolaba el número crudo y decía "0.75 €" con punto.

Mínimos de las pasarelas: SumUp ya estaba en 1 € y Stripe estaba en 0,50 €,
ahora también 1 €. Estaban sueltos en la ruta; se centralizan en
`lib/store-pricing`, un módulo PURO que importan tanto la ruta como el
componente, para que el carrito enseñe exactamente lo que se valida y se cobra.
`storeEuroTotal` pasa a vivir ahí (una sola implementación) y lib/store lo
envuelve con un guard: si alguien cambia PD_PER_UNIT o VP_PRICE_FACTOR sin tocar
store-pricing, revienta al primer uso en vez de cobrar mal en silencio (esos
módulos tocan BD y no pueden llegar al bundle del cliente, de ahí la
duplicación, igual que en PaymentMethodSelect).

Por debajo del mínimo, la opción de tarjeta se desactiva y dice "Mínimo 1,00 €"
en vez de dejarte pulsar Enviar para que la API la rechace. El método efectivo
se deriva en el render (si el carrito baja del mínimo con tarjeta ya elegida,
cae al saldo) en vez de sincronizarlo con un setState en un efecto, que
provocaría renders en cascada.

Verificado en el navegador: con 0,375 € las dos tarjetas salen desactivadas y
elegido el saldo; con 1,50 € se habilitan. Las dos líneas de 0,375 € suman el
total de 0,75 €.

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

101 lines
4.0 KiB
TypeScript

import { randomUUID } from 'crypto'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { priceStoreCart, purchaseStoreCart, storeEuroTotal, createStoreOrder } 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 })
}
let body: { character?: string; items?: number[]; 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'
const productName = `Compra en la tienda (${cart.lines.length} objeto${cart.lines.length === 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 })
}