From 0be3dc5280f13d4945a9a37f2d59e8a9a085f002 Mon Sep 17 00:00:00 2001 From: adevopg Date: Wed, 15 Jul 2026 10:19:09 +0000 Subject: [PATCH] =?UTF-8?q?store:=20reembolsar=20solo=20lo=20no=20entregad?= =?UTF-8?q?o=20si=20el=20env=C3=ADo=20falla=20a=20medias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web-next/lib/store.ts | 80 ++++++++++++++++++++++++++++----------- web-next/messages/en.json | 1 + web-next/messages/es.json | 1 + 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/web-next/lib/store.ts b/web-next/lib/store.ts index d7c3afe..6a4776e 100644 --- a/web-next/lib/store.ts +++ b/web-next/lib/store.ts @@ -186,13 +186,24 @@ export async function priceStoreCart(cart: StoreCartLine[]): Promise Array.from({ length: l.copies }, () => ({ i: l.itemId, q: l.qty }))) +function cartEntries(cart: PricedCart): CartEntry[] { + 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() } - // Envío por correo (troceado a 12 ítems). Si falla, se reembolsa el saldo. - const ok = await sendStoreItems(character, cartEntries(cart)) - if (!ok) { - if (charged) { + // Envío por correo (troceado a 12 ítems). Si falla a medias, se reembolsa SOLO + // lo que no se envió: los correos que ya salieron no se pueden recuperar, y + // devolver el carrito entero regalaría lo ya entregado. + 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) - .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(() => {}) } - 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 } } @@ -288,6 +308,12 @@ export async function createStoreOrder( /** * 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). + * + * 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 { if (!ref) return false @@ -310,23 +336,33 @@ export async function fulfillStoreOrder(character: string, ref: string): Promise return { i: Number(i), q: Number(q) } }) .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 { - if (!SAFE_NAME.test(character)) return false - const clean = items - .map((it) => ({ i: Math.floor(Number(it.i)), q: Math.floor(Number(it.q)) })) - .filter((it) => it.i > 0 && it.q >= 1) - if (clean.length === 0) return false +/** + * Envía los ítems de la tienda por correo (SOAP `.send items`), troceado a 12 + * por correo. + * + * Devuelve CUÁNTAS entradas se entregaron, no un booleano: un correo enviado ya + * no se puede deshacer, así que si el 3.º de 9 falla, los 2 primeros están en el + * 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 { + 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 body = 'Has recibido los objetos de la tienda. ¡Que los disfrutes!' - for (let n = 0; n < clean.length; n += MAIL_ITEM_LIMIT) { - const chunk = clean.slice(n, n + MAIL_ITEM_LIMIT) - const itemsStr = chunk.map((it) => `${it.i}:${it.q}`).join(' ') + let sent = 0 + for (let n = 0; n < items.length; n += MAIL_ITEM_LIMIT) { + 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}`) - if (res === null) return false + if (res === null) return sent + sent += chunk.length } - return true + return sent } diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 511ef49..98fdae1 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -1961,6 +1961,7 @@ "insufficientPd": "You don't have enough PD 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.", + "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.", "emptyCart": "Your cart is empty.", "generic": "An error occurred. Please try again.", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index d22cd87..8ad4fa0 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -1961,6 +1961,7 @@ "insufficientPd": "No tienes suficientes PD 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.", + "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.", "emptyCart": "Tu carrito está vacío.", "generic": "Ha ocurrido un error. Inténtalo de nuevo.",