Summary
The mobile payment stack has several state-integrity problems beyond the booking bugs already captured in #50 and #51.
The highest-risk problems are:
- the Razorpay verification route can mark the wrong payment as
SUCCEEDED
- checkout persists
Payment rows before a gateway order/payment-intent exists
- failed/expired payments do not have a reliable cleanup/recovery path
- discount usage and payment accounting data are not trustworthy
Taken together, these defects can strand orphaned PENDING payments, confirm unpaid bookings, overuse discount codes, and leave the system without a recovery path when webhooks or gateway calls fail.
Scope
Primary mobile files:
backend/routes/api/checkout/index.dart
backend/routes/api/checkout/verify.dart
backend/lib/database/repositories/checkout_repository.dart
backend/routes/api/webhooks/stripe.dart
backend/routes/api/webhooks/razorpay.dart
backend/lib/services/webhook_handlers.dart
lib/data/datasources/remote/checkout_remote_source.dart
Web parity references:
/Users/kaustavghosh/Desktop/familiarise_web/app/api/checkout/verify/route.ts
/Users/kaustavghosh/Desktop/familiarise_web/app/api/payments/discounts/validate/route.ts
/Users/kaustavghosh/Desktop/familiarise_web/lib/payments/operations/checkout.ts
/Users/kaustavghosh/Desktop/familiarise_web/scripts/payments/cleanup-abandoned-payments.ts
/Users/kaustavghosh/Desktop/familiarise_web/app/api/cleanup/reconcile-payment-status/route.ts
Findings
1. Razorpay verify can falsely confirm the wrong payment because it never binds the submitted order ID to the payment being verified
Evidence:
- The verify route loads the payment by internal payment UUID supplied in
payment_intent, not by stored gateway order ID.
backend/routes/api/checkout/verify.dart:73-79
- It correctly checks that the payment belongs to the authenticated user.
backend/routes/api/checkout/verify.dart:92-103
- For Razorpay, it then verifies only that the submitted
(razorpay_order_id, razorpay_payment_id, razorpay_signature) tuple is cryptographically valid.
backend/routes/api/checkout/verify.dart:133-178
- It never checks that
razorpay_order_id equals the payment record’s stored paymentIntent / order ID before marking the payment SUCCEEDED.
backend/routes/api/checkout/verify.dart:198-237
- The web verify endpoint is read-only and looks up the payment by actual
paymentIntent; it does not let a client-side callback mutate payment state with an unrelated order tuple.
/Users/kaustavghosh/Desktop/familiarise_web/app/api/checkout/verify/route.ts:23-80
Impact:
- a user who owns one pending payment and also possesses any valid Razorpay success tuple can attempt to mark a different pending payment as paid
- booking confirmation becomes client-claimable instead of being strictly bound to the gateway object created for that payment row
- this is a payment authorization/integrity bug, not just a UX issue
Proposed fix:
- Require the submitted
razorpay_order_id to equal the stored payment gateway identifier for that payment row before verification proceeds.
- Fetch the order/payment details from Razorpay and verify:
- order ID matches stored payment intent
- amount/currency match the payment row
- payment is actually captured/paid
- Prefer webhook-first confirmation and keep verify as a read-only status endpoint where possible.
- Add tests proving a valid signature tuple for payment B cannot confirm payment A.
2. Checkout creates the database Payment row before creating the real gateway object, which leaves orphaned PENDING payments on gateway failure
Evidence:
- The mobile checkout route computes the amount, then immediately creates the
Payment record.
backend/routes/api/checkout/index.dart:318-369
- Only after the row is inserted does it call Razorpay/Stripe to create the actual order or payment intent.
backend/routes/api/checkout/index.dart:376-524
CheckoutRepository.createPayment() also pre-populates a synthetic paymentIntent value before any real gateway object exists.
backend/lib/database/repositories/checkout_repository.dart:25-53
- If gateway creation fails, the route returns an error response, but the previously-created
Payment row is not deleted or marked failed.
backend/routes/api/checkout/index.dart:430-447
backend/routes/api/checkout/index.dart:506-523
- The web checkout flow persists the payment inside the main transaction after the gateway object exists, not before.
/Users/kaustavghosh/Desktop/familiarise_web/lib/payments/operations/checkout.ts:1503-1585
Impact:
- gateway outages or validation failures leave orphaned
PENDING payment rows behind
- those rows can confuse dashboard state, expiry logic, support tooling, and later recovery attempts
- a payment record can exist with a fake/generated
paymentIntent that never existed at the gateway
Proposed fix:
- Reverse the order:
- create the gateway order/payment intent first
- persist the payment row only once the gateway object exists
- If the current order must be preserved, wrap the DB insert in compensation logic:
- mark failed
- delete the row
- delete tentative booking artifacts if the checkout session never became real
- Add tests for Stripe/Razorpay gateway creation failure ensuring no orphan
PENDING payment remains.
3. Failed or expired payments do not have a reliable cleanup path, even though the system records expiresAt
Evidence:
- New payment rows are created with
expiresAt = now + 1 hour.
backend/lib/database/repositories/checkout_repository.dart:45-48
- The mobile payment failure webhook handler only marks the payment
FAILED and sends a notification.
backend/lib/services/webhook_handlers.dart:101-156
- It does not release tentative slots, revert booking state, cancel gateway objects, or restore related state.
- I could not find any mobile cleanup/reconciliation route beyond:
backend/routes/api/checkout/index.dart
backend/routes/api/checkout/verify.dart
backend/routes/api/payments/[paymentId]/refunds.dart
backend/routes/api/payments/[paymentId]/disputes.dart
backend/routes/api/webhooks/stripe.dart
backend/routes/api/webhooks/razorpay.dart
- The web repo has explicit abandoned-payment cleanup that:
- finds expired pending payments
- re-checks state
- cancels gateway payment intents/orders
- marks payments failed
- cleans tentative slots
- restores referral credits
/Users/kaustavghosh/Desktop/familiarise_web/scripts/payments/cleanup-abandoned-payments.ts:104-260
Impact:
- expired
PENDING payments can remain indefinitely
- tentative booking artifacts can drift out of sync with payment state
- support and reporting surfaces accumulate stale financial records with no backend-owned recovery path
Proposed fix:
- Add a payment cleanup job/route for expired pending payments.
- Define the cleanup semantics per booking type:
- fail payment
- release tentative slots
- revert or cancel provisional booking state
- restore any consumed credits/discount counters if needed
- Add tests for expired payments and failed webhook flows.
4. Webhook processing failures are acknowledged with HTTP 200, but the mobile backend has no replay or reconciliation mechanism
Evidence:
- Stripe webhook processing records the event, and on downstream processing failure it marks the event failed but still returns HTTP 200.
backend/routes/api/webhooks/stripe.dart:190-200
- Razorpay does the same.
backend/routes/api/webhooks/razorpay.dart:193-203
- Returning 200 prevents the provider from retrying the event.
- I could not find any mobile payment reconciliation or webhook replay route/job.
- The web repo has explicit reconciliation endpoints/jobs for payment status and other post-payment recovery work.
/Users/kaustavghosh/Desktop/familiarise_web/app/api/cleanup/reconcile-payment-status/route.ts:1-55
Impact:
- a transient DB or downstream processing error can permanently lose the payment event
- the payment may remain
PENDING or partially processed with no automated repair path
- webhook idempotency exists, but webhook recovery does not
Proposed fix:
- Do not acknowledge failed webhook processing with 200 unless there is a real internal retry/replay system.
- Add a reconciliation job that compares gateway state with local
Payment rows and repairs drift.
- Provide a safe admin/staff replay path for failed webhook events.
- Add tests for:
- webhook handler throws after event recording
- reconciliation later repairs the payment state
5. Discount-code application is inconsistent across the mobile stack and maxUses is not actually enforced
Evidence:
- The Flutter app calls
POST /api/checkout/validate-discount.
lib/data/datasources/remote/checkout_remote_source.dart:211-244
- The mobile backend does not expose that route; the payment surface currently consists only of checkout, verify, payment visibility, and webhooks.
- The checkout route does validate a discount code server-side and persists
discountCodeId on the payment.
backend/routes/api/checkout/index.dart:323-369
validateDiscountCode() checks maxUses vs currentUses.
backend/lib/database/repositories/checkout_repository.dart:173-233
- But I could not find any non-test code in the mobile repo that increments
DiscountCode.currentUses after payment creation or success.
- The web repo has both:
- a real discount validation endpoint
/Users/kaustavghosh/Desktop/familiarise_web/app/api/payments/discounts/validate/route.ts:22-119
- an atomic
currentUses increment in the checkout transaction
/Users/kaustavghosh/Desktop/familiarise_web/lib/payments/operations/checkout.ts:1595-1601
Impact:
- the mobile UI’s discount validation call path is broken
maxUses is effectively advisory because successful checkouts do not consume usage count
- discount analytics, abuse prevention, and future code exhaustion behavior are all unreliable
Proposed fix:
- Implement a real discount validation endpoint or align the Flutter client to the existing API.
- Increment
DiscountCode.currentUses atomically when a payment session is successfully created or when payment succeeds, depending on the intended business rule.
- Define reversal semantics for failed/expired/refunded payments.
- Add tests covering:
- validation endpoint contract
- max-use exhaustion
- concurrent checkouts on the last available discount use
6. Payment.originalAmount is overwritten with the discounted net amount, so the system loses the gross pre-discount price
Evidence:
- Checkout starts from plan price, then subtracts the applied discount before creating the payment.
backend/routes/api/checkout/index.dart:318-340
createPayment() receives that already-discounted amount and stores:
amount = amount
originalAmount = amount
backend/lib/database/repositories/checkout_repository.dart:29-35
- The web checkout flow explicitly preserves gross and net values separately.
/Users/kaustavghosh/Desktop/familiarise_web/lib/payments/operations/checkout.ts:332-415
/Users/kaustavghosh/Desktop/familiarise_web/lib/payments/operations/checkout.ts:1569-1578
- The web payout/earnings code then relies on
originalAmount as the gross consultant-facing amount.
/Users/kaustavghosh/Desktop/familiarise_web/lib/payments/payouts/earnings-service.ts:64-65
Impact:
- the mobile backend cannot reliably distinguish:
- gross plan price
- discount amount
- net collected amount
- earnings, refunds, invoice generation, and analytics become harder or impossible to calculate correctly from local payment data
- this is a financial-data modeling bug, not just a UI reporting issue
Proposed fix:
- Preserve gross
originalAmount before applying discounts.
- Store discount amount explicitly or derive it from gross minus net.
- Add tests confirming:
- undiscounted payments keep
amount == originalAmount
- discounted payments keep
amount < originalAmount
- refund/earnings logic can reconstruct gross vs net correctly
7. Stripe verify is status-only, and without reconciliation a missed webhook can leave payments stuck in PENDING
Evidence:
- For Stripe, the verify route simply returns the current DB status; it does not query Stripe or reconcile drift.
backend/routes/api/checkout/verify.dart:119-131
- Payment rows have expiration metadata, but there is no mobile reconciliation/cleanup route for stuck Stripe payments.
- The web repo has a dedicated payment-status reconciliation path.
/Users/kaustavghosh/Desktop/familiarise_web/app/api/cleanup/reconcile-payment-status/route.ts:1-55
Impact:
- if Stripe succeeds but the webhook is delayed or lost, the mobile verify endpoint cannot repair the local state
- the user sees a perpetually pending payment even when the gateway may already know the outcome
- this increases support burden and encourages unsafe manual intervention
Proposed fix:
- Keep verify read-only for the normal path, but add server-side reconciliation against Stripe for stale
PENDING payments.
- Introduce a recovery job that periodically checks aged
PENDING payments.
- Add tests for delayed webhook vs reconciliation behavior.
Recommended fix plan
- Split payment flow into three explicit phases:
- gateway object creation
- transactional DB persistence
- asynchronous post-payment side effects
- Make verification endpoints safe by default:
- client verify should not be able to promote a payment to
SUCCEEDED unless the gateway object is bound to the payment record and gateway state is re-validated
- Add a recovery layer:
- abandoned payment cleanup
- payment status reconciliation
- webhook replay / failed-event reprocessing
- Fix financial data modeling:
- preserve
originalAmount
- track discount consumption and reversal rules explicitly
- Align mobile API contracts with frontend usage for discount validation and payment status checks.
Acceptance criteria
- a Razorpay callback cannot confirm any payment other than the one bound to that order
- checkout does not leave orphan
PENDING payments when gateway order/payment-intent creation fails
- expired or failed payments are cleaned up deterministically
- failed webhook processing can be retried or reconciled automatically
- discount validation endpoint and mobile client agree on the same route contract
- discount
maxUses is enforced by real counter mutation
Payment.originalAmount preserves gross value while amount preserves net collected value
- stale Stripe
PENDING payments can be reconciled without manual DB edits
Regression test matrix
- Razorpay verify rejects a valid signature tuple when the submitted order ID does not match the stored payment intent
- Razorpay verify accepts only the matching order/payment/signature for the correct payment
- Stripe order/payment-intent creation failure leaves no orphan local payment row
- Razorpay order creation failure leaves no orphan local payment row
- expired pending payments are marked failed and their tentative booking artifacts are cleaned
- webhook processing failure is recoverable through replay or reconciliation
- discount validation route works for the Flutter client contract
- discount code usage increments and max-use exhaustion is enforced
- discounted payments persist both gross and net amounts correctly
- stale Stripe
PENDING payments transition correctly after reconciliation
Summary
The mobile payment stack has several state-integrity problems beyond the booking bugs already captured in
#50and#51.The highest-risk problems are:
SUCCEEDEDPaymentrows before a gateway order/payment-intent existsTaken together, these defects can strand orphaned
PENDINGpayments, confirm unpaid bookings, overuse discount codes, and leave the system without a recovery path when webhooks or gateway calls fail.Scope
Primary mobile files:
backend/routes/api/checkout/index.dartbackend/routes/api/checkout/verify.dartbackend/lib/database/repositories/checkout_repository.dartbackend/routes/api/webhooks/stripe.dartbackend/routes/api/webhooks/razorpay.dartbackend/lib/services/webhook_handlers.dartlib/data/datasources/remote/checkout_remote_source.dartWeb parity references:
/Users/kaustavghosh/Desktop/familiarise_web/app/api/checkout/verify/route.ts/Users/kaustavghosh/Desktop/familiarise_web/app/api/payments/discounts/validate/route.ts/Users/kaustavghosh/Desktop/familiarise_web/lib/payments/operations/checkout.ts/Users/kaustavghosh/Desktop/familiarise_web/scripts/payments/cleanup-abandoned-payments.ts/Users/kaustavghosh/Desktop/familiarise_web/app/api/cleanup/reconcile-payment-status/route.tsFindings
1. Razorpay verify can falsely confirm the wrong payment because it never binds the submitted order ID to the payment being verified
Evidence:
payment_intent, not by stored gateway order ID.backend/routes/api/checkout/verify.dart:73-79backend/routes/api/checkout/verify.dart:92-103(razorpay_order_id, razorpay_payment_id, razorpay_signature)tuple is cryptographically valid.backend/routes/api/checkout/verify.dart:133-178razorpay_order_idequals the payment record’s storedpaymentIntent/ order ID before marking the paymentSUCCEEDED.backend/routes/api/checkout/verify.dart:198-237paymentIntent; it does not let a client-side callback mutate payment state with an unrelated order tuple./Users/kaustavghosh/Desktop/familiarise_web/app/api/checkout/verify/route.ts:23-80Impact:
Proposed fix:
razorpay_order_idto equal the stored payment gateway identifier for that payment row before verification proceeds.2. Checkout creates the database
Paymentrow before creating the real gateway object, which leaves orphanedPENDINGpayments on gateway failureEvidence:
Paymentrecord.backend/routes/api/checkout/index.dart:318-369backend/routes/api/checkout/index.dart:376-524CheckoutRepository.createPayment()also pre-populates a syntheticpaymentIntentvalue before any real gateway object exists.backend/lib/database/repositories/checkout_repository.dart:25-53Paymentrow is not deleted or marked failed.backend/routes/api/checkout/index.dart:430-447backend/routes/api/checkout/index.dart:506-523/Users/kaustavghosh/Desktop/familiarise_web/lib/payments/operations/checkout.ts:1503-1585Impact:
PENDINGpayment rows behindpaymentIntentthat never existed at the gatewayProposed fix:
PENDINGpayment remains.3. Failed or expired payments do not have a reliable cleanup path, even though the system records
expiresAtEvidence:
expiresAt = now + 1 hour.backend/lib/database/repositories/checkout_repository.dart:45-48FAILEDand sends a notification.backend/lib/services/webhook_handlers.dart:101-156backend/routes/api/checkout/index.dartbackend/routes/api/checkout/verify.dartbackend/routes/api/payments/[paymentId]/refunds.dartbackend/routes/api/payments/[paymentId]/disputes.dartbackend/routes/api/webhooks/stripe.dartbackend/routes/api/webhooks/razorpay.dart/Users/kaustavghosh/Desktop/familiarise_web/scripts/payments/cleanup-abandoned-payments.ts:104-260Impact:
PENDINGpayments can remain indefinitelyProposed fix:
4. Webhook processing failures are acknowledged with HTTP 200, but the mobile backend has no replay or reconciliation mechanism
Evidence:
backend/routes/api/webhooks/stripe.dart:190-200backend/routes/api/webhooks/razorpay.dart:193-203/Users/kaustavghosh/Desktop/familiarise_web/app/api/cleanup/reconcile-payment-status/route.ts:1-55Impact:
PENDINGor partially processed with no automated repair pathProposed fix:
Paymentrows and repairs drift.5. Discount-code application is inconsistent across the mobile stack and
maxUsesis not actually enforcedEvidence:
POST /api/checkout/validate-discount.lib/data/datasources/remote/checkout_remote_source.dart:211-244discountCodeIdon the payment.backend/routes/api/checkout/index.dart:323-369validateDiscountCode()checksmaxUsesvscurrentUses.backend/lib/database/repositories/checkout_repository.dart:173-233DiscountCode.currentUsesafter payment creation or success./Users/kaustavghosh/Desktop/familiarise_web/app/api/payments/discounts/validate/route.ts:22-119currentUsesincrement in the checkout transaction/Users/kaustavghosh/Desktop/familiarise_web/lib/payments/operations/checkout.ts:1595-1601Impact:
maxUsesis effectively advisory because successful checkouts do not consume usage countProposed fix:
DiscountCode.currentUsesatomically when a payment session is successfully created or when payment succeeds, depending on the intended business rule.6.
Payment.originalAmountis overwritten with the discounted net amount, so the system loses the gross pre-discount priceEvidence:
backend/routes/api/checkout/index.dart:318-340createPayment()receives that already-discounted amount and stores:amount = amountoriginalAmount = amountbackend/lib/database/repositories/checkout_repository.dart:29-35/Users/kaustavghosh/Desktop/familiarise_web/lib/payments/operations/checkout.ts:332-415/Users/kaustavghosh/Desktop/familiarise_web/lib/payments/operations/checkout.ts:1569-1578originalAmountas the gross consultant-facing amount./Users/kaustavghosh/Desktop/familiarise_web/lib/payments/payouts/earnings-service.ts:64-65Impact:
Proposed fix:
originalAmountbefore applying discounts.amount == originalAmountamount < originalAmount7. Stripe verify is status-only, and without reconciliation a missed webhook can leave payments stuck in
PENDINGEvidence:
backend/routes/api/checkout/verify.dart:119-131/Users/kaustavghosh/Desktop/familiarise_web/app/api/cleanup/reconcile-payment-status/route.ts:1-55Impact:
Proposed fix:
PENDINGpayments.PENDINGpayments.Recommended fix plan
SUCCEEDEDunless the gateway object is bound to the payment record and gateway state is re-validatedoriginalAmountAcceptance criteria
PENDINGpayments when gateway order/payment-intent creation failsmaxUsesis enforced by real counter mutationPayment.originalAmountpreserves gross value whileamountpreserves net collected valuePENDINGpayments can be reconciled without manual DB editsRegression test matrix
PENDINGpayments transition correctly after reconciliation