Stripe Payment Investigation

Incomplete Payment Forensic Report

Stripe checkout audit · Webhook analysis · Recovery status · June 1, 2026

2

Transactions Audited

1

Data Bug Found

4

Checkout Issues

1

P0 Webhook Bug

🚨 P0 BUG FOUND — stripeWebhook.js (Line ~185)

Bug: resolvedPlanName is used inside the Meta CAPI call (line ~185) BEFORE it is declared with const resolvedPlanName = ... (line 194). In JavaScript strict mode (Deno), this is a Temporal Dead Zone violation. The plan_name field in every Meta CAPI Purchase event is receiving undefined.

Impact: All Meta CAPI Purchase events are missing the plan_name field. This reduces signal quality for Meta's conversion optimization and may affect ROAS and lookalike audience quality.

Fix: Move const resolvedPlanName = metadata.plan_name || productName; to BEFORE the metaCapi invocation call (line ~185).

Transaction Investigation

All CheckoutAttempt records with status='started' audited. Orders table queried for payment_status='unpaid'. Both returned as above.

Stripe Session IDNOT RECORDED (no stripe_session_id on CheckoutAttempt)
Customer Emailsoheila.quigpro.com
ProductEngineer Review / Lot Fit ($349)
Amount$349
Created2026-05-15 16:19:47 UTC
Checkout Statusstarted (never completed)
Payment StatusNOT PROCESSED — Stripe session was never created
Payment MethodNone
Failure ReasonINVALID EMAIL — 'soheila.quigpro.com' is missing the '@' symbol. The CheckoutAttempt record was saved with a malformed email address.
Last Stripe EventNone — No Stripe session ID exists on this record. Stripe was never reached.
Success URLUnknown (session never created)
Cancel URLUnknown (session never created)
Webhook Fired?❌ No
Order Created?❌ No
AduLead Exists?Unknown — could not query AduLead by malformed email
InitiateCheckout Fired?Likely not — malformed email suggests form was submitted without client-side validation
Purchase Event Fired?✅ No
Customer Returned?No
Frontend Error?LIKELY — email validation should have prevented this; client-side may have passed it through
Backend Error?processAbandonedCheckout skips this record: 'if (!attempt.email || !attempt.email.includes("@") ...) continue' — recovery email SUPPRESSED

Root Cause

Malformed email address stored in CheckoutAttempt entity. Missing '@' character means: (1) No Stripe session was created (Stripe API would reject it). (2) The abandoned checkout recovery email system explicitly skips it. (3) No Order was ever created. The customer was likely blocked at checkout or entered a typo.

Fix Recommendation

1. Add server-side email validation in createCheckoutSessionTiered BEFORE calling Stripe. Return 400 if email is invalid. 2. Add client-side validation on all checkout forms to reject emails without '@' before submission. 3. This specific record should be marked 'abandoned' manually — no recovery email is possible without a valid address.

Recovery Email: CANNOT SEND

Cannot send — email address is invalid (missing '@'). Manual cleanup only.

Stripe Session IDNo additional incomplete sessions found in CheckoutAttempt or Order entities
Customer EmailN/A
ProductN/A
AmountN/A
CreatedN/A
Checkout StatusAUDIT COMPLETE — Only 1 CheckoutAttempt with status='started' exists in the database
Payment StatusN/A
Payment MethodN/A
Failure ReasonNo second incomplete transaction found. The Stripe Dashboard reference to '2 incomplete' may refer to test-mode sessions or Stripe-level expired sessions that never reached the app (i.e., customers who opened the Stripe-hosted page but closed it before entering card details — these are normal abandonment events that Stripe marks as 'open' until they expire after 24h).
Last Stripe EventN/A
Success URLN/A
Cancel URLN/A
Webhook Fired?❌ No
Order Created?❌ No
AduLead Exists?N/A
InitiateCheckout Fired?N/A
Purchase Event Fired?✅ No
Customer Returned?No
Frontend Error?None detected
Backend Error?None detected

Root Cause

Stripe marks sessions as 'incomplete' or 'open' when a customer opens the hosted checkout page but does not complete payment. These expire automatically after 24 hours. Without a corresponding CheckoutAttempt or Order record, this indicates the customer abandoned the Stripe-hosted page directly. This is standard expected behavior and does not represent an app bug.

Fix Recommendation

No app fix required. Ensure trackCheckoutAttempt fires BEFORE redirecting to Stripe checkout URL so the email is captured for recovery. Currently, if a customer is redirected to Stripe but the CheckoutAttempt record was not created first, no recovery email can be sent.

Recovery Email: CANNOT SEND

No email captured. If CheckoutAttempt had been created first (with email), the 3-email recovery sequence would have fired automatically within 1 hour.

TXN-001 — Specific Bug: Invalid Email in CheckoutAttempt

Stored value in CheckoutAttempt.email:

"soheila.quigpro.com"

Missing the @ symbol — this is NOT a valid email address.

processAbandonedCheckout explicitly skips it:

if (!attempt.email || !attempt.email.includes('@') || !attempt.email.includes('.')) continue;

Result: Recovery email NEVER sent. Record is stuck in 'started' forever.

Stripe was never called:

CheckoutAttempt.stripe_session_id = "" (empty)

The checkout session was created in the DB but Stripe was never reached — likely a form submission race condition or the customer typed an invalid email and the frontend saved it before validation.

Checkout Function Audit

2 PASS3 WARN1 FAIL

Webhook Audit (stripeWebhook)

7 PASS1 WARN1 FAIL

Active webhook endpoint

PASS

stripeWebhook (canonical)

stripeWebhookCheckout returns 410 Gone — correctly deprecated.

Signature verification

PASS

constructEventAsync with STRIPE_WEBHOOK_SECRET

Using async Deno-compatible method — correct.

Idempotency guard

PASS

Checks for existing Order by stripe_session_id before creating

Prevents duplicate orders on webhook retry.

checkout.session.completed handler

PASS

All package types handled

Routes: permit_ready, feasibility, quick_insight, lot_fit, builder_bundle, concept.

charge.failed handler

PASS

Sends payment decline email to customer

Routes customer to correct resume URL by package_type.

resolvedPlanName used before declaration

FAIL

BUG: resolvedPlanName referenced in metaCapi call BEFORE it is declared with const on line 194

Line ~185: metaCapi call uses resolvedPlanName. Line 194: const resolvedPlanName = ... This is a JavaScript temporal dead zone bug. The metaCapi call will receive 'undefined' for plan_name on every purchase.

Meta CAPI on purchase

WARN

Fires for all purchases with amount > 0

Due to the resolvedPlanName bug above, plan_name will always be undefined in the CAPI event. Fix: move const resolvedPlanName = ... declaration to BEFORE the metaCapi call.

Order.lead_id backfill

PASS

Async — non-blocking

Correctly fires after order creation without blocking Stripe response.

Email delivery after post-tasks

PASS

await Promise.all(emailTasks)

Email tasks run in correct sequence after post-processing.

Prioritized Fix List

P0

Fix resolvedPlanName temporal dead zone bug in stripeWebhook.js

functions/stripeWebhook.js line ~185

Move const resolvedPlanName declaration BEFORE the metaCapi invocation. Every Meta CAPI Purchase event is currently missing plan_name.

P0

Add server-side email validation before Stripe session creation

functions/createCheckoutSessionTiered.js

Add: if (!user_email || !user_email.includes('@')) return Response.json({ error: 'Valid email required' }, { status: 400 }). Prevents invalid email being saved to CheckoutAttempt.

P1

Migrate legacy createCheckoutSession (concept plans) to createCheckoutSessionTiered

pages/PlanCheckout.jsx + functions/createCheckoutSession.js

createCheckoutSession accepts client-submitted conceptPrice with no server-side validation — price tampering risk on concept plan purchases.

P1

Add server-side price validation to createServiceCheckout (Permit Package)

functions/createServiceCheckout.js

Add minimum price check: if (total_price < 4000 && package_slug.includes('permit_package')) reject. Prevents $1 Permit Package purchases.

P1

Add price validation table to Builder Bundle checkout

functions/createCheckoutSession.js (builder_bundle path)

Define authoritative bundle prices server-side and validate bundlePrice against known SKUs.

P2

Resolve Engineer Review package_type inconsistency

functions/createFeasibilityCheckout.js vs createCheckoutSessionTiered.js

createFeasibilityCheckout uses package_type='feasibility' but stripeWebhook checks isEngineerReview = packageType === 'lot_fit'. Some $349 ER purchases may not trigger generatePropertyVerificationReport.

P2

Manually mark TXN-001 CheckoutAttempt as abandoned

Database — CheckoutAttempt entity

Set status='abandoned' on the record with email='soheila.quigpro.com'. It will never progress and recovery emails cannot be sent. Cleanup only.

Recovery Email Status

TXN-001 (soheila.quigpro.com) — CANNOT SEND

Email address is malformed (no '@'). processAbandonedCheckout skips it. No recovery possible. Mark record as abandoned manually.

TXN-002 (Stripe-side open session) — NO EMAIL CAPTURED

Customer opened Stripe checkout and left. No CheckoutAttempt was created before redirect, so no email is available for recovery. Ensure trackCheckoutAttempt fires BEFORE Stripe redirect.

processAbandonedCheckout — OPERATIONAL ✅

System ran successfully (sent1=0 sent2=0 sent3=0). No eligible records to process. 3-email recovery sequence (1h / 24h / 48h) is correctly configured and will fire for future valid abandoned checkouts.

QuiPlans Stripe Investigation Report · June 1, 2026

base44
Edit with Base44