From 42fc15de596eb5e3e194a49be9c74ad6c3f94698 Mon Sep 17 00:00:00 2001 From: adevopg Date: Mon, 13 Jul 2026 20:22:44 +0000 Subject: [PATCH] =?UTF-8?q?d-points:=20reconciliaci=C3=B3n=20SumUp=20(sin?= =?UTF-8?q?=20webhooks)=20para=20asegurar=20la=20entrega=20de=20PD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SumUp eliminó los webhooks, así que la acreditación no puede depender de que el navegador vuelva al return_url. Se añade reconciliación consultando el estado en la API de SumUp de los checkouts pendientes (fulfilled=0): - reconcileSumUpCheckouts() en lib/sumup.ts: recorre los pendientes recientes y acredita los que estén PAID (reclamo atómico idempotente). - /d-points y /account reconcilian los pendientes de la cuenta al cargar, para reflejar el saldo aunque el usuario no pasara por la página de éxito. - /api/dpoints/reconcile: backstop global protegido con CRON_SECRET, pensado para un systemd timer que lo llame periódicamente. Verificado E2E con tarjeta de test: pago SumUp sin volver a la web -> el backstop acredita los PD (idempotente). Credenciales y CRON_SECRET viven en .env.local (fuera de git). Co-Authored-By: Claude Opus 4.8 --- web-next/app/[locale]/account/page.tsx | 5 +++ web-next/app/[locale]/d-points/page.tsx | 5 +++ web-next/app/api/dpoints/reconcile/route.ts | 27 +++++++++++++ web-next/lib/sumup.ts | 42 +++++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 web-next/app/api/dpoints/reconcile/route.ts 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 +}