import type { RowDataPacket, ResultSetHeader } from 'mysql2' import { db, DB } from './db' import { creditDPoints, PD_PER_UNIT } from './dpoints' import { getPaidService } from './paid-services' const API = 'https://api.sumup.com/v0.1' /** SumUp está disponible sólo si hay API key y merchant code configurados. */ export function sumupConfigured(): boolean { return Boolean(process.env.SUMUP_API_KEY && process.env.SUMUP_MERCHANT_CODE) } function authHeaders(): Record { return { Authorization: `Bearer ${process.env.SUMUP_API_KEY}`, 'Content-Type': 'application/json', } } interface CreateParams { accountId: number username: string email: string acoreIp: string amount: number // importe en la moneda del comercio points: number reference: string description: string returnUrl: string // Para pagos de servicio (renombrar, etc.): al pagar se ejecuta la acción del // servicio sobre `characterName` en vez de acreditar PD. Sin `service` = compra de PD. service?: string characterName?: string // Campos extra del servicio (p.ej. { gold_amount }) que la entrega necesita. metadata?: Record } /** * Crea un checkout hospedado de SumUp y registra el pago en `home_stripelog` * (reutilizamos la tabla de logs; `mode = 'sumup'`, `session_id = reference`). * Devuelve la URL del checkout hospedado para redirigir al usuario. */ export async function createSumUpCheckout(p: CreateParams): Promise<{ success: boolean; url?: string; error?: string }> { if (!sumupConfigured()) return { success: false, error: 'notConfigured' } try { const res = await fetch(`${API}/checkouts`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ checkout_reference: p.reference, amount: p.amount, currency: process.env.SUMUP_CURRENCY || 'EUR', merchant_code: process.env.SUMUP_MERCHANT_CODE, description: p.description, return_url: p.returnUrl, hosted_checkout: { enabled: true }, }), }) if (!res.ok) return { success: false, error: `sumup_${res.status}` } const data: { id?: string; hosted_checkout_url?: string } = await res.json() const url = data.hosted_checkout_url if (!url) return { success: false, error: 'noHostedUrl' } await db(DB.default).query( 'INSERT INTO home_stripelog (account_id, username, email, acore_ip, stripe_ip, product_name, amount, session_id, mode, character_name, service, metadata, timestamp, fulfilled) ' + 'VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, NOW(), 0)', [ p.accountId, p.username, p.email, p.acoreIp, p.description, p.amount, p.reference, 'sumup', p.characterName ?? p.username, p.service ?? null, p.metadata ? JSON.stringify(p.metadata) : null, ], ) return { success: true, url } } catch (e) { return { success: false, error: String(e) } } } /** ¿El checkout con esta referencia está PAGADO en SumUp? */ async function isReferencePaid(reference: string): Promise { try { const res = await fetch(`${API}/checkouts?checkout_reference=${encodeURIComponent(reference)}`, { headers: authHeaders(), }) if (!res.ok) return false const list: { status?: string }[] = await res.json() return Array.isArray(list) && list.some((c) => c.status === 'PAID') } catch { return false } } /** * Entrega un checkout SumUp pagado: verifica el estado, reclama de forma atómica * (fulfilled 0->1) y acredita los PD. Idempotente. Devuelve true si acreditó ahora. */ export async function fulfillSumUpCheckout( reference: string, ): Promise<{ ok: boolean; paid: boolean; service?: string; character?: string }> { if (!reference) return { ok: false, paid: false } const paid = await isReferencePaid(reference) if (!paid) return { ok: false, paid: false } const [rows] = await db(DB.default).query( 'SELECT id, account_id, amount, service, character_name, metadata FROM home_stripelog WHERE session_id = ? AND mode = ?', [reference, 'sumup'], ) const log = rows[0] if (!log) return { ok: false, paid: true } // Reclamo atómico: sólo gana quien pasa fulfilled 0->1. 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 { ok: false, paid: true } // Pago de servicio (renombrar, oro…): ejecuta la acción sobre el personaje. const service = log.service ? String(log.service) : '' const cfg = service ? getPaidService(service) : null if (cfg) { let meta: Record = { service } try { if (log.metadata) meta = { ...JSON.parse(String(log.metadata)), service } } catch { /* metadata corrupto: seguimos con lo básico */ } const ok = await cfg.fulfill(String(log.character_name), meta) return { ok, paid: true, service, character: String(log.character_name) } } // Sin servicio: compra de PD (comportamiento por defecto). const points = Math.round(Number(log.amount) * PD_PER_UNIT) 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 }