fb28e97307
Faltaba la otra mitad de 0be3dc5: con saldo ya se reembolsaba lo que no llegaba
a enviarse, pero con tarjeta el dinero lo tiene la pasarela y el cliente se
quedaba pagando de más. Ambas tienen API de reembolso parcial, así que ahora
`fulfill` puede pedir que se devuelva un importe y lo hace quien conoce la
pasarela (lib/fulfill para Stripe, lib/sumup para SumUp).
Para saber CUÁNTO devolver hay que saber qué costó cada entrada, así que el
pedido pasa a guardarse como `itemId:qty:precio:moneda`. Releer el precio del
catálogo al entregar no vale por dos razones: 114 item_id son ambiguos (el mismo
objeto se vende a 50 PV y a 100 PD), y hay que devolver lo que se COBRÓ, no lo
que valga el ítem el día de la entrega. Los pedidos con el formato viejo se
entregan igual, pero no se puede calcular su reembolso: se registra para hacerlo
a mano.
`fulfill` devuelve ahora `boolean | {ok, refundEur}`; los servicios que no
entregan a medias siguen devolviendo boolean y no se tocan.
Sobre SumUp, todo comprobado contra su API en sandbox y nada de esto está en
sitios obvios:
- El reembolso NO va por referencia de checkout sino por transacción.
- Hay que usar el endpoint de v1.0; el viejo `/v0.1/me/refund/{txn}` responde
409 a CUALQUIER reembolso parcial (el total sí funciona).
- v1.0 quiere el importe en CÉNTIMOS y ENTERO, al revés que el resto de la API
v0.1, que usa euros. Y 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. Casi me lo comí.
- Hay un mínimo por reembolso (20 cts en esta cuenta, la API lo dice en
`min_refundable_amount`): por debajo responde 400 y se registra para hacerlo
a mano.
El host sale de SUMUP_API_BASE (por defecto api.sumup.com) en vez de ir a fuego.
Verificado de punta a punta con pagos reales de prueba, parcheando el SOAP para
que solo saliera el primer correo:
- Stripe (sk_test): pagados 1,30 €, entregadas 12/13 -> reembolso de 0,10 €,
status succeeded en la API de Stripe.
- SumUp (sandbox): pagados 1,50 €, entregadas 12/15 -> evento REFUND 0.3
REFUNDED en la API de SumUp.
De paso: el concepto del cobro decía "1 objeto" al comprar 15 copias (contaba
líneas, no copias).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
256 lines
10 KiB
TypeScript
256 lines
10 KiB
TypeScript
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<string, string> {
|
|
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<string, string>
|
|
}
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
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<boolean> {
|
|
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<RowDataPacket[]>(
|
|
'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<ResultSetHeader>(
|
|
'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<string, string> = { 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 `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<number> {
|
|
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<RowDataPacket[]>(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
|
|
}
|