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>
This commit is contained in:
@@ -2,6 +2,7 @@ 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'
|
||||
|
||||
@@ -43,7 +44,7 @@ export async function POST(request: Request) {
|
||||
// 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 = provider === 'sumup' ? 1 : 0.5
|
||||
const minEur = CARD_MIN_EUR[provider]
|
||||
if (amount < minEur) return Response.json({ success: false, error: 'amountTooLow', min: minEur })
|
||||
|
||||
const ref = randomUUID()
|
||||
|
||||
@@ -590,6 +590,16 @@ textarea:focus {
|
||||
es lo que separa las filas), .icon-td, .width-50-td y .justified del original.
|
||||
No se pisan: el `border-collapse` que había aquí anulaba ese border-spacing. */
|
||||
|
||||
/* Precios del carrito: se enseñan SIEMPRE las dos monedas (saldo PD/PV y euros)
|
||||
y se apaga la que no se va a cobrar con el método elegido. Así el carrito no
|
||||
presupone nada antes de elegir ni se contradice al pagar con tarjeta. */
|
||||
.store-price { display: block; transition: opacity .2s, color .2s; }
|
||||
.store-price-eur { color: #ebdec2; }
|
||||
.store-price-total { font-weight: bold; }
|
||||
.store-price-off { opacity: .4; }
|
||||
.store-price-off .dp-color,
|
||||
.store-price-off .vp-color { color: inherit; }
|
||||
|
||||
/* Modal de resultado */
|
||||
.modal-div {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations, useLocale } from 'next-intl'
|
||||
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
|
||||
import { WowheadLink } from '@/components/WowheadLink'
|
||||
import { wowheadIcon } from '@/lib/wowhead'
|
||||
import { CARD_MIN_EUR, lineEur, storeEuroTotal } from '@/lib/store-pricing'
|
||||
import type { StoreCategory, StoreItem } from '@/lib/store'
|
||||
|
||||
const ICON_FALLBACK = 'inv_misc_questionmark'
|
||||
@@ -12,6 +13,23 @@ const ICON_FALLBACK = 'inv_misc_questionmark'
|
||||
/** Renders de los ítems de la tienda (public/), para el preview en hover. */
|
||||
const STORE_DISPLAY_PATH = '/nw-themes/nw-ryu/nw-images/nw-displays'
|
||||
|
||||
/**
|
||||
* Importe en euros en el idioma de la web ("2,00 €", "0,125 €").
|
||||
*
|
||||
* Mínimo 2 decimales porque es dinero ("1,50 €", no "1,5 €"). Y hasta 3 a
|
||||
* propósito: un ítem en PV con precio impar cuesta medio céntimo (25 PV =
|
||||
* 0,125 €), así que redondear cada línea a 2 haría que las líneas no sumaran el
|
||||
* total y el carrito se contradiría. El total sí va redondeado a céntimos,
|
||||
* porque es el importe real que se cobra.
|
||||
*/
|
||||
function eurNumber(n: number, locale: string): string {
|
||||
return new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 3 }).format(n)
|
||||
}
|
||||
|
||||
function formatEur(n: number, locale: string): string {
|
||||
return `${eurNumber(n, locale)} €`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaplica el tratamiento de wowhead (con `colorLinks` = teñir por calidad) a los
|
||||
* enlaces que React acaba de montar. `tooltips.js` solo recorre el DOM al cargar,
|
||||
@@ -178,14 +196,36 @@ export function StoreBrowser({
|
||||
const qtyTotal = lines.reduce((s, l) => s + l.qty, 0)
|
||||
// Clase de color del personaje elegido, para pintarlo como el original.
|
||||
const charClass = characters.find((c) => c.name === character)?.classCss ?? ''
|
||||
// 100 PD = 1 €, 200 PV = 1 € (igual que en lib/store storeEuroTotal).
|
||||
const eurTotal = Math.round((pdTotal / 100 + vpTotal / 200) * 100) / 100
|
||||
// El mismo cálculo que valida y cobra /api/store/send (módulo compartido).
|
||||
const eurTotal = storeEuroTotal(pdTotal, vpTotal)
|
||||
// Por debajo del mínimo de la pasarela, su API rechaza el cobro: se desactiva
|
||||
// la opción y se dice por qué, en vez de dejar que falle al pulsar Enviar.
|
||||
const cardTooLow = (m: PayMethod) => (m === 'stripe' || m === 'sumup') && eurTotal < CARD_MIN_EUR[m]
|
||||
// Método REAL, derivado en el render: si el carrito baja del mínimo con una
|
||||
// tarjeta ya elegida, esa opción se desactiva y se cae al saldo. Derivado y no
|
||||
// un setState en un efecto, que provocaría renders en cascada.
|
||||
const activeMethod: PayMethod = cardTooLow(method) ? 'balance' : method
|
||||
// Con tarjeta se paga en euros y con saldo en PD/PV: se muestran siempre los
|
||||
// dos, y se apaga el que no se va a cobrar para que no haya duda.
|
||||
const payingCard = activeMethod === 'stripe' || activeMethod === 'sumup'
|
||||
|
||||
const payOptions: { id: PayMethod; label: string; sub: string; disabled?: boolean }[] = [
|
||||
{ id: 'balance', label: t('payBalance'), sub: `${pdTotal} PD · ${vpTotal} PV` },
|
||||
...(['stripe', 'sumup'] as const).map((id) => ({
|
||||
id: id as PayMethod,
|
||||
label: t(id === 'stripe' ? 'payStripe' : 'paySumUp'),
|
||||
sub: cardTooLow(id)
|
||||
? t('minCard', { min: eurNumber(CARD_MIN_EUR[id], locale) })
|
||||
: formatEur(eurTotal, locale),
|
||||
disabled: cardTooLow(id),
|
||||
})),
|
||||
]
|
||||
|
||||
async function send() {
|
||||
if (sending || cart.size === 0) return
|
||||
const card = method === 'stripe' || method === 'sumup'
|
||||
const card = payingCard
|
||||
const ok = card
|
||||
? window.confirm(t('confirmCard', { eur: eurTotal, character }))
|
||||
? window.confirm(t('confirmCard', { eur: eurNumber(eurTotal, locale), character }))
|
||||
: window.confirm(t('confirm', { character, pd: pdTotal, vp: vpTotal }))
|
||||
if (!ok) return
|
||||
setSending(true)
|
||||
@@ -194,18 +234,20 @@ export function StoreBrowser({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ character, items: [...cart.keys()], provider: method, locale }),
|
||||
body: JSON.stringify({ character, items: [...cart.keys()], provider: activeMethod, locale }),
|
||||
})
|
||||
const data: { success?: boolean; error?: string; url?: string; min?: number } = await res.json()
|
||||
if (data.success && data.url) {
|
||||
window.location.href = data.url // pasarela (Stripe/SumUp)
|
||||
// .assign() y no `location.href = …`: es equivalente, pero la regla del
|
||||
// compilador de React prohíbe asignar a algo definido fuera del componente.
|
||||
window.location.assign(data.url) // pasarela (Stripe/SumUp)
|
||||
return
|
||||
}
|
||||
if (data.success) {
|
||||
setModal({ ok: true, text: t('successMsg', { character }) })
|
||||
setCart(new Map())
|
||||
} else if (data.error === 'amountTooLow') {
|
||||
setModal({ ok: false, text: t('errors.amountTooLow', { min: data.min ?? 1 }) })
|
||||
setModal({ ok: false, text: t('errors.amountTooLow', { min: eurNumber(data.min ?? 1, locale) }) })
|
||||
} else {
|
||||
setModal({ ok: false, text: t.has(`errors.${data.error}`) ? t(`errors.${data.error}`) : t('errors.generic') })
|
||||
}
|
||||
@@ -253,13 +295,19 @@ export function StoreBrowser({
|
||||
<div className="pay-method-select">
|
||||
<p className="second-brown">{t('payMethod')}</p>
|
||||
<div className="pay-method-options">
|
||||
{([
|
||||
{ id: 'balance' as PayMethod, label: t('payBalance'), sub: `${pdTotal} PD · ${vpTotal} PV` },
|
||||
{ id: 'stripe' as PayMethod, label: t('payStripe'), sub: `${eurTotal} €` },
|
||||
{ id: 'sumup' as PayMethod, label: t('paySumUp'), sub: `${eurTotal} €` },
|
||||
]).map((o) => (
|
||||
<label key={o.id} className={`pay-method-option${method === o.id ? ' selected' : ''}`}>
|
||||
<input type="radio" name="store-pay" value={o.id} checked={method === o.id} onChange={() => setMethod(o.id)} />
|
||||
{payOptions.map((o) => (
|
||||
<label
|
||||
key={o.id}
|
||||
className={`pay-method-option${activeMethod === o.id ? ' selected' : ''}${o.disabled ? ' disabled' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="store-pay"
|
||||
value={o.id}
|
||||
checked={activeMethod === o.id}
|
||||
disabled={o.disabled}
|
||||
onChange={() => setMethod(o.id)}
|
||||
/>
|
||||
<span className="pay-method-label">{o.label}</span>
|
||||
<span className="pay-method-sub yellow-info">{o.sub}</span>
|
||||
</label>
|
||||
@@ -291,7 +339,12 @@ export function StoreBrowser({
|
||||
</td>
|
||||
<td><span>{l.qty}</span></td>
|
||||
<td>
|
||||
<span className={l.currency === 'pv' ? 'vp-color' : 'dp-color'}>{l.currency === 'pv' ? 'PV' : 'PD'}:</span> <span>{l.price}</span>
|
||||
<span className={`store-price${payingCard ? ' store-price-off' : ''}`}>
|
||||
<span className={l.currency === 'pv' ? 'vp-color' : 'dp-color'}>{l.currency === 'pv' ? 'PV' : 'PD'}:</span> <span>{l.price}</span>
|
||||
</span>
|
||||
<span className={`store-price store-price-eur${payingCard ? '' : ' store-price-off'}`}>
|
||||
{formatEur(lineEur(l.price, l.currency), locale)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="icon-td">
|
||||
<button type="button" className="store-remove-button" onClick={() => removeItem(l.id)} title={t('remove')}>
|
||||
@@ -306,8 +359,13 @@ export function StoreBrowser({
|
||||
<td></td>
|
||||
<td><span>{qtyTotal}</span></td>
|
||||
<td>
|
||||
<span className="dp-color">PD: </span><span>{pdTotal}</span><br />
|
||||
<span className="vp-color">PV: </span><span>{vpTotal}</span>
|
||||
<span className={`store-price${payingCard ? ' store-price-off' : ''}`}>
|
||||
<span className="dp-color">PD: </span><span>{pdTotal}</span><br />
|
||||
<span className="vp-color">PV: </span><span>{vpTotal}</span>
|
||||
</span>
|
||||
<span className={`store-price store-price-eur store-price-total${payingCard ? '' : ' store-price-off'}`}>
|
||||
{formatEur(eurTotal, locale)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" className="store-send-button" onClick={send} disabled={sending || cart.size === 0}>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Precios de la tienda en euros: conversión y mínimos de las pasarelas.
|
||||
*
|
||||
* Módulo PURO (sin acceso a BD), para que lo importen tanto la ruta de servidor
|
||||
* como el componente de cliente y no puedan divergir: lo que enseña el carrito
|
||||
* es exactamente lo que valida y cobra `/api/store/send`.
|
||||
*/
|
||||
|
||||
// Duplicados a propósito de PD_PER_UNIT (lib/dpoints) y VP_PRICE_FACTOR
|
||||
// (lib/pay-with-dpoints): esos módulos importan la BD y no pueden llegar al
|
||||
// bundle del cliente. Mantener sincronizados (mismo criterio que
|
||||
// PaymentMethodSelect). 100 PD = 1 €; los PV cuestan el doble: 200 PV = 1 €.
|
||||
export const PD_PER_EUR = 100
|
||||
export const PV_PER_EUR = 200
|
||||
|
||||
/**
|
||||
* Importe mínimo por pasarela, en euros. Por debajo, la API de la pasarela
|
||||
* rechaza el cobro, así que hay que impedirlo ANTES de crear el pedido.
|
||||
*/
|
||||
export const CARD_MIN_EUR: Record<'stripe' | 'sumup', number> = {
|
||||
stripe: 1,
|
||||
sumup: 1,
|
||||
}
|
||||
|
||||
/** Euros de una línea, SIN redondear: un ítem en PV de precio impar cuesta medio céntimo. */
|
||||
export function lineEur(price: number, currency: 'pd' | 'pv'): number {
|
||||
return price / (currency === 'pv' ? PV_PER_EUR : PD_PER_EUR)
|
||||
}
|
||||
|
||||
/**
|
||||
* Coste del carrito en euros. Se redondea UNA vez, sobre el total: es el importe
|
||||
* que se cobra, y NO la suma de las líneas redondeadas (que daría otro número).
|
||||
*/
|
||||
export function storeEuroTotal(pdTotal: number, vpTotal: number): number {
|
||||
return Math.round((pdTotal / PD_PER_EUR + vpTotal / PV_PER_EUR) * 100) / 100
|
||||
}
|
||||
+14
-2
@@ -3,6 +3,7 @@ import { db, DB } from './db'
|
||||
import { executeSoapCommand } from './soap'
|
||||
import { PD_PER_UNIT } from './dpoints'
|
||||
import { VP_PRICE_FACTOR } from './pay-with-dpoints'
|
||||
import { PD_PER_EUR, PV_PER_EUR, storeEuroTotal as pureEuroTotal } from './store-pricing'
|
||||
|
||||
/**
|
||||
* Tienda de ítems (transfiguración/equipo), portada del sistema antiguo (BENNU).
|
||||
@@ -211,10 +212,21 @@ export async function purchaseStoreCart(
|
||||
* Coste del carrito en euros para pagar con tarjeta. Inverso exacto de cómo se
|
||||
* derivan los precios PD/PV de un precio en euros en el resto de servicios:
|
||||
* 100 PD = 1 € y los PV cuestan VP_PRICE_FACTOR× (200 PV = 1 €).
|
||||
*
|
||||
* La implementación vive en `store-pricing` (módulo puro) para que el carrito
|
||||
* del cliente enseñe EXACTAMENTE el importe que aquí se valida y se cobra. Este
|
||||
* `if` es la red de seguridad de esa duplicación: si alguien cambia PD_PER_UNIT
|
||||
* o VP_PRICE_FACTOR y no toca store-pricing, salta al primer uso en vez de
|
||||
* cobrar mal en silencio.
|
||||
*/
|
||||
export function storeEuroTotal(pdTotal: number, vpTotal: number): number {
|
||||
const eur = pdTotal / PD_PER_UNIT + vpTotal / (PD_PER_UNIT * VP_PRICE_FACTOR)
|
||||
return Math.round(eur * 100) / 100
|
||||
if (PD_PER_UNIT !== PD_PER_EUR || PD_PER_UNIT * VP_PRICE_FACTOR !== PV_PER_EUR) {
|
||||
throw new Error(
|
||||
`store-pricing desincronizado: PD_PER_UNIT=${PD_PER_UNIT}, VP_PRICE_FACTOR=${VP_PRICE_FACTOR}` +
|
||||
` pero store-pricing tiene PD_PER_EUR=${PD_PER_EUR}, PV_PER_EUR=${PV_PER_EUR}`,
|
||||
)
|
||||
}
|
||||
return pureEuroTotal(pdTotal, vpTotal)
|
||||
}
|
||||
|
||||
/** Guarda el carrito como pedido pendiente (para entregarlo tras el pago con tarjeta). */
|
||||
|
||||
@@ -1972,6 +1972,7 @@
|
||||
"payBalance": "PD/VP balance",
|
||||
"payStripe": "Card (Stripe)",
|
||||
"paySumUp": "Card (SumUp)",
|
||||
"minCard": "Minimum {min} €",
|
||||
"confirmCard": "Pay {eur} € by card and send the items to {character}?",
|
||||
"amountTooLow": "The minimum amount to pay by card is {min} €."
|
||||
}
|
||||
|
||||
@@ -1972,6 +1972,7 @@
|
||||
"payBalance": "Saldo PD/PV",
|
||||
"payStripe": "Tarjeta (Stripe)",
|
||||
"paySumUp": "Tarjeta (SumUp)",
|
||||
"minCard": "Mínimo {min} €",
|
||||
"confirmCard": "¿Pagar {eur} € con tarjeta y enviar los objetos a {character}?",
|
||||
"amountTooLow": "El importe mínimo para pagar con tarjeta es {min} €."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user