Skip to main content

max / makenotwork

v0.6.3: surface pending checkout conflict on purchase page When a buyer has an in-progress (pending) Stripe checkout for an item and revisits the purchase page or retries Continue to Payment, the unique-pending index would silently bounce them to /i/{id} with no explanation. They had to wait for the nightly sweep (>25h) to retry. Now /purchase/{id} detects the pending row and renders a notice with a Cancel button. POST /stripe/checkout/{id}/cancel-pending deletes the buyer's pending row (releasing any reserved promo code) and returns to the purchase page so they can start fresh. The 23505 fallback in create_checkout now redirects to /purchase/{id} (not /i/{id}) so the notice is what they see.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-17 18:06 UTC
Commit: aedfc5a54c3875a1f42dc739f2108597b431cd36
Parent: c67a44e
8 files changed, +122 insertions, -3 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.6.2"
3 + version = "0.6.3"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -1002,6 +1002,53 @@ pub async fn cleanup_stale_pending(
1002 1002 Ok(rows.into_iter().map(|(id,)| id).collect())
1003 1003 }
1004 1004
1005 + /// Returns the buyer's pending transaction for a specific item, if any.
1006 + /// Used to surface in-progress checkouts on the purchase page.
1007 + #[tracing::instrument(skip_all)]
1008 + pub async fn get_pending_item_purchase(
1009 + pool: &PgPool,
1010 + buyer_id: UserId,
1011 + item_id: ItemId,
1012 + ) -> Result<Option<(TransactionId, chrono::DateTime<chrono::Utc>)>> {
1013 + let row: Option<(TransactionId, chrono::DateTime<chrono::Utc>)> = sqlx::query_as(
1014 + r#"
1015 + SELECT id, created_at FROM transactions
1016 + WHERE buyer_id = $1 AND item_id = $2 AND status = 'pending'
1017 + LIMIT 1
1018 + "#,
1019 + )
1020 + .bind(buyer_id)
1021 + .bind(item_id)
1022 + .fetch_optional(pool)
1023 + .await?;
1024 +
1025 + Ok(row)
1026 + }
1027 +
1028 + /// Delete the buyer's pending transaction for a specific item.
1029 + /// Returns any released `promo_code_id` so the caller can release its
1030 + /// reservation.
1031 + #[tracing::instrument(skip_all)]
1032 + pub async fn delete_pending_item_purchase(
1033 + pool: &PgPool,
1034 + buyer_id: UserId,
1035 + item_id: ItemId,
1036 + ) -> Result<Option<super::PromoCodeId>> {
1037 + let row: Option<(Option<super::PromoCodeId>,)> = sqlx::query_as(
1038 + r#"
1039 + DELETE FROM transactions
1040 + WHERE buyer_id = $1 AND item_id = $2 AND status = 'pending'
1041 + RETURNING promo_code_id
1042 + "#,
1043 + )
1044 + .bind(buyer_id)
1045 + .bind(item_id)
1046 + .fetch_optional(pool)
1047 + .await?;
1048 +
1049 + Ok(row.and_then(|(id,)| id))
1050 + }
1051 +
1005 1052 /// Create a completed free guest transaction.
1006 1053 ///
1007 1054 /// Returns the number of rows inserted (0 if already claimed via ON CONFLICT).
@@ -188,6 +188,15 @@ pub(super) async fn purchase_page(
188 188 let pwyw_min = db_item.pwyw_min_cents.unwrap_or(0);
189 189 let pwyw_min_dollars = format!("{:.2}", pwyw_min as f64 / 100.0);
190 190
191 + let pending_started = if let Some(ref u) = maybe_user {
192 + match db::transactions::get_pending_item_purchase(&state.db, u.id, id).await? {
193 + Some((_, created_at)) => format_relative_ago(created_at),
194 + None => String::new(),
195 + }
196 + } else {
197 + String::new()
198 + };
199 +
191 200 Ok(PurchaseTemplate {
192 201 csrf_token,
193 202 item,
@@ -201,10 +210,28 @@ pub(super) async fn purchase_page(
201 210 pwyw_min_dollars,
202 211 stripe_tax_enabled: db_user.stripe_tax_enabled,
203 212 is_logged_in,
213 + pending_started,
204 214 }
205 215 .into_response())
206 216 }
207 217
218 + fn format_relative_ago(ts: chrono::DateTime<chrono::Utc>) -> String {
219 + let delta = chrono::Utc::now().signed_duration_since(ts);
220 + let secs = delta.num_seconds().max(0);
221 + if secs < 60 {
222 + "just now".to_string()
223 + } else if secs < 3600 {
224 + let m = secs / 60;
225 + format!("{m} minute{} ago", if m == 1 { "" } else { "s" })
226 + } else if secs < 86400 {
227 + let h = secs / 3600;
228 + format!("{h} hour{} ago", if h == 1 { "" } else { "s" })
229 + } else {
230 + let d = secs / 86400;
231 + format!("{d} day{} ago", if d == 1 { "" } else { "s" })
232 + }
233 + }
234 +
208 235 /// Render a purchase receipt page.
209 236 #[tracing::instrument(skip_all, name = "content::receipt_page")]
210 237 pub(super) async fn receipt_page(
@@ -364,7 +364,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
364 364 db::promo_codes::release_use_count(&state.db, pc_id).await.ok();
365 365 }
366 366 tracing::info!(buyer_id = %user.id, item_id = %item_uuid, "duplicate pending checkout blocked");
367 - return Ok(Redirect::to(&format!("/i/{}", item_id)).into_response());
367 + return Ok(Redirect::to(&format!("/purchase/{}", item_id)).into_response());
368 368 }
369 369 Err(e) => {
370 370 if let Some(pc_id) = promo_code_id {
@@ -381,6 +381,31 @@ pub(in crate::routes::stripe) async fn create_checkout(
381 381 Ok(Redirect::to(&checkout_url).into_response())
382 382 }
383 383
384 + /// POST /stripe/checkout/{item_id}/cancel-pending — delete the buyer's
385 + /// in-progress checkout for this item so they can start a fresh one.
386 + ///
387 + /// Safe to call when no pending row exists (no-op). Releases any reserved
388 + /// promo code use_count.
389 + #[tracing::instrument(skip_all, name = "stripe::cancel_pending", fields(item_id))]
390 + pub(in crate::routes::stripe) async fn cancel_pending_item_checkout(
391 + State(state): State<AppState>,
392 + AuthUser(user): AuthUser,
393 + Path(item_id): Path<String>,
394 + ) -> Result<Response> {
395 + tracing::Span::current().record("item_id", tracing::field::display(&item_id));
396 + let item_uuid: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
397 +
398 + if let Some(promo_id) =
399 + db::transactions::delete_pending_item_purchase(&state.db, user.id, item_uuid)
400 + .await
401 + .context("delete pending item checkout")?
402 + {
403 + db::promo_codes::release_use_count(&state.db, promo_id).await.ok();
404 + }
405 +
406 + Ok(Redirect::to(&format!("/purchase/{}", item_id)).into_response())
407 + }
408 +
384 409 /// Grant access to all child items of a purchased bundle.
385 410 ///
386 411 /// For each child item, creates a completed $0 transaction (idempotent via
@@ -6,7 +6,7 @@ mod subscriptions;
6 6 mod tips;
7 7 mod cart;
8 8
9 - pub(in crate::routes::stripe) use item::create_checkout;
9 + pub(in crate::routes::stripe) use item::{cancel_pending_item_checkout, create_checkout};
10 10 pub(crate) use item::grant_bundle_items;
11 11 pub(in crate::routes::stripe) use project::create_project_checkout;
12 12 pub(in crate::routes::stripe) use subscriptions::{
@@ -32,6 +32,7 @@ pub fn stripe_routes() -> Router<AppState> {
32 32 .route("/stripe/billing-portal", post(checkout::open_billing_portal))
33 33 .route("/stripe/creator-tier", post(checkout::create_creator_tier_checkout))
34 34 .route("/stripe/checkout/{item_id}", post(checkout::create_checkout))
35 + .route("/stripe/checkout/{item_id}/cancel-pending", post(checkout::cancel_pending_item_checkout))
35 36 .route("/stripe/checkout/project/{project_id}", post(checkout::create_project_checkout))
36 37 .route("/stripe/subscribe/{tier_id}", post(checkout::create_subscription_checkout))
37 38 .route("/stripe/checkout/tip/{recipient_id}", post(checkout::create_tip_checkout))
@@ -478,6 +478,10 @@ pub struct PurchaseTemplate {
478 478 pub stripe_tax_enabled: bool,
479 479 /// Whether the current visitor is logged in (show guest checkout if not).
480 480 pub is_logged_in: bool,
481 + /// If the buyer has an in-progress (pending) checkout for this item,
482 + /// the relative time it was started (e.g. "5 minutes ago"). Empty
483 + /// string means no pending checkout.
484 + pub pending_started: String,
481 485 }
482 486
483 487 /// Minimal direct purchase page — no navigation, for link-in-bio sharing.
@@ -111,7 +111,21 @@
111 111 You'll be redirected to a secure checkout page to complete your purchase.
112 112 </p>
113 113
114 + {% if is_logged_in && !pending_started.is_empty() %}
115 + <div class="info-box" style="margin-bottom: 1.5rem; background: var(--surface-muted); padding: 1rem 1.25rem;">
116 + <p style="margin: 0 0 0.75rem;"><strong>You have an unfinished checkout</strong> for this item, started {{ pending_started }}.</p>
117 + <p style="margin: 0 0 0.75rem; font-size: 0.9rem; opacity: 0.8;">
118 + Stripe only allows one open checkout at a time. Cancel the previous attempt to start a new one.
119 + </p>
120 + <form action="/stripe/checkout/{{ item.id }}/cancel-pending" method="POST" style="margin: 0;">
121 + <input type="hidden" name="_csrf" value="{{ csrf_token.as_deref().unwrap_or_default() }}">
122 + <button class="secondary" type="submit">Cancel previous checkout</button>
123 + </form>
124 + </div>
125 + {% endif %}
126 +
114 127 {% if is_logged_in %}
128 + {% if pending_started.is_empty() %}
115 129 <form action="/stripe/checkout/{{ item.id }}" method="POST">
116 130 {% if pwyw_enabled %}
117 131 <div style="margin-bottom: 1rem;">
@@ -141,6 +155,7 @@
141 155 <p style="text-align: center; margin-top: 0.75rem; font-size: 0.85rem; opacity: 0.7;">
142 156 or <a href="#" onclick="fetch('/api/cart/{{ item.id }}',{method:'POST',headers:csrfHeaders()}).then(function(r){if(!r.ok)throw new Error('Failed');return r.json()}).then(function(){window.location.href='/cart'}).catch(function(){alert('Could not add to cart. Please try again.')});return false;">add to cart</a> to buy with other items and save the creator on fees
143 157 </p>
158 + {% endif %}
144 159 {% else %}
145 160 <div id="guest-checkout">
146 161 {% if pwyw_enabled %}