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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user