max / makenotwork
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 %} |