feat: expire stale payment-link intents and detect amount mismatches - #205
Merged
Conversation
Two real gaps found in live testing: 1. A payment intent that never completes stayed 'pending' in the DB forever -- the status CHECK only allowed pending/confirmed and nothing ever swept stale rows. Adds an 'expired' status; the ingest supervisor's tick() now sweeps pending intents older than 1 hour on every pass (a single indexed UPDATE, cheap even when it matches nothing) and fires a payment_link.expired webhook per row. 2. A deposit for the wrong amount (manual bank-style transfer sending too little or too much) was silently absorbed: the existing underpayment guard just logged a server-side warning and left the payment pending forever, with no webhook and no payer-facing signal. Overpayment wasn't checked at all -- it silently confirmed as if correct. Adds 'underpaid'/'overpaid' statuses, a payment_link.mismatched webhook carrying both the expected and received amounts, and the mismatch's transaction stays linked (so the merchant can see what actually arrived) without ever being marked confirmed. - migration 0018: widens the payment_link_payments status CHECK. - crates/store: expire_stale_payment_link_payments, mark_payment_link_payment_mismatched, get_payment_link_by_id (unscoped, for the sweep's internal webhook lookup), get_transaction. - crates/api: PaymentStatusView gains expected_usdc_stroops/ received_usdc_stroops so the pay page can render a mismatch banner without a second request. - Tests: sweep marks only >1hr-old pending rows and is idempotent; underpaid/overpaid/exact deposits each land on the correct status via a real Ingestor::process() call.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two real gaps found in live testing of the payment-link checkout:
Stale pending payments never expire. A payment intent that never completes stayed
pendingin the DB forever — thestatusCHECKonly allowedpending/confirmed, and nothing ever swept stale rows. Adds anexpiredstatus; the ingest supervisor'stick()now sweeps pending intents older than 1 hour on every pass (a single indexedUPDATE, cheap even when it matches nothing) and fires apayment_link.expiredwebhook per swept row.Wrong-amount deposits were silently absorbed. A manual/bank-style transfer sending too little or too much: the existing underpayment guard just logged a server-side warning and left the payment pending forever — no webhook, no payer-facing signal. Overpayment wasn't checked at all; it silently confirmed as if correct. Adds
underpaid/overpaidstatuses, apayment_link.mismatchedwebhook carrying both the expected and received amounts, and the mismatched deposit's transaction stays linked (so the merchant can see what actually arrived) without ever being markedconfirmed.0018: widens thepayment_link_payments.statusCHECK.crates/store:expire_stale_payment_link_payments,mark_payment_link_payment_mismatched,get_payment_link_by_id(unscoped, for the sweep's internal webhook lookup),get_transaction.crates/api:PaymentStatusViewgainsexpected_usdc_stroops/received_usdc_stroopsso the pay page can render a mismatch banner without a second request.Companion frontend PR wires required payer fields, an email-format check, a back button, and the new mismatch/expired states into the hosted pay page.
Test plan
cargo test --workspace --locked— all green (store: sweep marks only >1hr-old pending rows and is idempotent; ingest: underpaid/overpaid/exact deposits each land on the correct status via a realIngestor::process()call)cargo clippy --workspace --all-targets --locked -- -D warnings— cleancargo fmt --all -- --check— clean