store: reembolsar solo lo no entregado si el envío falla a medias
Un carrito de más de 12 entradas se envía en varios correos. Si fallaba el 3.º
de 9, los 2 primeros ya estaban en el buzón del jugador y aun así se le devolvía
el carrito ENTERO: se quedaba los objetos gratis. Con las cantidades por línea
(f232f0f) llegar ahí es fácil: 100 copias son 9 correos.
sendStoreItems devuelve ahora CUÁNTAS entradas entregó en vez de un booleano: un
correo enviado no se puede deshacer, así que quien llama necesita saber dónde se
cortó. Cada `.send items` es un correo, así que el troceo es atómico por correo:
un chunk sale entero o no sale. purchaseStoreCart reembolsa solo las entradas
que quedaron sin enviar (cada una lleva su precio y su moneda) y devuelve
`partialDelivery`, con un mensaje que dice la verdad: parte llegó al correo del
juego y solo se ha devuelto el resto. Si no sale ni un correo, sigue siendo el
`deliveryFailed` de siempre con la devolución completa.
En el pago con tarjeta no se puede hacer lo mismo (el dinero ya lo cobró la
pasarela): fulfillStoreOrder devuelve false si no salió todo. No puede duplicar
porque claimPaidCheckout reclama el pago una sola vez, pero por eso mismo
tampoco hay reintento y un envío a medias hay que rescatarlo a mano desde
home_store_order. Queda anotado en el código.
Verificado de punta a punta, no solo razonado: con un parche temporal del SOAP
que deja salir el 1.er correo y tumba el resto, 13 copias de un ítem de 10 PD
(130 PD, 12+1 entradas) dejan el saldo en 500 -> 380, o sea 500-130+10: se
devuelve SOLO la entrada que no salió y se cobran las 12 entregadas. El parche
se revirtió y producción se reconstruyó limpia.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+58
-22
@@ -186,13 +186,24 @@ export async function priceStoreCart(cart: StoreCartLine[]): Promise<PricedCart
|
|||||||
return { lines, pdTotal: sum('pd'), vpTotal: sum('pv') }
|
return { lines, pdTotal: sum('pd'), vpTotal: sum('pv') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Entrada de correo: un lote de un ítem, con lo que costó (para reembolsar). */
|
||||||
|
interface CartEntry {
|
||||||
|
i: number
|
||||||
|
q: number
|
||||||
|
currency: Currency
|
||||||
|
price: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entradas para el SOAP/el pedido: cada copia va como una entrada propia
|
* Entradas para el SOAP/el pedido: cada copia va como una entrada propia
|
||||||
* (`2589:20 2589:20` = 2 lotes de 20), que es justo lo que ya sabe trocear
|
* (`2589:20 2589:20` = 2 lotes de 20), que es justo lo que ya sabe trocear
|
||||||
* `sendStoreItems` (12 por correo) y parsear `fulfillStoreOrder`.
|
* `sendStoreItems` (12 por correo) y parsear `fulfillStoreOrder`. Cada entrada
|
||||||
|
* lleva su precio para poder reembolsar SOLO lo que no llegue a enviarse.
|
||||||
*/
|
*/
|
||||||
function cartEntries(cart: PricedCart): { i: number; q: number }[] {
|
function cartEntries(cart: PricedCart): CartEntry[] {
|
||||||
return cart.lines.flatMap((l) => Array.from({ length: l.copies }, () => ({ i: l.itemId, q: l.qty })))
|
return cart.lines.flatMap((l) =>
|
||||||
|
Array.from({ length: l.copies }, () => ({ i: l.itemId, q: l.qty, currency: l.currency, price: l.price })),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,15 +242,24 @@ export async function purchaseStoreCart(
|
|||||||
conn.release()
|
conn.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Envío por correo (troceado a 12 ítems). Si falla, se reembolsa el saldo.
|
// Envío por correo (troceado a 12 ítems). Si falla a medias, se reembolsa SOLO
|
||||||
const ok = await sendStoreItems(character, cartEntries(cart))
|
// lo que no se envió: los correos que ya salieron no se pueden recuperar, y
|
||||||
if (!ok) {
|
// devolver el carrito entero regalaría lo ya entregado.
|
||||||
if (charged) {
|
const entries = cartEntries(cart)
|
||||||
|
const sent = await sendStoreItems(character, entries)
|
||||||
|
if (sent < entries.length) {
|
||||||
|
const left = entries.slice(sent)
|
||||||
|
const back = (c: Currency) => left.filter((e) => e.currency === c).reduce((s, e) => s + e.price, 0)
|
||||||
|
const pdBack = back('pd')
|
||||||
|
const vpBack = back('pv')
|
||||||
|
if (charged && (pdBack > 0 || vpBack > 0)) {
|
||||||
await db(DB.default)
|
await db(DB.default)
|
||||||
.query('UPDATE home_api_points SET dp = dp + ?, vp = vp + ? WHERE accountID = ?', [cart.pdTotal, cart.vpTotal, accountId])
|
.query('UPDATE home_api_points SET dp = dp + ?, vp = vp + ? WHERE accountID = ?', [pdBack, vpBack, accountId])
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
return { success: false, error: 'deliveryFailed' }
|
// Si no salió ni un correo es el fallo de siempre (SOAP caído). Si salió
|
||||||
|
// alguno, el jugador tiene parte de la compra y solo se le devuelve el resto.
|
||||||
|
return { success: false, error: sent === 0 ? 'deliveryFailed' : 'partialDelivery' }
|
||||||
}
|
}
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
@@ -288,6 +308,12 @@ export async function createStoreOrder(
|
|||||||
/**
|
/**
|
||||||
* Entrega un pedido pagado con tarjeta: busca el carrito por `ref` y envía los
|
* Entrega un pedido pagado con tarjeta: busca el carrito por `ref` y envía los
|
||||||
* ítems por correo. Lo llama el fulfillment del servicio `store` (webhook/return).
|
* ítems por correo. Lo llama el fulfillment del servicio `store` (webhook/return).
|
||||||
|
*
|
||||||
|
* Aquí no se puede reembolsar lo no entregado como en el pago con saldo: el
|
||||||
|
* dinero ya lo cobró la pasarela. Devuelve false si no salió TODO, que es lo
|
||||||
|
* honesto; no puede duplicar porque `claimPaidCheckout` reclama el pago una
|
||||||
|
* sola vez (y por eso mismo tampoco hay reintento: un envío a medias deja el
|
||||||
|
* pedido incompleto y hay que rescatarlo a mano desde `home_store_order`).
|
||||||
*/
|
*/
|
||||||
export async function fulfillStoreOrder(character: string, ref: string): Promise<boolean> {
|
export async function fulfillStoreOrder(character: string, ref: string): Promise<boolean> {
|
||||||
if (!ref) return false
|
if (!ref) return false
|
||||||
@@ -310,23 +336,33 @@ export async function fulfillStoreOrder(character: string, ref: string): Promise
|
|||||||
return { i: Number(i), q: Number(q) }
|
return { i: Number(i), q: Number(q) }
|
||||||
})
|
})
|
||||||
.filter((it) => it.i > 0 && it.q >= 1)
|
.filter((it) => it.i > 0 && it.q >= 1)
|
||||||
return sendStoreItems(target, items)
|
return (await sendStoreItems(target, items)) === items.length
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Envía los ítems de la tienda por correo (SOAP `.send items`), troceado. */
|
/**
|
||||||
async function sendStoreItems(character: string, items: { i: number; q: number }[]): Promise<boolean> {
|
* Envía los ítems de la tienda por correo (SOAP `.send items`), troceado a 12
|
||||||
if (!SAFE_NAME.test(character)) return false
|
* por correo.
|
||||||
const clean = items
|
*
|
||||||
.map((it) => ({ i: Math.floor(Number(it.i)), q: Math.floor(Number(it.q)) }))
|
* Devuelve CUÁNTAS entradas se entregaron, no un booleano: un correo enviado ya
|
||||||
.filter((it) => it.i > 0 && it.q >= 1)
|
* no se puede deshacer, así que si el 3.º de 9 falla, los 2 primeros están en el
|
||||||
if (clean.length === 0) return false
|
* buzón del jugador y quien llame tiene que saberlo para reembolsar solo el
|
||||||
|
* resto. Cada correo es un `.send items`, así que el troceo es atómico por
|
||||||
|
* correo: un chunk sale entero o no sale.
|
||||||
|
*/
|
||||||
|
async function sendStoreItems(character: string, items: { i: number; q: number }[]): Promise<number> {
|
||||||
|
if (!SAFE_NAME.test(character)) return 0
|
||||||
|
// Los datos salen de la BD: si algo no cuadra es un bug, y se prefiere no
|
||||||
|
// enviar nada a enviar de menos y descuadrar el reembolso por índice.
|
||||||
|
if (items.length === 0 || items.some((it) => !(Number(it.i) > 0) || !(Number(it.q) >= 1))) return 0
|
||||||
const subject = 'Tienda'
|
const subject = 'Tienda'
|
||||||
const body = 'Has recibido los objetos de la tienda. ¡Que los disfrutes!'
|
const body = 'Has recibido los objetos de la tienda. ¡Que los disfrutes!'
|
||||||
for (let n = 0; n < clean.length; n += MAIL_ITEM_LIMIT) {
|
let sent = 0
|
||||||
const chunk = clean.slice(n, n + MAIL_ITEM_LIMIT)
|
for (let n = 0; n < items.length; n += MAIL_ITEM_LIMIT) {
|
||||||
const itemsStr = chunk.map((it) => `${it.i}:${it.q}`).join(' ')
|
const chunk = items.slice(n, n + MAIL_ITEM_LIMIT)
|
||||||
|
const itemsStr = chunk.map((it) => `${Math.floor(it.i)}:${Math.floor(it.q)}`).join(' ')
|
||||||
const res = await executeSoapCommand(`.send items "${character}" "${subject}" "${body}" ${itemsStr}`)
|
const res = await executeSoapCommand(`.send items "${character}" "${subject}" "${body}" ${itemsStr}`)
|
||||||
if (res === null) return false
|
if (res === null) return sent
|
||||||
|
sent += chunk.length
|
||||||
}
|
}
|
||||||
return true
|
return sent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1961,6 +1961,7 @@
|
|||||||
"insufficientPd": "You don't have enough PD for this purchase.",
|
"insufficientPd": "You don't have enough PD for this purchase.",
|
||||||
"insufficientVp": "You don't have enough VP for this purchase.",
|
"insufficientVp": "You don't have enough VP for this purchase.",
|
||||||
"deliveryFailed": "The items could not be sent (game server unavailable). Your balance has been refunded.",
|
"deliveryFailed": "The items could not be sent (game server unavailable). Your balance has been refunded.",
|
||||||
|
"partialDelivery": "Only part of the purchase could be sent (the game server stopped responding). The items that did arrive are in your in-game mail; you have been refunded for the rest.",
|
||||||
"invalidCharacter": "Invalid character.",
|
"invalidCharacter": "Invalid character.",
|
||||||
"emptyCart": "Your cart is empty.",
|
"emptyCart": "Your cart is empty.",
|
||||||
"generic": "An error occurred. Please try again.",
|
"generic": "An error occurred. Please try again.",
|
||||||
|
|||||||
@@ -1961,6 +1961,7 @@
|
|||||||
"insufficientPd": "No tienes suficientes PD para esta compra.",
|
"insufficientPd": "No tienes suficientes PD para esta compra.",
|
||||||
"insufficientVp": "No tienes suficientes PV para esta compra.",
|
"insufficientVp": "No tienes suficientes PV para esta compra.",
|
||||||
"deliveryFailed": "No se pudieron enviar los objetos (servidor de juego no disponible). Se te ha devuelto el saldo.",
|
"deliveryFailed": "No se pudieron enviar los objetos (servidor de juego no disponible). Se te ha devuelto el saldo.",
|
||||||
|
"partialDelivery": "Solo se pudo enviar parte de la compra (el servidor de juego dejó de responder). Los objetos que sí llegaron están en tu correo del juego; se te ha devuelto el saldo del resto.",
|
||||||
"invalidCharacter": "El personaje no es válido.",
|
"invalidCharacter": "El personaje no es válido.",
|
||||||
"emptyCart": "Tu carrito está vacío.",
|
"emptyCart": "Tu carrito está vacío.",
|
||||||
"generic": "Ha ocurrido un error. Inténtalo de nuevo.",
|
"generic": "Ha ocurrido un error. Inténtalo de nuevo.",
|
||||||
|
|||||||
Reference in New Issue
Block a user