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>
131 lines
4.6 KiB
TypeScript
131 lines
4.6 KiB
TypeScript
import Stripe from 'stripe'
|
|
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
|
|
import { db, DB } from './db'
|
|
|
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
|
|
|
|
/**
|
|
* Reembolsa un pago de Stripe, entero o en parte.
|
|
*
|
|
* `amountEur` omitido = todo. Stripe cobra en la unidad mínima, así que el
|
|
* importe va en céntimos. El reembolso se pide sobre el PaymentIntent de la
|
|
* sesión de checkout, que es lo que guardamos en `home_stripelog`.
|
|
*/
|
|
export async function refundStripeSession(sessionId: string, amountEur?: number): Promise<boolean> {
|
|
if (!sessionId) return false
|
|
try {
|
|
const s = await stripe.checkout.sessions.retrieve(sessionId)
|
|
const pi = typeof s.payment_intent === 'string' ? s.payment_intent : s.payment_intent?.id
|
|
if (!pi) return false
|
|
const cents = amountEur === undefined ? undefined : Math.round(amountEur * 100)
|
|
if (cents !== undefined && cents <= 0) return false
|
|
await stripe.refunds.create({ payment_intent: pi, ...(cents === undefined ? {} : { amount: cents }) })
|
|
return true
|
|
} catch (e) {
|
|
console.error(`[stripe] no se pudo reembolsar ${sessionId}:`, e)
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function isSessionPaid(sessionId: string): Promise<boolean> {
|
|
if (!sessionId) return false
|
|
try {
|
|
const s = await stripe.checkout.sessions.retrieve(sessionId)
|
|
return s.payment_status === 'paid'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export interface CheckoutParams {
|
|
accountId: number
|
|
username: string
|
|
email: string
|
|
acoreIp: string
|
|
amount: number
|
|
characterName: string
|
|
productName: string
|
|
successUrl: string
|
|
cancelUrl: string
|
|
currency?: string
|
|
metadata?: Record<string, string>
|
|
}
|
|
|
|
/** Crea una sesión de pago Stripe y registra el StripeLog. Devuelve la URL de checkout. */
|
|
export async function createCheckoutSession(
|
|
p: CheckoutParams,
|
|
): Promise<{ success: boolean; url?: string; error?: string }> {
|
|
try {
|
|
const sep = p.successUrl.includes('?') ? '&' : '?'
|
|
const successUrl = `${p.successUrl}${sep}session_id={CHECKOUT_SESSION_ID}`
|
|
const session = await stripe.checkout.sessions.create({
|
|
payment_method_types: ['card'],
|
|
line_items: [
|
|
{
|
|
price_data: {
|
|
currency: p.currency || 'eur',
|
|
product_data: { name: p.productName },
|
|
unit_amount: Math.round(p.amount * 100),
|
|
},
|
|
quantity: 1,
|
|
},
|
|
],
|
|
mode: 'payment',
|
|
success_url: successUrl,
|
|
cancel_url: p.cancelUrl,
|
|
metadata: p.metadata,
|
|
})
|
|
const mode = (process.env.STRIPE_SECRET_KEY || '').startsWith('sk_live') ? 'live' : 'test'
|
|
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, timestamp, fulfilled) ' +
|
|
'VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, NOW(), 0)',
|
|
[p.accountId, p.username, p.email, p.acoreIp, p.productName, p.amount, session.id, mode, p.characterName],
|
|
)
|
|
return { success: true, url: session.url ?? undefined }
|
|
} catch (e) {
|
|
return { success: false, error: String(e) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verifica que la sesión está pagada y aún no entregada; la marca como entregada
|
|
* (evita re-entregas). Devuelve el character_name del log o null. (Port de
|
|
* require_paid_stripe.)
|
|
*/
|
|
export async function claimPaidCheckout(
|
|
sessionId: string,
|
|
): Promise<{ characterName: string; metadata: Record<string, string> } | null> {
|
|
if (!sessionId) return null
|
|
let metadata: Record<string, string> = {}
|
|
try {
|
|
const s = await stripe.checkout.sessions.retrieve(sessionId)
|
|
if (s.payment_status !== 'paid') return null
|
|
metadata = (s.metadata as Record<string, string>) ?? {}
|
|
} catch {
|
|
return null
|
|
}
|
|
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
|
'SELECT id, character_name FROM home_stripelog WHERE session_id = ?',
|
|
[sessionId],
|
|
)
|
|
const log = rows[0]
|
|
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<ResultSetHeader>(
|
|
'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<void> {
|
|
if (!sessionId) return
|
|
await db(DB.default).query('UPDATE home_stripelog SET stripe_ip = ?, timestamp = NOW() WHERE session_id = ?', [
|
|
ip.slice(0, 45),
|
|
sessionId,
|
|
])
|
|
}
|