Permitir pagar los servicios con PD, Stripe o SumUp (elección)

Los 9 servicios con precio en euros (rename, customize, change-race,
change-faction, level-up, gold, transfer, restore-item, send-gift) ahora
ofrecen un selector de forma de pago con las 3 opciones: saldo PD, tarjeta
(Stripe) o tarjeta (SumUp).

- lib/dpoints: spendDPoints() descuenta PD de forma atómica (FOR UPDATE).
- lib/pay-with-dpoints: paga con saldo PD, ejecuta la acción al momento y
  reembolsa si la ejecución falla; 100 PD = 1 €.
- rutas checkout (character/[service] y gift): rama provider='pd' que valida
  saldo, descuenta, ejecuta fulfill y devuelve la URL de éxito (sin pasarela).
- service-success: rama provider='pd' (la entrega ya se hizo en el checkout).
- PaymentMethodSelect: componente compartido con coste por método y saldo;
  desactiva PD si no hay saldo suficiente.
- Formularios (PaidServiceForm, Gold, Transfer, RestoreItems, SendGift) y sus
  páginas pasan el saldo PD y envían el método elegido + locale.
- i18n: namespace Pay (es/en); añadidas claves Paid.restore-item que faltaban.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:44:31 +00:00
parent 942fe2e397
commit 1b0920d11f
24 changed files with 420 additions and 66 deletions
@@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getDPointsBalance } from '@/lib/dpoints'
import { getChangeFactionPrice } from '@/lib/prices'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
@@ -36,7 +37,11 @@ export default async function ChangeFactionCharacterPage({ params }: { params: P
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getChangeFactionPrice()])
const [chars, price, pdBalance] = await Promise.all([
getGameCharacters(session.accountId!),
getChangeFactionPrice(),
getDPointsBalance(session.accountId!),
])
const t = await getTranslations('CharServiceB.changeFaction')
@@ -95,8 +100,8 @@ export default async function ChangeFactionCharacterPage({ params }: { params: P
checkoutEndpoint="/api/character/change-faction/checkout"
payLabel={t('payLabel')}
buttonClass="change-faction-button"
provider="sumup"
confirmText={t('confirm', { price })}
priceEur={price}
pdBalance={pdBalance}
/>
</ServiceBox>
</PageShell>
@@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getDPointsBalance } from '@/lib/dpoints'
import { getChangeRacePrice } from '@/lib/prices'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
@@ -24,7 +25,11 @@ export default async function ChangeRaceCharacterPage({ params }: { params: Prom
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getChangeRacePrice()])
const [chars, price, pdBalance] = await Promise.all([
getGameCharacters(session.accountId!),
getChangeRacePrice(),
getDPointsBalance(session.accountId!),
])
const t = await getTranslations('CharServiceB.changeRace')
@@ -61,8 +66,8 @@ export default async function ChangeRaceCharacterPage({ params }: { params: Prom
checkoutEndpoint="/api/character/change-race/checkout"
payLabel={t('payLabel')}
buttonClass="change-race-button"
provider="sumup"
confirmText={t('confirm', { price })}
priceEur={price}
pdBalance={pdBalance}
/>
</ServiceBox>
</PageShell>
@@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getDPointsBalance } from '@/lib/dpoints'
import { getCustomizePrice } from '@/lib/prices'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
@@ -24,7 +25,11 @@ export default async function CustomizeCharacterPage({ params }: { params: Promi
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getCustomizePrice()])
const [chars, price, pdBalance] = await Promise.all([
getGameCharacters(session.accountId!),
getCustomizePrice(),
getDPointsBalance(session.accountId!),
])
const t = await getTranslations('CharServiceB.customize')
@@ -62,8 +67,8 @@ export default async function CustomizeCharacterPage({ params }: { params: Promi
checkoutEndpoint="/api/character/customize/checkout"
payLabel={t('payLabel')}
buttonClass="customize-button"
provider="sumup"
confirmText={t('confirm', { price })}
priceEur={price}
pdBalance={pdBalance}
/>
</ServiceBox>
</PageShell>
@@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getDPointsBalance } from '@/lib/dpoints'
import { getGoldOptions } from '@/lib/prices'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
@@ -26,7 +27,11 @@ export default async function GoldCharacterPage({ params }: { params: Promise<{
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, options] = await Promise.all([getGameCharacters(session.accountId!), getGoldOptions()])
const [chars, options, pdBalance] = await Promise.all([
getGameCharacters(session.accountId!),
getGoldOptions(),
getDPointsBalance(session.accountId!),
])
const t = await getTranslations('CharServiceB.gold')
@@ -72,7 +77,7 @@ export default async function GoldCharacterPage({ params }: { params: Promise<{
<br />
</div>
<GoldForm characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))} options={options} />
<GoldForm characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))} options={options} pdBalance={pdBalance} />
</ServiceBox>
</PageShell>
)
@@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getDPointsBalance } from '@/lib/dpoints'
import { getLevelUpPrice } from '@/lib/prices'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
@@ -24,7 +25,11 @@ export default async function LevelUpCharacterPage({ params }: { params: Promise
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getLevelUpPrice()])
const [chars, price, pdBalance] = await Promise.all([
getGameCharacters(session.accountId!),
getLevelUpPrice(),
getDPointsBalance(session.accountId!),
])
const t = await getTranslations('CharServiceB.levelUp')
@@ -57,8 +62,8 @@ export default async function LevelUpCharacterPage({ params }: { params: Promise
checkoutEndpoint="/api/character/level-up/checkout"
payLabel={t('payLabel')}
buttonClass="level-up-button"
provider="sumup"
confirmText={t('confirm', { price })}
priceEur={price}
pdBalance={pdBalance}
/>
</ServiceBox>
</PageShell>
@@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getDPointsBalance } from '@/lib/dpoints'
import { getRenamePrice } from '@/lib/prices'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
@@ -24,7 +25,11 @@ export default async function RenameCharacterPage({ params }: { params: Promise<
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getRenamePrice()])
const [chars, price, pdBalance] = await Promise.all([
getGameCharacters(session.accountId!),
getRenamePrice(),
getDPointsBalance(session.accountId!),
])
const t = await getTranslations('CharServiceB.rename')
@@ -55,8 +60,8 @@ export default async function RenameCharacterPage({ params }: { params: Promise<
checkoutEndpoint="/api/character/rename/checkout"
payLabel={t('payLabel')}
buttonClass="rename-button"
provider="sumup"
confirmText={t('confirm', { price })}
priceEur={price}
pdBalance={pdBalance}
/>
</ServiceBox>
</PageShell>
+7 -2
View File
@@ -4,6 +4,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getDPointsBalance } from '@/lib/dpoints'
import { getRestoreItemPrice } from '@/lib/prices'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
@@ -26,7 +27,11 @@ export default async function RestoreItemsPage({ params }: { params: Promise<{ l
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getRestoreItemPrice()])
const [chars, price, pdBalance] = await Promise.all([
getGameCharacters(session.accountId!),
getRestoreItemPrice(),
getDPointsBalance(session.accountId!),
])
return (
<PageShell title={t('restoreItems.pageTitle')}>
@@ -63,7 +68,7 @@ export default async function RestoreItemsPage({ params }: { params: Promise<{ l
<br />
<p>{t.rich('restoreItems.requires', { price, s: (c) => <span>{c}</span>, e: (c) => <span className="yellow-info">{c}</span> })}</p>
<br />
<RestoreItemsForm characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))} price={price} />
<RestoreItemsForm characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))} price={price} pdBalance={pdBalance} />
</div>
</ServiceBox>
</PageShell>
+7 -1
View File
@@ -4,6 +4,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect, Link } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getDPointsBalance } from '@/lib/dpoints'
import { getGiftCatalog } from '@/lib/gift'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
@@ -26,7 +27,11 @@ export default async function SendGiftPage({ params }: { params: Promise<{ local
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, catalog] = await Promise.all([getGameCharacters(session.accountId!), getGiftCatalog()])
const [chars, catalog, pdBalance] = await Promise.all([
getGameCharacters(session.accountId!),
getGiftCatalog(),
getDPointsBalance(session.accountId!),
])
const currency = process.env.SUMUP_CURRENCY === 'EUR' ? '€' : process.env.SUMUP_CURRENCY || '€'
return (
@@ -64,6 +69,7 @@ export default async function SendGiftPage({ params }: { params: Promise<{ local
characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))}
catalog={catalog}
currency={currency}
pdBalance={pdBalance}
/>
</ServiceBox>
</PageShell>
@@ -11,18 +11,22 @@ export default async function ServiceSuccessPage({
searchParams,
}: {
params: Promise<{ locale: string }>
searchParams: Promise<{ service?: string; session_id?: string; provider?: string; ref?: string }>
searchParams: Promise<{ service?: string; session_id?: string; provider?: string; ref?: string; name?: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const { service: urlService, session_id, provider, ref } = await searchParams
const { service: urlService, session_id, provider, ref, name } = await searchParams
const t = await getTranslations('Paid')
// Entrega compartida con el webhook/reconciliación (idempotente vía reclamo atómico).
let status: 'delivered' | 'already' | 'error' = 'error'
let okName: string | null = null
let service = urlService ?? null
if (provider === 'sumup' && ref) {
if (provider === 'pd') {
// Pago con saldo PD: la entrega ya se ejecutó en el checkout; aquí solo se muestra.
status = 'delivered'
okName = name ?? null
} else if (provider === 'sumup' && ref) {
const r = await fulfillSumUpCheckout(ref)
service = r.service ?? urlService ?? null
okName = r.character ?? null
@@ -4,6 +4,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect, Link } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getDPointsBalance } from '@/lib/dpoints'
import { getTransferPrice } from '@/lib/prices'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
@@ -26,7 +27,11 @@ export default async function TransferCharacterPage({ params }: { params: Promis
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getTransferPrice()])
const [chars, price, pdBalance] = await Promise.all([
getGameCharacters(session.accountId!),
getTransferPrice(),
getDPointsBalance(session.accountId!),
])
return (
<PageShell title={t('transfer.pageTitle')}>
@@ -66,7 +71,7 @@ export default async function TransferCharacterPage({ params }: { params: Promis
<p>{t.rich('transfer.requires', { price, s: (c) => <span>{c}</span>, e: (c) => <span className="yellow-info">{c}</span> })}</p>
</div>
<TransferForm characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))} price={price} />
<TransferForm characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))} price={price} pdBalance={pdBalance} />
</ServiceBox>
</PageShell>
)
@@ -4,6 +4,12 @@ import { getGameCharacters } from '@/lib/characters'
import { createCheckoutSession } from '@/lib/stripe'
import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup'
import { getPaidService } from '@/lib/paid-services'
import { payServiceWithDPoints } from '@/lib/pay-with-dpoints'
/** Locale seguro (es/en) para construir la URL de retorno. */
function safeLocale(v: unknown): string {
return v === 'en' ? 'en' : 'es'
}
export async function POST(request: Request, { params }: { params: Promise<{ service: string }> }) {
const { service } = await params
@@ -49,6 +55,19 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser
const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0'
const productName = cfg.productName(character, metadata)
// Pago con saldo PD: se descuenta el saldo y se ejecuta la acción al momento
// (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))
if (!r.success) return Response.json({ success: false, error: r.error })
const locale = safeLocale(body.locale)
return Response.json({
success: true,
url: `${site}/${locale}/service-success?service=${service}&provider=pd&name=${encodeURIComponent(character)}`,
})
}
// Pago por SumUp (euros): al pagar, la reconciliación/return ejecuta la acción
// del servicio sobre el personaje (no acredita PD).
if (String(body.provider) === 'sumup') {
+19 -4
View File
@@ -3,7 +3,8 @@ import { getSession } from '@/lib/session'
import { getGameCharacters, getAccountIdByCharacterName } from '@/lib/characters'
import { checkSecurityToken } from '@/lib/security-token'
import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup'
import { priceCart, isValidCharName, type CartLine } from '@/lib/gift'
import { priceCart, isValidCharName, sendGiftByMail, type CartLine } from '@/lib/gift'
import { payServiceWithDPoints } from '@/lib/pay-with-dpoints'
/**
* Inicia el pago (SumUp) de un regalo. Condiciones:
@@ -18,7 +19,7 @@ export async function POST(request: Request) {
const session = await getSession()
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
let body: { source?: string; destination?: string; cart?: CartLine[]; security_token?: string; provider?: string } = {}
let body: { source?: string; destination?: string; cart?: CartLine[]; security_token?: string; provider?: string; locale?: string } = {}
try {
body = await request.json()
} catch {
@@ -58,14 +59,28 @@ export async function POST(request: Request) {
return Response.json({ success: false, message: 'Tu carrito está vacío o contiene objetos no válidos.' })
}
const site = process.env.SITE_URL || ''
const items = priced.lines.map((l) => ({ i: l.itemId, q: l.qty }))
// Pago con saldo PD: se descuenta el saldo y se envía el regalo al momento.
if (String(body.provider) === 'pd') {
const r = await payServiceWithDPoints(session.accountId, priced.total, () =>
sendGiftByMail(destination, source, items),
)
if (!r.success) return Response.json({ success: false, error: r.error, message: 'No se pudo completar el pago con PD.' })
const locale = String(body.locale) === 'en' ? 'en' : 'es'
return Response.json({
success: true,
url: `${site}/${locale}/service-success?service=send-gift&provider=pd&name=${encodeURIComponent(destination)}`,
})
}
if (String(body.provider) !== 'sumup' || !sumupConfigured()) {
return Response.json({ success: false, error: 'notConfigured', message: 'El método de pago no está disponible ahora mismo.' })
}
const site = process.env.SITE_URL || ''
const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0'
const reference = randomUUID()
const items = priced.lines.map((l) => ({ i: l.itemId, q: l.qty }))
const description = `Regalo para ${destination} (${priced.lines.length} objeto${priced.lines.length === 1 ? '' : 's'})`
const r = await createSumUpCheckout({
+34
View File
@@ -482,3 +482,37 @@ textarea:focus {
.twofa-login-info { display: flex; flex-direction: column-reverse; align-items: center; }
.twofa-login-preview { float: none; display: block; clear: both; margin: 10px auto 8px auto; max-width: 90vw; }
}
/* ==== Selector de forma de pago (PD / Stripe / SumUp) ==== */
.pay-method-select { margin: 10px auto 4px auto; max-width: 560px; }
.pay-method-options {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin: 8px 0;
}
.pay-method-option {
flex: 1 1 150px;
min-width: 140px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 10px;
cursor: pointer;
border: 2px solid #352e2b;
border-radius: 8px;
background: rgba(0, 0, 0, 0.25);
transition: border-color .25s, background .25s;
}
.pay-method-option:hover { border-color: #6b5a4e; }
.pay-method-option.selected {
border-color: #d79602;
background: rgba(215, 150, 2, 0.10);
}
.pay-method-option.disabled { cursor: not-allowed; opacity: .55; }
.pay-method-option input[type='radio'] { accent-color: #d79602; }
.pay-method-label { color: #ebdec2; font-weight: bold; }
.pay-method-sub { font-size: 13px; }
.pay-method-balance { margin-top: 2px; font-size: 13px; }
+14 -8
View File
@@ -1,24 +1,29 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useTranslations, useLocale } from 'next-intl'
import type { GoldOption } from '@/lib/prices'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { PaymentMethodSelect, pdCostOf, type PayMethod } from '@/components/PaymentMethodSelect'
export function GoldForm({ characters, options }: { characters: CharOption[]; options: GoldOption[] }) {
export function GoldForm({ characters, options, pdBalance }: { characters: CharOption[]; options: GoldOption[]; pdBalance: number }) {
const t = useTranslations('Services')
const tp = useTranslations('Paid')
const tpay = useTranslations('Pay')
const locale = useLocale()
const [character, setCharacter] = useState('')
const [amount, setAmount] = useState('')
const [method, setMethod] = useState<PayMethod>('pd')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const price = options.find((o) => String(o.gold_amount) === amount)?.price ?? 0
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (busy || !character || !amount) return
const opt = options.find((o) => String(o.gold_amount) === amount)
const price = opt?.price ?? 0
if (!window.confirm(`¿Estás seguro de enviar ${amount} de oro a ${character} por ${price} € (SumUp)?`)) return
const payAmount = method === 'pd' ? tpay('pdCost', { cost: pdCostOf(price) }) : tpay('eur', { price })
if (!window.confirm(tpay('confirm', { amount: payAmount }))) return
setBusy(true)
setError(null)
try {
@@ -26,12 +31,12 @@ export function GoldForm({ characters, options }: { characters: CharOption[]; op
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ character, gold_amount: amount, provider: 'sumup' }),
body: JSON.stringify({ character, gold_amount: amount, provider: method, locale }),
})
const data: { success?: boolean; url?: string; message?: string } = await res.json()
const data: { success?: boolean; url?: string; message?: string; error?: string } = await res.json()
if (data.success && data.url) window.location.href = data.url
else {
setError(data.message || t('genericError'))
setError(data.message || (data.error && tpay.has(`errors.${data.error}`) ? tpay(`errors.${data.error}`) : t('genericError')))
setBusy(false)
}
} catch {
@@ -55,6 +60,7 @@ export function GoldForm({ characters, options }: { characters: CharOption[]; op
</option>
))}
</select>
{amount && <PaymentMethodSelect value={method} onChange={setMethod} priceEur={price} pdBalance={pdBalance} />}
<br />
<button type="submit" className="gold-button" disabled={busy || !character || !amount}>
{busy ? t('processing') : tp('gold.send')}
+22 -13
View File
@@ -1,29 +1,38 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useTranslations, useLocale } from 'next-intl'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { PaymentMethodSelect, pdCostOf, type PayMethod } from '@/components/PaymentMethodSelect'
interface Props {
characters: CharOption[]
checkoutEndpoint: string
payLabel: string // ya formateado con el precio
payLabel: string // texto de acción del botón
priceEur: number // precio del servicio en euros (para calcular el coste en PD)
pdBalance: number // saldo PD de la cuenta
buttonClass?: string
provider?: 'stripe' | 'sumup' // pasarela; por defecto Stripe
confirmText?: string // si se pasa, pide confirmación antes de redirigir al pago
}
/** Selector de personaje + botón de pago. Redirige al Checkout (Stripe o SumUp). */
export function PaidServiceForm({ characters, checkoutEndpoint, payLabel, buttonClass = '', provider, confirmText }: Props) {
/** Selector de personaje + forma de pago (PD / Stripe / SumUp) + botón. */
export function PaidServiceForm({ characters, checkoutEndpoint, payLabel, priceEur, pdBalance, buttonClass = '' }: Props) {
const t = useTranslations('Services')
const tp = useTranslations('Pay')
const locale = useLocale()
const [character, setCharacter] = useState('')
const [method, setMethod] = useState<PayMethod>(pdBalance >= pdCostOf(priceEur) ? 'pd' : 'sumup')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
function msgFor(err?: string): string {
return err && tp.has(`errors.${err}`) ? tp(`errors.${err}`) : t('genericError')
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (busy || !character) return
if (confirmText && !window.confirm(confirmText)) return
const amount = method === 'pd' ? tp('pdCost', { cost: pdCostOf(priceEur) }) : tp('eur', { price: priceEur })
if (!window.confirm(tp('confirm', { amount }))) return
setBusy(true)
setError(null)
try {
@@ -31,13 +40,13 @@ export function PaidServiceForm({ characters, checkoutEndpoint, payLabel, button
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ character, ...(provider ? { provider } : {}) }),
body: JSON.stringify({ character, provider: method, locale }),
})
const data: { success?: boolean; url?: string; message?: string } = await res.json()
const data: { success?: boolean; url?: string; message?: string; error?: string } = await res.json()
if (data.success && data.url) {
window.location.href = data.url // Checkout (Stripe o SumUp)
window.location.href = data.url // éxito PD, o Checkout (Stripe/SumUp)
} else {
setError(data.message || t('genericError'))
setError(data.message || msgFor(data.error))
setBusy(false)
}
} catch {
@@ -54,9 +63,9 @@ export function PaidServiceForm({ characters, checkoutEndpoint, payLabel, button
<div className="centered">
<p>{t('choose')}</p>
<br />
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<form onSubmit={handleSubmit} acceptCharset="utf-8" noValidate>
<CharacterSelect characters={characters} value={character} onChange={setCharacter} placeholder={t('selectCharacter')} />
<br />
<PaymentMethodSelect value={method} onChange={setMethod} priceEur={priceEur} pdBalance={pdBalance} />
<button type="submit" className={buttonClass} disabled={busy || !character}>
{busy ? t('processing') : payLabel}
</button>
@@ -0,0 +1,71 @@
'use client'
import { useTranslations } from 'next-intl'
export type PayMethod = 'pd' | 'stripe' | 'sumup'
// 100 PD = 1 € (igual que PD_PER_UNIT en lib/dpoints, que no se importa aquí
// para no arrastrar el acceso a BD al bundle del cliente).
const PD_PER_UNIT = 100
/** Coste en PD (redondeado) de un precio en euros. */
export function pdCostOf(priceEur: number): number {
return Math.round(priceEur * PD_PER_UNIT)
}
/**
* Selector de forma de pago: saldo PD, tarjeta (Stripe) o tarjeta (SumUp).
* Muestra el coste de cada método y desactiva PD si no hay saldo suficiente.
*/
export function PaymentMethodSelect({
value,
onChange,
priceEur,
pdBalance,
}: {
value: PayMethod
onChange: (m: PayMethod) => void
priceEur: number
pdBalance: number
}) {
const t = useTranslations('Pay')
const cost = pdCostOf(priceEur)
const canPd = pdBalance >= cost && cost > 0
const options: { id: PayMethod; label: string; sub: string; disabled?: boolean }[] = [
{
id: 'pd',
label: t('pd'),
sub: canPd ? t('pdCost', { cost }) : t('insufficient', { cost }),
disabled: !canPd,
},
{ id: 'stripe', label: t('stripe'), sub: t('eur', { price: priceEur }) },
{ id: 'sumup', label: t('sumup'), sub: t('eur', { price: priceEur }) },
]
return (
<div className="pay-method-select">
<p className="second-brown">{t('method')}</p>
<div className="pay-method-options">
{options.map((o) => (
<label
key={o.id}
className={`pay-method-option${value === o.id ? ' selected' : ''}${o.disabled ? ' disabled' : ''}`}
>
<input
type="radio"
name="pay-method"
value={o.id}
checked={value === o.id}
disabled={o.disabled}
onChange={() => onChange(o.id)}
/>
<span className="pay-method-label">{o.label}</span>
<span className={`pay-method-sub ${o.disabled ? 'red-form-response' : 'yellow-info'}`}>{o.sub}</span>
</label>
))}
</div>
<p className="third-brown pay-method-balance">{t('balance', { balance: pdBalance })}</p>
</div>
)
}
+13 -5
View File
@@ -1,8 +1,9 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useTranslations, useLocale } from 'next-intl'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { PaymentMethodSelect, pdCostOf, type PayMethod } from '@/components/PaymentMethodSelect'
import { Turnstile } from '@/components/Turnstile'
const ITEM_DB = 'https://wotlk.novawow.com'
@@ -33,9 +34,12 @@ function itemsError(t: ReturnType<typeof useTranslations>, error?: string, minut
}
}
export function RestoreItemsForm({ characters, price }: { characters: CharOption[]; price: number }) {
export function RestoreItemsForm({ characters, price, pdBalance }: { characters: CharOption[]; price: number; pdBalance: number }) {
const t = useTranslations('CharService')
const tpay = useTranslations('Pay')
const locale = useLocale()
const [character, setCharacter] = useState('')
const [method, setMethod] = useState<PayMethod>(pdBalance >= pdCostOf(price) ? 'pd' : 'sumup')
const [captcha, setCaptcha] = useState('')
const [captchaKey, setCaptchaKey] = useState(0)
const [busy, setBusy] = useState(false)
@@ -80,14 +84,15 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ character, recover_id: String(item.recoverId), provider: 'sumup' }),
body: JSON.stringify({ character, recover_id: String(item.recoverId), provider: method, locale }),
})
const data: { success?: boolean; url?: string; error?: string; message?: string } = await res.json()
if (data.success && data.url) {
window.location.assign(data.url) // checkout de SumUp
window.location.assign(data.url) // éxito PD, o checkout de SumUp/Stripe
return
}
setMessage({ ok: false, text: data.message || itemsError(t, data.error) })
const payErr = data.error && tpay.has(`errors.${data.error}`) ? tpay(`errors.${data.error}`) : undefined
setMessage({ ok: false, text: data.message || payErr || itemsError(t, data.error) })
setBusy(false)
} catch {
setMessage({ ok: false, text: itemsError(t) })
@@ -122,6 +127,8 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
{items.length === 0 ? (
<p className="second-brown">{t('restoreItems.noDeletedItems')}</p>
) : (
<>
<PaymentMethodSelect value={method} onChange={setMethod} priceEur={price} pdBalance={pdBalance} />
<table className="restore-item-table">
<tbody>
{items.map((it) => (
@@ -139,6 +146,7 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
))}
</tbody>
</table>
</>
)}
<br />
<p><a id="a-select-other" href="#" onClick={(e) => { e.preventDefault(); reset() }}>{t('restoreItems.queryOther')}</a></p>
+13 -4
View File
@@ -1,9 +1,10 @@
'use client'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useTranslations, useLocale } from 'next-intl'
import type { GiftCategory, GiftItem } from '@/lib/gift'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { PaymentMethodSelect, type PayMethod } from '@/components/PaymentMethodSelect'
// Tope de cantidad en la interfaz. El servidor lo revalida en priceCart (fuente de verdad).
const MAX_QTY = 100
@@ -49,16 +50,21 @@ export function SendGiftForm({
characters,
catalog,
currency = '€',
pdBalance = 0,
}: {
characters: CharOption[]
catalog: GiftCategory[]
currency?: string
pdBalance?: number
}) {
const t = useTranslations('CharService')
const tpay = useTranslations('Pay')
const locale = useLocale()
const [source, setSource] = useState('')
const [destination, setDestination] = useState('')
const [token, setToken] = useState('')
const [cart, setCart] = useState<Map<number, CartEntry>>(new Map())
const [method, setMethod] = useState<PayMethod>('pd')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -133,13 +139,15 @@ export function SendGiftForm({
destination: destination.trim(),
security_token: token.trim(),
cart: [...cart.values()].map((e) => ({ id: e.item.id, qty: e.qty })),
provider: 'sumup',
provider: method,
locale,
}),
})
const data: { success?: boolean; url?: string; message?: string } = await res.json()
const data: { success?: boolean; url?: string; message?: string; error?: string } = await res.json()
if (data.success && data.url) window.location.href = data.url
else {
setError(data.message || t('sendGift.errPayment'))
const payErr = data.error && tpay.has(`errors.${data.error}`) ? tpay(`errors.${data.error}`) : undefined
setError(data.message || payErr || t('sendGift.errPayment'))
setBusy(false)
}
} catch {
@@ -252,6 +260,7 @@ export function SendGiftForm({
<p>
{t.rich('sendGift.total', { total, currency, s: (c) => <span className="yellow-info">{c}</span> })}
</p>
{total > 0 && <PaymentMethodSelect value={method} onChange={setMethod} priceEur={total} pdBalance={pdBalance} />}
<br />
<button
type="submit"
+8 -1
View File
@@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getDPointsBalance } from '@/lib/dpoints'
import { getPaidService } from '@/lib/paid-services'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
@@ -19,7 +20,11 @@ export async function ServicePageContent({ service, locale }: { service: string;
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), cfg!.price({})])
const [chars, price, pdBalance] = await Promise.all([
getGameCharacters(session.accountId!),
cfg!.price({}),
getDPointsBalance(session.accountId!),
])
return (
<PageShell title={t(`${service}.title`)}>
@@ -29,6 +34,8 @@ export async function ServicePageContent({ service, locale }: { service: string;
checkoutEndpoint={`/api/character/${service}/checkout`}
payLabel={t(`${service}.pay`, { price })}
buttonClass={`${service}-button`}
priceEur={price}
pdBalance={pdBalance}
/>
</ServiceBox>
</PageShell>
+11 -6
View File
@@ -1,8 +1,9 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useTranslations, useLocale } from 'next-intl'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { PaymentMethodSelect, pdCostOf, type PayMethod } from '@/components/PaymentMethodSelect'
/** Campo con ojo para mostrar/ocultar (contraseña / token). */
function SecretInput({
@@ -42,12 +43,15 @@ function SecretInput({
)
}
export function TransferForm({ characters, price }: { characters: CharOption[]; price: number }) {
export function TransferForm({ characters, price, pdBalance }: { characters: CharOption[]; price: number; pdBalance: number }) {
const t = useTranslations('CharService')
const tpay = useTranslations('Pay')
const locale = useLocale()
const [character, setCharacter] = useState('')
const [destination, setDestination] = useState('')
const [password, setPassword] = useState('')
const [token, setToken] = useState('')
const [method, setMethod] = useState<PayMethod>(pdBalance >= pdCostOf(price) ? 'pd' : 'sumup')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -67,13 +71,14 @@ export function TransferForm({ characters, price }: { characters: CharOption[];
destination_account: destination.trim(),
password,
security_token: token.trim(),
provider: 'sumup',
provider: method,
locale,
}),
})
const data: { success?: boolean; url?: string; message?: string } = await res.json()
const data: { success?: boolean; url?: string; message?: string; error?: string } = await res.json()
if (data.success && data.url) window.location.href = data.url
else {
setError(data.message || t('transfer.errorGeneric'))
setError(data.message || (data.error && tpay.has(`errors.${data.error}`) ? tpay(`errors.${data.error}`) : t('transfer.errorGeneric')))
setBusy(false)
}
} catch {
@@ -94,7 +99,7 @@ export function TransferForm({ characters, price }: { characters: CharOption[];
<SecretInput id="password" value={password} onChange={setPassword} placeholder={t('transfer.passwordPlaceholder')} maxLength={16} toggleClass="toggle-password" />
<br />
<SecretInput id="security-token" value={token} onChange={setToken} placeholder={t('transfer.tokenPlaceholder')} maxLength={6} toggleClass="toggle-token" />
<br />
<PaymentMethodSelect value={method} onChange={setMethod} priceEur={price} pdBalance={pdBalance} />
<button type="submit" className="transfer-button" disabled={busy || !character || !destination.trim() || !password || !token.trim()}>
{busy ? t('transfer.submitting') : t('transfer.submit')}
</button>
+40
View File
@@ -85,3 +85,43 @@ export async function transferDPoints(
conn.release()
}
}
/**
* Descuenta `points` PD del saldo de una cuenta de forma atómica (bloqueo de
* fila para evitar dobles gastos concurrentes). Se usa para pagar servicios con
* el saldo PD. Devuelve `insufficientFunds` si no hay saldo suficiente.
* Si la acción posterior falla, reembolsa con `creditDPoints`.
*/
export async function spendDPoints(
accountId: number,
points: number,
): Promise<{ success: boolean; error?: string }> {
if (!accountId || !Number.isInteger(points) || points <= 0) {
return { success: false, error: 'invalidRequest' }
}
const conn = await db(DB.default).getConnection()
try {
await conn.beginTransaction()
const [src] = await conn.query<RowDataPacket[]>(
'SELECT dp FROM home_api_points WHERE accountID = ? FOR UPDATE',
[accountId],
)
const balance = src[0] ? Number(src[0].dp) : 0
if (balance < points) {
await conn.rollback()
return { success: false, error: 'insufficientFunds' }
}
await conn.query('UPDATE home_api_points SET dp = dp - ? WHERE accountID = ?', [points, accountId])
await conn.commit()
return { success: true }
} catch {
try {
await conn.rollback()
} catch {
/* ignore */
}
return { success: false, error: 'genericError' }
} finally {
conn.release()
}
}
+35
View File
@@ -0,0 +1,35 @@
import { spendDPoints, creditDPoints, PD_PER_UNIT } from './dpoints'
/** Coste en PD de un precio en euros (100 PD = 1 €). */
export function dpointsCost(priceEur: number): number {
return Math.round(priceEur * PD_PER_UNIT)
}
/**
* Paga un servicio con el saldo PD: descuenta el coste de forma atómica y
* ejecuta la acción al momento (sin pasarela). Si la ejecución falla, reembolsa
* el saldo. Devuelve `insufficientPd` si no hay saldo suficiente.
*/
export async function payServiceWithDPoints(
accountId: number,
priceEur: number,
fulfill: () => Promise<boolean>,
): Promise<{ success: boolean; error?: string }> {
const cost = dpointsCost(priceEur)
if (!(cost > 0)) return { success: false, error: 'invalidRequest' }
const spent = await spendDPoints(accountId, cost)
if (!spent.success) {
return { success: false, error: spent.error === 'insufficientFunds' ? 'insufficientPd' : 'genericError' }
}
let ok = false
try {
ok = await fulfill()
} catch {
ok = false
}
if (!ok) {
await creditDPoints(accountId, cost) // reembolso si no se pudo ejecutar
return { success: false, error: 'fulfillFailed' }
}
return { success: true }
}
+23
View File
@@ -354,6 +354,11 @@
"send-gift": {
"title": "Send gift",
"success": "The gift has been mailed to character {name}."
},
"restore-item": {
"title": "Restore item",
"pay": "Restore for {price} €",
"success": "The item has been returned to character {name}."
}
},
"SecurityToken": {
@@ -1894,5 +1899,23 @@
"disabling": "Disabling...",
"disable": "Disable 2FA"
}
},
"Pay": {
"method": "Payment method",
"pd": "PD balance",
"stripe": "Card (Stripe)",
"sumup": "Card (SumUp)",
"pdCost": "{cost} PD",
"eur": "{price} €",
"balance": "Available balance: {balance} PD",
"insufficient": "Insufficient balance ({cost} PD)",
"confirm": "Confirm the payment of {amount}?",
"errors": {
"insufficientPd": "You don't have enough PD balance for this service.",
"fulfillFailed": "The service could not be completed. Your PD balance has been refunded.",
"notConfigured": "This payment method is not available right now.",
"invalidRequest": "Invalid request.",
"genericError": "An error occurred. Please try again."
}
}
}
+23
View File
@@ -354,6 +354,11 @@
"send-gift": {
"title": "Enviar regalo",
"success": "El regalo ha sido enviado por correo al personaje {name}."
},
"restore-item": {
"title": "Recuperar ítem",
"pay": "Recuperar por {price} €",
"success": "El ítem ha sido devuelto al personaje {name}."
}
},
"SecurityToken": {
@@ -1894,5 +1899,23 @@
"disabling": "Desactivando...",
"disable": "Desactivar 2FA"
}
},
"Pay": {
"method": "Forma de pago",
"pd": "Saldo PD",
"stripe": "Tarjeta (Stripe)",
"sumup": "Tarjeta (SumUp)",
"pdCost": "{cost} PD",
"eur": "{price} €",
"balance": "Saldo disponible: {balance} PD",
"insufficient": "Saldo insuficiente ({cost} PD)",
"confirm": "¿Confirmas el pago de {amount}?",
"errors": {
"insufficientPd": "No tienes saldo PD suficiente para este servicio.",
"fulfillFailed": "No se pudo completar el servicio. Se te ha devuelto el saldo PD.",
"notConfigured": "Esta forma de pago no está disponible ahora mismo.",
"invalidRequest": "Solicitud no válida.",
"genericError": "Ha ocurrido un error. Inténtalo de nuevo."
}
}
}