From 389dac68c4b2848ed08a042deee45f54867de485 Mon Sep 17 00:00:00 2001 From: adevopg Date: Mon, 13 Jul 2026 00:55:18 +0000 Subject: [PATCH] Stripe: webhook que entrega el servicio aunque el usuario no vuelva MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/api/stripe/webhook: verifica firma (constructEventAsync) y en checkout.session.completed registra la IP de Stripe (como Django) y ENTREGA el servicio. Idempotente frente a la página de éxito. - lib/stripe.ts: claimPaidCheckout ahora es ATÓMICO (UPDATE ... WHERE fulfilled=0 + affectedRows) para evitar doble entrega entre webhook y página de éxito; nuevo recordStripeIp. - lib/fulfill.ts: fulfillCheckoutSession compartido (reclama + resuelve servicio desde metadata.service + ejecuta). checkout guarda `service` en metadata. - service-success: usa la entrega compartida y distingue entregado/ya-procesado/error. - i18n: Paid.alreadyProcessed. Requiere configurar STRIPE_WEBHOOK_SECRET en .env.local (endpoint apuntando a /api/stripe/webhook, evento checkout.session.completed). Verificado: build OK, webhook 400 sin firma / firma inválida. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/[locale]/service-success/page.tsx | 32 +++++++++------ .../api/character/[service]/checkout/route.ts | 5 ++- web-next/app/api/stripe/webhook/route.ts | 39 +++++++++++++++++++ web-next/lib/fulfill.ts | 31 +++++++++++++++ web-next/lib/stripe.ts | 23 +++++++++-- web-next/messages/en.json | 3 +- web-next/messages/es.json | 3 +- 7 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 web-next/app/api/stripe/webhook/route.ts create mode 100644 web-next/lib/fulfill.ts diff --git a/web-next/app/[locale]/service-success/page.tsx b/web-next/app/[locale]/service-success/page.tsx index 435a728..53127b5 100644 --- a/web-next/app/[locale]/service-success/page.tsx +++ b/web-next/app/[locale]/service-success/page.tsx @@ -1,6 +1,6 @@ import { getTranslations, setRequestLocale } from 'next-intl/server' -import { claimPaidCheckout } from '@/lib/stripe' -import { getPaidService } from '@/lib/paid-services' +import { fulfillCheckoutSession } from '@/lib/fulfill' +import { isSessionPaid } from '@/lib/stripe' export const dynamic = 'force-dynamic' @@ -13,25 +13,33 @@ export default async function ServiceSuccessPage({ }) { const { locale } = await params setRequestLocale(locale) - const { service, session_id } = await searchParams + const { service: urlService, session_id } = await searchParams const t = await getTranslations('Paid') - const cfg = service ? getPaidService(service) : null - // Verifica el pago (una vez) y ejecuta el comando SOAP del servicio. + // Entrega compartida con el webhook (idempotente). Si el webhook ya entregó, + // el reclamo atómico devuelve ok=false pero el pago sigue confirmado. + let status: 'delivered' | 'already' | 'error' = 'error' let okName: string | null = null - if (cfg && session_id) { - const claim = await claimPaidCheckout(session_id) - if (claim) { - const ok = await cfg.fulfill(claim.characterName, claim.metadata) - if (ok) okName = claim.characterName + let service = urlService ?? null + if (session_id) { + const r = await fulfillCheckoutSession(session_id, urlService) + service = r.service ?? urlService ?? null + if (r.ok) { + status = 'delivered' + okName = r.character + } else if (await isSessionPaid(session_id)) { + // Pago confirmado pero ya reclamado antes (webhook o recarga de la página). + status = 'already' } } return (

{service ? t(`${service}.title`) : ''}

- {okName ? ( -

{t(`${service}.success`, { name: okName })}

+ {status === 'delivered' ? ( +

{t(`${service}.success`, { name: okName ?? '' })}

+ ) : status === 'already' ? ( +

{t('alreadyProcessed')}

) : (

{t('error')}

)} diff --git a/web-next/app/api/character/[service]/checkout/route.ts b/web-next/app/api/character/[service]/checkout/route.ts index 24bfc46..e95af15 100644 --- a/web-next/app/api/character/[service]/checkout/route.ts +++ b/web-next/app/api/character/[service]/checkout/route.ts @@ -25,8 +25,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser return Response.json({ success: false, error: 'invalidCharacter' }) } - // Campos extra del servicio (gold_amount, destination_account...) -> metadata - const metadata: Record = {} + // Campos extra del servicio (gold_amount, destination_account...) -> metadata. + // `service` va en metadata para que el webhook sepa qué entregar sin la URL. + const metadata: Record = { service } for (const f of cfg.extraFields) { const v = String(body[f] ?? '').trim() if (!v) return Response.json({ success: false, error: 'missingFields' }) diff --git a/web-next/app/api/stripe/webhook/route.ts b/web-next/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..73355d5 --- /dev/null +++ b/web-next/app/api/stripe/webhook/route.ts @@ -0,0 +1,39 @@ +import Stripe from 'stripe' +import { recordStripeIp } from '@/lib/stripe' +import { fulfillCheckoutSession } from '@/lib/fulfill' + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string) + +/** + * Webhook de Stripe. Verifica la firma y, en `checkout.session.completed`: + * - registra la IP de Stripe (como Django), + * - ENTREGA el servicio (idempotente vía claim atómico), de modo que el pago se + * cumple aunque el usuario cierre el navegador antes de volver a la web. + */ +export async function POST(request: Request) { + const secret = process.env.STRIPE_WEBHOOK_SECRET + const sig = request.headers.get('stripe-signature') + if (!secret || !sig) return Response.json({ success: false, message: 'missingSignature' }, { status: 400 }) + + const payload = await request.text() + let event: Stripe.Event + try { + event = await stripe.webhooks.constructEventAsync(payload, sig, secret) + } catch { + return Response.json({ success: false, message: 'invalidSignature' }, { status: 400 }) + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session + const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0' + await recordStripeIp(session.id, ip) + // Entrega best-effort; los errores no deben provocar reintentos infinitos de Stripe. + try { + await fulfillCheckoutSession(session.id) + } catch { + /* el reclamo atómico impide dobles entregas; se puede reintentar desde la web */ + } + } + + return Response.json({ success: true }) +} diff --git a/web-next/lib/fulfill.ts b/web-next/lib/fulfill.ts new file mode 100644 index 0000000..bf46f27 --- /dev/null +++ b/web-next/lib/fulfill.ts @@ -0,0 +1,31 @@ +import { claimPaidCheckout } from './stripe' +import { getPaidService } from './paid-services' + +export interface FulfillResult { + ok: boolean + service: string | null + character: string | null +} + +/** + * Entrega un checkout pagado: reclama (atómico, una sola vez), resuelve el servicio + * desde metadata.service y ejecuta su acción. Reutilizable por la página de éxito y + * por el webhook, de modo que el pago se cumple aunque el usuario no vuelva. + * + * `fallbackService` permite a la página de éxito pasar el servicio de la URL para + * sesiones antiguas cuyo metadata no lo incluía. + */ +export async function fulfillCheckoutSession( + sessionId: string, + fallbackService?: string, +): Promise { + const claim = await claimPaidCheckout(sessionId) + if (!claim) return { ok: false, service: fallbackService ?? null, character: null } + + const service = claim.metadata.service || fallbackService || '' + const cfg = service ? getPaidService(service) : null + if (!cfg) return { ok: false, service: service || null, character: claim.characterName } + + const ok = await cfg.fulfill(claim.characterName, claim.metadata) + return { ok, service, character: claim.characterName } +} diff --git a/web-next/lib/stripe.ts b/web-next/lib/stripe.ts index 8a0bb45..19f2c1f 100644 --- a/web-next/lib/stripe.ts +++ b/web-next/lib/stripe.ts @@ -1,5 +1,5 @@ import Stripe from 'stripe' -import type { RowDataPacket } from 'mysql2' +import type { RowDataPacket, ResultSetHeader } from 'mysql2' import { db, DB } from './db' const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string) @@ -82,11 +82,26 @@ export async function claimPaidCheckout( return null } const [rows] = await db(DB.default).query( - 'SELECT id, character_name, fulfilled FROM home_stripelog WHERE session_id = ?', + 'SELECT id, character_name FROM home_stripelog WHERE session_id = ?', [sessionId], ) const log = rows[0] - if (!log || log.fulfilled) return null - await db(DB.default).query('UPDATE home_stripelog SET fulfilled = 1 WHERE id = ?', [log.id]) + if (!log) return null + // Reclamo ATÓMICO: solo gana quien pasa fulfilled 0->1. Evita doble entrega si + // el webhook y la página de éxito corren a la vez. + const [res] = await db(DB.default).query( + 'UPDATE home_stripelog SET fulfilled = 1 WHERE id = ? AND fulfilled = 0', + [log.id], + ) + if (res.affectedRows === 0) return null return { characterName: log.character_name, metadata } } + +/** Registra la IP de Stripe y actualiza el timestamp del log (como el webhook de Django). */ +export async function recordStripeIp(sessionId: string, ip: string): Promise { + if (!sessionId) return + await db(DB.default).query('UPDATE home_stripelog SET stripe_ip = ?, timestamp = NOW() WHERE session_id = ?', [ + ip.slice(0, 45), + sessionId, + ]) +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 49cd613..76af20a 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -181,7 +181,8 @@ "pay": "Transfer for {price} €", "destination": "Destination account", "success": "Character {name} has been transferred to the destination account." - } + }, + "alreadyProcessed": "Your payment is confirmed and the reward was already delivered." }, "SecurityToken": { "title": "Security token", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 557160c..e52a96f 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -181,7 +181,8 @@ "pay": "Transferir por {price} €", "destination": "Cuenta de destino", "success": "El personaje {name} ha sido transferido a la cuenta de destino." - } + }, + "alreadyProcessed": "Tu pago se ha confirmado y la recompensa ya fue entregada." }, "SecurityToken": { "title": "Token de seguridad",