store: el concepto del cobro dice quién compra y qué

Ponía "Compra en la tienda (1 objeto)", que no identifica nada. Ahora:

  Tienda · BNet inna@inna.cl · Cuenta 15#1 · Innadin · Grito de la sirena (45327) x2

Cuenta Battle.net, cuenta de juego, personaje, y el nombre de cada ítem con su
id. Es lo único que Stripe/SumUp enseñan en su panel, así que es lo que hay para
identificar un pago cuando toca reclamar o devolver algo a mano.

Se recorta a 200 caracteres porque las dos pasarelas limitan el campo (~250) y
un carrito grande se pasaría. Se recortan solo los ÍTEMS ("+37 más"), nunca la
cabecera: quién ha pagado importa más que el listado, que además queda entero en
home_store_order. El recorte quita ítems hasta que quepa TODO con el sufijo
incluido; comprobarlo antes de añadir el "+N más" se pasaba igual (206 chars con
12 ítems, visto en un checkout real).

priceStoreCart trae ahora también el nombre del ítem, que no leía.

Verificado contra checkouts reales de Stripe (test) con 1, 12 y 40 ítems: 82,
167 y 168 caracteres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 11:36:14 +00:00
parent 457dac83f0
commit 18fe2489d0
2 changed files with 50 additions and 7 deletions
+8 -5
View File
@@ -1,7 +1,7 @@
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 { priceStoreCart, purchaseStoreCart, storeEuroTotal, createStoreOrder, storeConcept, type StoreCartLine } from '@/lib/store'
import { CARD_MIN_EUR } from '@/lib/store-pricing'
import { createCheckoutSession } from '@/lib/stripe'
import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup'
@@ -56,10 +56,13 @@ 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'
// 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'})`
// Concepto con quién compra y qué: es lo único que verá la pasarela en su
// panel para identificar el pago (ver `storeConcept`).
const productName = storeConcept(cart, {
bnetEmail: session.bnetEmail,
account: session.username,
character,
})
const metadata = { service: 'store', order_ref: ref }
if (provider === 'sumup') {
+42 -2
View File
@@ -197,11 +197,50 @@ export interface StoreCartLine {
export interface PricedCart {
// `qty` = lote que entrega CADA copia (home_store_item.quantity);
// `copies` = cuántas veces se compra la línea. Total de unidades = qty*copies.
lines: { id: number; itemId: number; qty: number; currency: Currency; price: number; copies: number }[]
// `name` = nombre del catálogo; lo usa el concepto del cobro con tarjeta.
lines: { id: number; itemId: number; name: string; qty: number; currency: Currency; price: number; copies: number }[]
pdTotal: number
vpTotal: number
}
/**
* Concepto del cobro con tarjeta: quién compra y qué. Sale en la pasarela y en
* su panel, así que es lo único que tendrán Stripe/SumUp para identificar un
* pago si hay que reclamar o devolver algo a mano.
*
* Formato: `Tienda · BNet <email> · Cuenta <cuenta> · <personaje> · Nombre (id) x2, …`
*
* Se recorta a `MAX_CONCEPT`: Stripe y SumUp limitan el campo (~250-255), y un
* carrito de 100 líneas se pasaría de largo. Se recortan solo los ÍTEMS, nunca
* la cabecera: quién ha pagado importa más que el listado completo, que además
* queda entero en `home_store_order`.
*/
const MAX_CONCEPT = 200
export function storeConcept(
cart: PricedCart,
opts: { bnetEmail?: string; account?: string; character: string },
): string {
const head = [
'Tienda',
opts.bnetEmail ? `BNet ${opts.bnetEmail}` : null,
opts.account ? `Cuenta ${opts.account}` : null,
opts.character,
]
.filter(Boolean)
.join(' · ')
const parts = cart.lines.map((l) => `${l.name} (${l.itemId})${l.copies > 1 ? ` x${l.copies}` : ''}`)
// Se van quitando ítems por el final hasta que quepa TODO, sufijo incluido: si
// se comprueba antes de añadir el "+N más" el resultado se pasa igualmente.
const fits = (s: string) => `${head} · ${s}`.length <= MAX_CONCEPT
let list = parts.join(', ')
for (let n = parts.length - 1; n >= 0 && !fits(list); n--) {
list = n > 0 ? `${parts.slice(0, n).join(', ')}, +${parts.length - n} más` : `${parts.length} ítems`
}
return fits(list) ? `${head} · ${list}` : head
}
/**
* Valida el carrito contra la BD (precios/cantidades/moneda REALES, nunca del
* cliente) a partir de las filas `home_store_item.id` y las copias pedidas.
@@ -223,7 +262,7 @@ export async function priceStoreCart(cart: StoreCartLine[]): Promise<PricedCart
let rows: ItemRow[] = []
try {
;[rows] = await db(DB.default).query<ItemRow[]>(
`SELECT id, item_id, quantity, currency, price FROM home_store_item WHERE id IN (${ids.map(() => '?').join(',')})`,
`SELECT id, item_id, name, quantity, currency, price FROM home_store_item WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
} catch {
@@ -234,6 +273,7 @@ export async function priceStoreCart(cart: StoreCartLine[]): Promise<PricedCart
const lines = rows.map((r) => ({
id: Number(r.id),
itemId: Number(r.item_id),
name: String(r.name),
qty: Number(r.quantity),
currency: r.currency,
price: Number(r.price),