From 6827aafdbc1c761c829dc26a9f55488d939f0644 Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 10 Jun 2026 06:57:59 -0500 Subject: [PATCH] fix(checkout): batch surcharge 5x over-count + ERPNext SO missing discount Mitchell's batch CB-95BA6C90: Stripe correctly charged $450.88 ($437.75 net + $13.13 surcharge), but the DB + Telegram showed $503.40 with a $65.65 surcharge. Two bugs: 1) On Stripe session creation, the per-row surcharge UPDATE wrote the FULL batch surcharge ($13.13) to EVERY row via WHERE batch_id, so anything summing the per-row field (the Telegram order notification) over-counted Nx (5 x $13.13 = $65.65). Now the single surcharge is split across the rows so they sum to the true total. Stripe was always charged correctly (one surcharge line item). 2) ensureComplianceSalesOrder built the ERPNext SO from full line-item prices but applied NO discount, so the SO grand total over-stated what the customer paid. Now applies the promo/bundle discount via apply_discount_on=Grand Total + discount_amount on both the primary and fallback SO create. --- api/src/routes/checkout.ts | 49 ++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/api/src/routes/checkout.ts b/api/src/routes/checkout.ts index 69e9693..a34fd30 100644 --- a/api/src/routes/checkout.ts +++ b/api/src/routes/checkout.ts @@ -308,10 +308,12 @@ export async function ensureComplianceSalesOrder( const { COMPLIANCE_SERVICES } = await import("./compliance-orders.js"); const surchargePct = Number(first.surcharge_pct || 0); let surchargeCents = 0; + let discountCents = 0; const lineItems = rows.map((o: Record) => { const info = COMPLIANCE_SERVICES[(o.service_slug as string) || ""]; surchargeCents += Number(o.surcharge_cents || 0); + discountCents += Number(o.discount_cents || 0); const items: Array<{ item_code: string; description: string; qty: number; rate: number }> = [{ item_code: info?.erpnext_item || "COMPLIANCE-SERVICE", description: (o.service_name as string) || info?.name || "Compliance Service", @@ -348,6 +350,9 @@ export async function ensureComplianceSalesOrder( custom_surcharge_pct: surchargePct, workflow_state: "Received", items: lineItems, + // Reflect the promo/bundle discount so the SO grand total matches what the + // customer actually paid (line items are full price; discount applied here). + ...(discountCents > 0 ? { apply_discount_on: "Grand Total", discount_amount: toDollars(discountCents) } : {}), }).catch(async (e: unknown) => { // Resilience: if a service's ERPNext Item is missing, the SO would 404. // Retry once with every line item remapped to the generic COMPLIANCE-SERVICE @@ -366,6 +371,7 @@ export async function ensureComplianceSalesOrder( custom_surcharge_pct: surchargePct, workflow_state: "Received", items: fallback, + ...(discountCents > 0 ? { apply_discount_on: "Grand Total", discount_amount: toDollars(discountCents) } : {}), }); } throw e; @@ -1512,17 +1518,40 @@ router.post("/api/v1/checkout/create-session", async (req, res) => { }; const table = tableMap[order_type]; if (table) { - // For batch orders, update by batch_id; otherwise by order_number + // For batch orders, update by batch_id; otherwise by order_number. const whereCol = order_type === "compliance_batch" ? "batch_id" : "order_number"; - await pool.query( - `UPDATE ${table} - SET stripe_session_id = $1, - payment_method = $2, - surcharge_pct = $3, - surcharge_cents = $4 - WHERE ${whereCol} = $5`, - [session.id, payment_method, surcharge_pct, surcharge_cents, order_id], - ); + if (order_type === "compliance_batch") { + // A batch has ONE surcharge for the whole order, but it is stored per + // row. Writing the full surcharge_cents to every row makes anything that + // SUMS the per-row field (e.g. the Telegram order notification) over- + // count by Nx. Split the single surcharge across the rows so the per-row + // values sum to the true total (remainder on the first row). + const { rows: brows } = await pool.query( + `SELECT order_number FROM ${table} WHERE batch_id = $1 ORDER BY created_at`, + [order_id], + ); + const n = brows.length || 1; + const per = Math.floor(surcharge_cents / n); + const remainder = surcharge_cents - per * n; + for (let i = 0; i < brows.length; i++) { + const rowSurcharge = per + (i === 0 ? remainder : 0); + await pool.query( + `UPDATE ${table} + SET stripe_session_id = $1, payment_method = $2, + surcharge_pct = $3, surcharge_cents = $4 + WHERE order_number = $5`, + [session.id, payment_method, surcharge_pct, rowSurcharge, brows[i].order_number], + ); + } + } else { + await pool.query( + `UPDATE ${table} + SET stripe_session_id = $1, payment_method = $2, + surcharge_pct = $3, surcharge_cents = $4 + WHERE ${whereCol} = $5`, + [session.id, payment_method, surcharge_pct, surcharge_cents, order_id], + ); + } } console.log(`[checkout] Stripe session ${session.id} created for ${order_type} ${order_id}`);