fix(api): reuse or replace unpaid checkout by phone and cart#301

merged3
payment-continue-sheet
payment-addorder-reuse
openedAug 29, 2026byAAviddaram
View on GitHubTwo-way sync enabledPR last imported 20d ago

Same-cart Facebook retries were creating a new Order on every addOrder because #299 dropped server reuse in favor of client resume.

This layer restores server ownership: same phone + same cart within 2h returns the existing unpaid Payment; a different cart fails the prior pending Payment, cancels its Order, then creates. customer_claimed_paid is not auto-failed.

Depends on #300. Stack: #300 → #301 → #302

Test plan

  • Submit checkout twice with the same phone and cart within minutes — one Order, same paymentNumber
  • Change the cart and submit again — prior pending Payment becomes failed, Order cancelled, new Order created
  • Reuse path does not re-send Messenger new-order or precreate a second QPay invoice

Checks

4 successful · 1 skipped
All checks passed
f82c23a

Added 1 commit

  • fix(api): reuse or replace unpaid checkout by phone and cartf19d507
Synced from GitHub

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • v2

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 602c054e-2ca9-4991-a8e9-3c987f6e08ec

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Synced from GitHub

Greptile Summary

This PR deduplicates recent unpaid checkouts by customer phone and normalized cart contents, reusing matching orders or cancelling and replacing different pending carts.

  • Adds a deterministic cart fingerprint helper.
  • Reuses matching orders and payments within a two-hour window.
  • Retires a prior pending payment and order when the submitted cart differs.
  • Suppresses duplicate invoice creation, notifications, and analytics for reused checkouts.

Confidence Score: 1/5

This PR is not safe to merge until existing-checkout ownership is authenticated, cancellation is coupled to a successful payment transition, and reused totals remain consistent.

The public reuse flow can expose and modify another customer's checkout, concurrent claiming can leave a claimed payment attached to a cancelled order, and price changes can make the reused response disagree with persisted payment data.

Files Needing Attention: packages/api/src/routers/store/order.ts

Security Review

The reuse branch allows an unauthenticated caller with a matching phone and cart to modify another customer's pending order and obtain checkout credentials for its payment.

Important Files Changed

FilenameOverview
packages/api/src/lib/order/cart-fingerprint.tsAdds deterministic normalization and ordering for product-and-quantity cart identities; no independent defect was identified in the helper.
packages/api/src/routers/store/order.tsAdds checkout reuse and replacement behavior, but introduces an authorization bypass, a claimed-payment cancellation race, and inconsistent totals when prices change before reuse.

Sequence Diagram

mermaid
1sequenceDiagram
2 participant C as Checkout caller
3 participant API as addOrder
4 participant DB as Database
5 participant KV as Checkout-token store
6 C->>API: phone, cart, address
7 API->>DB: Find recent created order by phone
8 DB-->>API: Order, details, open payment
9 alt Cart fingerprint matches
10 API->>DB: Update existing address and notes
11 API->>KV: Issue token for existing payment
12 API-->>C: Existing order/payment and checkout token
13 else Cart differs and payment was pending
14 API->>DB: Mark prior payment failed
15 API->>DB: Cancel prior order
16 API->>DB: Create replacement order/payment
17 API-->>C: New checkout
18 end

Reviews (1): Last reviewed commit: "fix(api): reuse or replace unpaid checko..." | Re-trigger Greptile

packages/api/src/routers/store/order.tslines 198–210Outdated · f19d507Open thread
……@@ -163,6 +168,90 @@ export const order = router({
207+ addressZoneId: input.addressZoneId ?? pendingOrder.addressZoneId,
208+ notes: input.notes ?? null,
209+ })
210+ .where(eq(OrdersTable.id, pendingOrder.id));
Ggreptile-appsBoton lines 198–210Aug 29, 2026

P1 security Reuse bypasses checkout ownership

When an unauthenticated caller submits another customer's phone number and matching cart, this branch updates the existing order and returns its payment number, after which the flow issues checkout credentials accepted by protected order and payment operations. This lets the caller overwrite delivery details and access the customer's checkout. How this was verified: The public request's phone and cart flow directly into the lookup and update without ownership verification, and the resulting guest claims or token authorize the selected payment.

Known tradeoff. Guest addOrder is already public and keyed by typed phone. Facebook iOS kills the session, so a session-phone gate recreates the production duplicate case. Phone + matching cart within 2h is the identity that survives webview death. Leaving this thread open for a human call if we want OTP before reuse instead.

Understood. Given that guest checkout is intentionally public and Facebook iOS can destroy the session, phone + matching cart within the 2-hour window is the practical identity available for deduplication. This still means someone who knows the phone and cart could overwrite checkout details, but that is an acknowledged product tradeoff rather than an implementation oversight. Leaving the thread open for a human decision on requiring OTP before reuse is reasonable.

packages/api/src/routers/store/order.tslines 198–217Outdated · f19d507Open thread
……@@ -163,6 +168,90 @@ export const order = router({
214+ orderId: pendingOrder.id,
215+ orderNumber: pendingOrder.orderNumber,
216+ paymentNumber: openPayment.paymentNumber,
217+ };
Ggreptile-appsBoton lines 198–217Aug 29, 2026

P1 Reused checkout returns stale amount

When a product price changes before a matching checkout is retried, total is recomputed from current prices but this branch retains the original order total and payment amount. The addOrder response consequently reports a different amount from the persisted payment and subsequent getPaymentByNumber response.

Fixed: reuse returns the persisted payment amount (openPayment.amount), not the recomputed cart total.

packages/api/src/routers/store/order.tslines 227–246Outdated · f19d507Open thread
……@@ -163,6 +168,90 @@ export const order = router({
243+ eq(OrdersTable.status, "created"),
244+ isNull(OrdersTable.deletedAt),
245+ ),
246+ );
Ggreptile-appsBoton lines 227–246Aug 29, 2026

P1 Claimed orders can be cancelled

If the customer claims the transfer after this transaction reads the payment as pending, the conditional payment update affects no row, but the independent order update still marks the created order as cancelled. The payment therefore remains customer_claimed_paid while its order is cancelled instead of remaining available for support review.

Fixed: cancel the Order only when the Payment pending → failed update returns a row, so a concurrent claim leaves the claimed Payment and its Order alone.

Cchatgpt-codex-connectorBotreviewed · 1 inline threadAug 29, 2026
Synced from GitHub

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f19d50718a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

packages/api/src/routers/store/order.tsline 177Outdated · f19d507Open thread
……@@ -163,6 +168,90 @@ export const order = router({
174+ eq(OrdersTable.customerPhone, customerPhone),
175+ eq(OrdersTable.status, "created"),
176+ isNull(OrdersTable.deletedAt),
177+ gte(OrdersTable.createdAt, reuseAfter),

P1 Badge Cancel payable checkouts older than the reuse window

The createdAt >= reuseAfter predicate excludes every unpaid checkout older than two hours from both the reuse and replacement paths. If a customer returns later and submits a new cart, the old Payment remains pending while this mutation creates another pending Payment, breaking the one-payable-checkout-per-phone invariant and leaving two payments the customer can act on. Use the time window only to decide whether a matching cart is a retry; an older payable Payment still needs to be found and retired before inserting its replacement.

Useful? React with 👍 / 👎.

Fixed: dropped the 2h window. Latest created unpaid checkout for the phone is always reused (same cart) or retired (different cart) before insert.

Added 1 commit

  • fix(api): only cancel prior order after payment fail sticks97c87b7

Added 1 commit

  • fix(api): retire any prior unpaid checkout, not only recent onesf82c23a
Cchatgpt-codex-connectorBotreviewed · 2 inline threadsAug 29, 2026
Synced from GitHub

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f82c23a0f9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

packages/api/src/routers/store/order.tslines 208–212Open thread
……@@ -163,6 +169,99 @@ export const order = router({
209+ .set({
210+ address: input.address,
211+ addressZoneId: input.addressZoneId ?? pendingOrder.addressZoneId,
212+ notes: input.notes ?? null,
Cchatgpt-codex-connectorBoton lines 208–212Aug 29, 2026

P1 Badge Do not mutate claimed-paid orders during reuse

Because this public procedure treats customer_claimed_paid as reusable, a same-cart submission reaches this update and can replace the address and notes of an order whose bank transfer is already under review; the response subsequently issues a fresh checkout token and guest session. A caller only needs the phone number and matching product quantities to redirect the order before support confirms it, after which the existing confirmation flow promotes the same mutated order. Claimed payments should be returned without mutable updates and without granting new access absent authorization.

Useful? React with 👍 / 👎.

……@@ -163,6 +169,99 @@ export const order = router({
231+ ) {
232+ const [failedPayment] = await tx
233+ .update(PaymentsTable)
234+ .set({ status: "failed" })

P1 Badge Prevent retired payments from accepting QPay funds

When a customer submits a different cart after the old QR was created—or while its queued precreateQpayInvoice is still running—this marks the payment failed without invalidating the provider invoice. Both precreateQpayInvoice and payment.createQr reject only success, so the failed payment can still expose or create a payable QR, while confirmPaymentAndApplyStock transitions only pending/claimed payments; a successful charge therefore leaves the customer with a cancelled order and an unrecognized payment. The retirement path must cancel/invalidate the invoice and all invoice-creation paths must refuse failed payments.

Useful? React with 👍 / 👎.