Skip to content

Add expired payment retry and auto-sign flow#72

Merged
marcopesani merged 7 commits intomainfrom
claude/extend-payment-expiration-oDC8G
Feb 28, 2026
Merged

Add expired payment retry and auto-sign flow#72
marcopesani merged 7 commits intomainfrom
claude/extend-payment-expiration-oDC8G

Conversation

@marcopesani
Copy link
Owner

Summary

This PR adds support for retrying expired payments and enabling auto-sign policies for future payments. When a pending payment expires, users can now retry the payment request or enable auto-sign to automatically approve future payments from the same endpoint.

Key Changes

  • Expired Payment Handling: Payments are now marked as "expired" when the countdown reaches zero, with automatic server-side recording via expirePendingPaymentAction
  • Retry Flow: Added retryExpiredPayment action to re-execute the payment request and create a new pending payment if needed
  • Auto-Sign Integration: Added enableAutoSignAndRetry action that enables auto-sign policy for the endpoint before retrying, allowing future payments to be auto-approved if conditions are met
  • UI Updates:
    • Conditional button rendering based on payment status (expired vs. pending)
    • New "Retry" and "Enable Auto Sign & Retry" buttons for expired payments
    • "Reject" button renamed to "Cancel" for expired payments
    • Updated toast messages to reflect context ("Payment dismissed" vs. "Payment rejected")
  • Transaction Logging: Expired payments now create transaction records for audit trails across all expiration paths (UI countdown, approval race condition, MCP tools)
  • Dashboard Auto-Switch: Added logic to automatically switch to a chain with pending/expired payments if the current chain has none, preventing users from missing actionable payments
  • Query Improvements:
    • getPendingPayments now supports includeExpired option to show recently expired payments on dashboard
    • usePendingPayments hook updated to pass through the includeExpired flag
    • New getPendingPaymentChainId helper to find chains with actionable payments

Implementation Details

  • Uses useRef to ensure expired payment recording fires only once per card instance
  • Shared retryPaymentFlow function handles both retry and auto-sign+retry paths
  • Atomic upsert in ensureAutoSignPolicy safely creates or activates auto-sign policies
  • Consistent transaction logging across UI, API, and MCP tool expiration paths
  • Revalidates dashboard paths after all payment mutations for cache consistency

https://claude.ai/code/session_01LDPLUmKDP2Y7yMJzw2tcub

…sistence

When a pending payment expires, it now records an "expired" transaction
in the DB for audit trail and the card stays visible with three new
action buttons: Retry, Enable Auto Sign & Reply, and Cancel.

- Retry re-executes the full x402 payment flow with original params
- Enable Auto Sign & Reply upserts an active autoSign policy for the
  endpoint origin then retries (auto-signs if session key is available)
- Cancel dismisses the expired card (rejectPendingPayment now accepts
  both "pending" and "expired" preconditions)
- Dashboard auto-switches to the chain with pending payments on load
  when the cookie chain has none (auth-aware-providers override)
- MCP tools (check-pending, get-result) now also record expired
  transactions when detecting expiry

https://claude.ai/code/session_01LDPLUmKDP2Y7yMJzw2tcub
@vercel
Copy link

vercel bot commented Feb 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
brevet Error Error Feb 28, 2026 10:03pm

Request Review

devin-ai-integration[bot]

This comment was marked as resolved.

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

- Extract expirePaymentWithAudit into data layer to eliminate duplicated
  expire+transaction logic across 4 call sites and fix race condition
  where MCP tools could create duplicate expired transactions
- Replace silent parseFloat()||0 with explicit isNaN warning log so
  unparseable payment amounts are flagged instead of silently zeroed
- Transition old expired payment to rejected before creating new one on
  retry, preventing duplicate actionable entries in the dashboard
- Harden ensureAutoSignPolicy: throw on invalid URL instead of silently
  falling back to raw string; validate via validateEndpointPattern
- Parallelize getPendingCount + getPendingPaymentChainId in
  auth-aware-providers with Promise.all
- Add compound index (userId, status, chainId, expiresAt) for efficient
  pending payment queries
- Add tests for ensureAutoSignPolicy URL validation and origin extraction

https://claude.ai/code/session_01LDPLUmKDP2Y7yMJzw2tcub
devin-ai-integration[bot]

This comment was marked as resolved.

…xport

- Add 22 e2e tests covering every payment happy path:
  create→approve→complete, create→approve→fail, create→expire-with-audit,
  create→reject, expire idempotency (race-safety), dismiss expired,
  state precondition enforcement, user scoping, and query filters
- Make expirePendingPayment private (only used internally by
  expirePaymentWithAudit — was dead export caught by code audit)

https://claude.ai/code/session_01LDPLUmKDP2Y7yMJzw2tcub
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@marcopesani marcopesani merged commit 212f204 into main Feb 28, 2026
2 of 4 checks passed
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 14 additional findings in Devin Review.

Open in Devin Review

Comment on lines +33 to +36
const [pendingOnCookie, chainWithPending] = await Promise.all([
getPendingCount(user.userId, { chainId: initialChainId }),
getPendingPaymentChainId(user.userId),
]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Auto-switch logic uses inconsistent definitions of 'actionable payments'

The auto-switch logic in auth-aware-providers.tsx checks whether the cookie chain has actionable payments using getPendingCount, which only counts status: "pending" with expiresAt > now (src/lib/data/payments.ts:42-52). However, it determines the switch target using getPendingPaymentChainId, which counts both "pending" and "expired" payments (src/lib/data/payments.ts:240-249).

Root Cause: mismatched query semantics

With this PR, expired payments are now actionable (retry / auto-sign / dismiss). But the pendingOnCookie check still uses the old definition that excludes expired payments:

// getPendingCount: only status="pending" AND expiresAt > now
const [pendingOnCookie, chainWithPending] = await Promise.all([
  getPendingCount(user.userId, { chainId: initialChainId }),  // ignores expired
  getPendingPaymentChainId(user.userId),                      // includes expired
]);

Scenario: User has 3 expired payments on cookie chain (chain A) that they want to retry, and 1 newer pending payment on chain B.

  • pendingOnCookie = 0 (expired payments on chain A are not counted)
  • chainWithPending = chain B (newer pending payment)
  • Result: user is auto-switched to chain B, missing the expired payments on chain A

Expected: The cookie chain should be considered as having actionable payments because it has expired payments with retry/auto-sign actions available.

Impact: Users may be silently switched away from chains where they have actionable expired payments, causing confusion when they expect to see those payments.

Prompt for agents
In src/app/auth-aware-providers.tsx, line 34, replace the getPendingCount call with a query that also considers expired payments as actionable. Two options:

1. Add an includeExpired option to getPendingCount in src/lib/data/payments.ts (similar to how getPendingPayments already supports it), and call it with { chainId: initialChainId, includeExpired: true }.

2. Alternatively, reuse getPendingPayments with { chainId: initialChainId, includeExpired: true } and check the length, though this fetches more data than needed.

Option 1 is preferred. In src/lib/data/payments.ts, update getPendingCount to accept an includeExpired option:

export async function getPendingCount(userId: string, options?: { chainId?: number; includeExpired?: boolean }) {
  await connectDB();
  const filter: Record<string, unknown> = { userId: new Types.ObjectId(userId) };
  if (options?.includeExpired) {
    filter.status = { $in: ["pending", "expired"] };
  } else {
    filter.status = "pending";
    filter.expiresAt = { $gt: new Date() };
  }
  if (options?.chainId !== undefined) filter.chainId = options.chainId;
  return PendingPayment.countDocuments(filter);
}

Then in src/app/auth-aware-providers.tsx line 34, change to:
  getPendingCount(user.userId, { chainId: initialChainId, includeExpired: true })
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants