import type { RowDataPacket, ResultSetHeader } from 'mysql2' import { db, DB } from './db' import { creditDPoints, PD_PER_UNIT } from './dpoints' import { getPaidService, fulfillOk, fulfillRefundEur } from './paid-services' // Host de la API de SumUp, configurable por si hay que apuntar a otro entorno. // Conviven dos versiones y NO son intercambiables: los checkouts van por v0.1 y // los reembolsos por v1.0 (el `/v0.1/me/refund/{txn}` antiguo responde 409 a // cualquier reembolso parcial). Ojo también con las unidades: v0.1 usa euros // (1.30) y v1.0 céntimos (130). const SUMUP_HOST = process.env.SUMUP_API_BASE || 'https://api.sumup.com' const API = `${SUMUP_HOST}/v0.1` const API_V1 = `${SUMUP_HOST}/v1.0` /** 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 `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 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 } } /** * Reembolsa un pago de SumUp, entero o en parte. `amountEur` omitido = todo. * * Tres cosas que hay que saber, todas comprobadas contra la API en sandbox: * - No se reembolsa por referencia de checkout sino por TRANSACCIÓN, así que * primero hay que sacar su id del checkout pagado. * - El endpoint bueno es el de v1.0 (`API_V1`, ver arriba). El viejo * `/v0.1/me/refund/{txn}` responde 409 a cualquier reembolso parcial. * - ⚠️ El importe va en CÉNTIMOS y ENTERO (como Stripe, y al revés que el resto * de la API v0.1, que usa unidades). Mandar 0.10 en vez de 10 NO da error: se * trunca a 0, SumUp lo lee como "sin importe" y DEVUELVE EL PAGO ENTERO. * * Además hay un mínimo por reembolso (la API lo devuelve en `min_refundable_amount`; * son 20 céntimos en esta cuenta): por debajo responde 400 y no se puede devolver * a medias. Se registra para hacerlo a mano. */ export async function refundSumUpCheckout(reference: string, amountEur?: number): Promise { if (!sumupConfigured() || !reference) return false if (amountEur !== undefined && !(amountEur > 0)) return false const merchant = process.env.SUMUP_MERCHANT_CODE ?? '' try { const res = await fetch(`${API}/checkouts?checkout_reference=${encodeURIComponent(reference)}`, { headers: authHeaders(), }) if (!res.ok) return false const list: { status?: string; transaction_id?: string; transactions?: { id?: string }[] }[] = await res.json() const paid = Array.isArray(list) ? list.find((c) => c.status === 'PAID') : null const txn = paid?.transaction_id || paid?.transactions?.find((t) => t.id)?.id if (!txn) { console.error(`[sumup] ${reference}: pagado pero sin id de transacción; no se puede reembolsar`) return false } const cents = amountEur === undefined ? undefined : Math.round(amountEur * 100) const r = await fetch( `${API_V1}/merchants/${encodeURIComponent(merchant)}/payments/${encodeURIComponent(txn)}/refunds`, { method: 'POST', headers: authHeaders(), // Sin `amount` = reembolso total. Nunca mandar decimales: ver arriba. body: JSON.stringify(cents === undefined ? {} : { amount: cents }), }, ) if (!r.ok) { console.error(`[sumup] reembolso de ${reference} (${cents ?? 'total'} cts) rechazado: ${r.status} ${await r.text()}`) return false } return true } catch (e) { console.error(`[sumup] no se pudo reembolsar ${reference}:`, e) 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 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 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 outcome = await cfg.fulfill(String(log.character_name), meta) // Entrega parcial: el pago ya está cobrado y no hay reintento (se reclama una // sola vez), así que se devuelve por SumUp lo que no se pudo entregar. const refundEur = fulfillRefundEur(outcome) if (refundEur > 0 && !(await refundSumUpCheckout(reference, refundEur))) { console.error( `[sumup] ${service}: entrega parcial y el reembolso de ${refundEur} € FALLÓ en ${reference}.` + ' Hay que devolverlo a mano desde el panel de SumUp.', ) } return { ok: fulfillOk(outcome), 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 `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 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 }