diff --git a/web-next/app/[locale]/account/page.tsx b/web-next/app/[locale]/account/page.tsx index 7e638e3..ab3f0e7 100644 --- a/web-next/app/[locale]/account/page.tsx +++ b/web-next/app/[locale]/account/page.tsx @@ -2,6 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server' import { redirect, Link } from '@/i18n/navigation' import { getSession } from '@/lib/session' import { getAccountDashboard, getAccountRank } from '@/lib/account' +import { reconcileSumUpCheckouts } from '@/lib/sumup' import { getGameAccounts } from '@/lib/auth' import { isAdmin } from '@/lib/admin' import { PageShell } from '@/components/PageShell' @@ -38,6 +39,10 @@ export default async function AccountPage({ params }: { params: Promise<{ locale if (!session.bnetId) redirect({ href: '/login', locale }) if (!session.username) redirect({ href: '/select-account', locale }) + // Acredita cualquier pago SumUp pendiente de esta cuenta (SumUp no tiene + // webhooks) antes de leer el saldo de PD, para que se muestre actualizado. + if (session.accountId) await reconcileSumUpCheckouts({ accountId: session.accountId }).catch(() => {}) + const data = await getAccountDashboard(session) const admin = await isAdmin(session) const rank = getAccountRank(data.dp) diff --git a/web-next/app/[locale]/d-points/page.tsx b/web-next/app/[locale]/d-points/page.tsx index 7d5323c..ebd6813 100644 --- a/web-next/app/[locale]/d-points/page.tsx +++ b/web-next/app/[locale]/d-points/page.tsx @@ -3,6 +3,7 @@ import { setRequestLocale } from 'next-intl/server' import { redirect } from '@/i18n/navigation' import { getSession } from '@/lib/session' import { getRealmName } from '@/lib/realm' +import { reconcileSumUpCheckouts } from '@/lib/sumup' import { PageShell } from '@/components/PageShell' import { DPointsTabs } from '@/components/DPointsTabs' @@ -18,6 +19,10 @@ export default async function DPointsPage({ params }: { params: Promise<{ locale if (!session.bnetId) redirect({ href: '/login', locale }) if (!session.username) redirect({ href: '/select-account', locale }) + // SumUp no tiene webhooks: reconciliamos aquí los pagos SumUp pendientes de + // esta cuenta por si el navegador no volvió al return_url tras pagar. + if (session.accountId) await reconcileSumUpCheckouts({ accountId: session.accountId }).catch(() => {}) + const realm = await getRealmName() const currency = (process.env.DP_CURRENCY || 'eur').toLowerCase() diff --git a/web-next/app/api/dpoints/reconcile/route.ts b/web-next/app/api/dpoints/reconcile/route.ts new file mode 100644 index 0000000..f3e1a3f --- /dev/null +++ b/web-next/app/api/dpoints/reconcile/route.ts @@ -0,0 +1,27 @@ +import { reconcileSumUpCheckouts } from '@/lib/sumup' + +/** + * Backstop de reconciliación para SumUp (que ya no ofrece webhooks): revisa los + * checkouts SumUp pendientes y acredita los que estén PAID. Pensado para un cron + * (systemd timer) que lo llame periódicamente. Protegido con CRON_SECRET. + */ +async function run(request: Request): Promise { + const secret = process.env.CRON_SECRET + if (!secret) return Response.json({ success: false, error: 'notConfigured' }, { status: 503 }) + + const provided = + request.headers.get('x-cron-secret') || + (request.headers.get('authorization') || '').replace(/^Bearer\s+/i, '') + if (provided !== secret) return Response.json({ success: false, error: 'unauthorized' }, { status: 401 }) + + const credited = await reconcileSumUpCheckouts({}) + return Response.json({ success: true, credited }) +} + +export async function POST(request: Request) { + return run(request) +} + +export async function GET(request: Request) { + return run(request) +} diff --git a/web-next/lib/sumup.ts b/web-next/lib/sumup.ts index e3843ab..48a5da9 100644 --- a/web-next/lib/sumup.ts +++ b/web-next/lib/sumup.ts @@ -106,3 +106,45 @@ export async function fulfillSumUpCheckout(reference: string): Promise<{ ok: boo const ok = await creditDPoints(Number(log.account_id), points) return { ok, paid: true } } + +/** + * Reconciliación: SumUp ya no ofrece webhooks, así que la entrega no puede + * depender de que el navegador vuelva al `return_url`. Esta función recorre los + * checkouts SumUp pendientes (fulfilled=0) registrados en `home_stripelog`, + * consulta su estado en la API de SumUp y acredita los que estén PAID. + * + * - Sin `accountId`: backstop global (para un cron). Con `accountId`: reconcilia + * sólo los pendientes de esa cuenta (al cargar /account o /d-points). + * - Acota por antigüedad: los no pagados acaban EXPIRED en SumUp, no tiene + * sentido reconsultarlos indefinidamente. + */ +export async function reconcileSumUpCheckouts(opts?: { accountId?: number; maxAgeDays?: number }): Promise { + if (!sumupConfigured()) return 0 + const maxAgeDays = opts?.maxAgeDays ?? 7 + let sql = + "SELECT session_id FROM home_stripelog WHERE mode = 'sumup' AND fulfilled = 0 AND timestamp > (NOW() - INTERVAL ? DAY)" + const params: (string | number)[] = [maxAgeDays] + if (opts?.accountId) { + sql += ' AND account_id = ?' + params.push(opts.accountId) + } + sql += ' ORDER BY id DESC LIMIT 50' + + let rows: RowDataPacket[] + try { + ;[rows] = await db(DB.default).query(sql, params) + } catch { + return 0 + } + + let credited = 0 + for (const r of rows) { + try { + const res = await fulfillSumUpCheckout(String(r.session_id)) + if (res.ok) credited++ + } catch { + /* seguir con el resto */ + } + } + return credited +}