max / makenotwork
14 files changed,
+535 insertions,
-44 deletions
| @@ -0,0 +1,22 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_checkout_session_id = $1) AS \"exists!\"", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "exists!", | |
| 9 | + | "type_info": "Bool" | |
| 10 | + | } | |
| 11 | + | ], | |
| 12 | + | "parameters": { | |
| 13 | + | "Left": [ | |
| 14 | + | "Text" | |
| 15 | + | ] | |
| 16 | + | }, | |
| 17 | + | "nullable": [ | |
| 18 | + | null | |
| 19 | + | ] | |
| 20 | + | }, | |
| 21 | + | "hash": "4e989ded9c96a21d19a600f0ab66344166ec3f52462605e9116bce92a9c4ec75" | |
| 22 | + | } |
| @@ -0,0 +1,28 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n UPDATE transactions\n SET status = 'refunded'\n WHERE id = $1 AND status = 'completed'\n RETURNING id AS \"id: super::TransactionId\", item_id AS \"item_id: ItemId\"\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id: super::TransactionId", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "item_id: ItemId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | } | |
| 16 | + | ], | |
| 17 | + | "parameters": { | |
| 18 | + | "Left": [ | |
| 19 | + | "Uuid" | |
| 20 | + | ] | |
| 21 | + | }, | |
| 22 | + | "nullable": [ | |
| 23 | + | false, | |
| 24 | + | true | |
| 25 | + | ] | |
| 26 | + | }, | |
| 27 | + | "hash": "63c9327081c816fd579ac89f2c796f6c9fc75442cca0e6ed36ad88b3e306c57f" | |
| 28 | + | } |
| @@ -0,0 +1,22 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_payment_intent_id = $1) AS \"exists!\"", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "exists!", | |
| 9 | + | "type_info": "Bool" | |
| 10 | + | } | |
| 11 | + | ], | |
| 12 | + | "parameters": { | |
| 13 | + | "Left": [ | |
| 14 | + | "Text" | |
| 15 | + | ] | |
| 16 | + | }, | |
| 17 | + | "nullable": [ | |
| 18 | + | null | |
| 19 | + | ] | |
| 20 | + | }, | |
| 21 | + | "hash": "fb319a64afdb053c7054ec9a1eff88571d661f67b9c4958c1419dd862fae8f3a" | |
| 22 | + | } |
| @@ -857,6 +857,68 @@ pub async fn refund_transaction_by_payment_intent<'e>( | |||
| 857 | 857 | Ok(rows.into_iter().map(|r| (r.id, r.item_id)).collect()) | |
| 858 | 858 | } | |
| 859 | 859 | ||
| 860 | + | /// Mark a SINGLE transaction refunded by id, returning `(id, item_id)` if it | |
| 861 | + | /// transitioned from `completed`. Returns `None` if it was already refunded or | |
| 862 | + | /// otherwise not refundable (idempotent for webhook re-delivery). | |
| 863 | + | /// | |
| 864 | + | /// Used by the line-scoped `refund.created` handler: cart lines share a | |
| 865 | + | /// payment_intent, so refunding one line must touch only its own row — never the | |
| 866 | + | /// PI-wide [`refund_transaction_by_payment_intent`] (Run #2 Payments SERIOUS). | |
| 867 | + | #[tracing::instrument(skip_all)] | |
| 868 | + | pub async fn refund_transaction_by_id<'e>( | |
| 869 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 870 | + | id: TransactionId, | |
| 871 | + | ) -> Result<Option<(super::TransactionId, Option<ItemId>)>> { | |
| 872 | + | let row = sqlx::query!( | |
| 873 | + | r#" | |
| 874 | + | UPDATE transactions | |
| 875 | + | SET status = 'refunded' | |
| 876 | + | WHERE id = $1 AND status = 'completed' | |
| 877 | + | RETURNING id AS "id: super::TransactionId", item_id AS "item_id: ItemId" | |
| 878 | + | "#, | |
| 879 | + | id as TransactionId, | |
| 880 | + | ) | |
| 881 | + | .fetch_optional(executor) | |
| 882 | + | .await?; | |
| 883 | + | ||
| 884 | + | Ok(row.map(|r| (r.id, r.item_id))) | |
| 885 | + | } | |
| 886 | + | ||
| 887 | + | /// True if any transaction (any status) references this payment_intent. Lets the | |
| 888 | + | /// `charge.refunded` handler tell "already refunded" (line-scoped refunds marked | |
| 889 | + | /// the rows) apart from "genuinely unmatched" before queuing a pending refund. | |
| 890 | + | pub async fn transaction_exists_for_payment_intent<'e>( | |
| 891 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 892 | + | payment_intent_id: &str, | |
| 893 | + | ) -> Result<bool> { | |
| 894 | + | let exists = sqlx::query_scalar!( | |
| 895 | + | r#"SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_payment_intent_id = $1) AS "exists!""#, | |
| 896 | + | payment_intent_id, | |
| 897 | + | ) | |
| 898 | + | .fetch_one(executor) | |
| 899 | + | .await?; | |
| 900 | + | ||
| 901 | + | Ok(exists) | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | /// True if any transaction (any status) references this checkout session. Lets | |
| 905 | + | /// the cart-completion webhook tell a benign duplicate delivery (rows already | |
| 906 | + | /// completed) apart from an ORPHANED paid session (rows never created — buyer | |
| 907 | + | /// charged, got nothing) so the latter is escalated (Run #2 Payments SERIOUS). | |
| 908 | + | pub async fn transaction_exists_for_checkout_session<'e>( | |
| 909 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 910 | + | checkout_session_id: &str, | |
| 911 | + | ) -> Result<bool> { | |
| 912 | + | let exists = sqlx::query_scalar!( | |
| 913 | + | r#"SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_checkout_session_id = $1) AS "exists!""#, | |
| 914 | + | checkout_session_id, | |
| 915 | + | ) | |
| 916 | + | .fetch_one(executor) | |
| 917 | + | .await?; | |
| 918 | + | ||
| 919 | + | Ok(exists) | |
| 920 | + | } | |
| 921 | + | ||
| 860 | 922 | /// Revoke all child transactions linked to a parent (bundle) transaction. | |
| 861 | 923 | /// | |
| 862 | 924 | /// Returns the item IDs of revoked children so callers can decrement sales counts. |
| @@ -292,22 +292,36 @@ impl StripeClient { | |||
| 292 | 292 | Ok(session.url) | |
| 293 | 293 | } | |
| 294 | 294 | ||
| 295 | - | /// Issue a full refund for a payment on a connected account. | |
| 296 | - | #[tracing::instrument(skip_all, name = "payments::create_refund")] | |
| 297 | - | pub async fn create_refund( | |
| 295 | + | /// Issue a line-scoped refund for one transaction on a connected account. | |
| 296 | + | /// | |
| 297 | + | /// `amount_cents` is refunded against the shared PaymentIntent and the Stripe | |
| 298 | + | /// refund is tagged with `mnw_transaction_id` so the `refund.created` webhook | |
| 299 | + | /// marks and revokes exactly that transaction. Cart checkouts put every line | |
| 300 | + | /// of an order under ONE PaymentIntent, so a PI-wide refund would silently | |
| 301 | + | /// reverse the whole order (Run #2 Payments SERIOUS). | |
| 302 | + | #[tracing::instrument(skip_all, name = "payments::create_refund_for_transaction")] | |
| 303 | + | pub async fn create_refund_for_transaction( | |
| 298 | 304 | &self, | |
| 299 | 305 | payment_intent_id: &str, | |
| 300 | 306 | connected_account_id: &str, | |
| 307 | + | amount_cents: i64, | |
| 308 | + | transaction_id: crate::db::TransactionId, | |
| 301 | 309 | ) -> Result<()> { | |
| 302 | 310 | let acct = Self::parse_account_id(connected_account_id)?; | |
| 311 | + | let metadata = std::collections::HashMap::from([( | |
| 312 | + | "mnw_transaction_id".to_string(), | |
| 313 | + | transaction_id.to_string(), | |
| 314 | + | )]); | |
| 303 | 315 | CreateRefund::new() | |
| 304 | 316 | .payment_intent(payment_intent_id.to_string()) | |
| 317 | + | .amount(amount_cents) | |
| 318 | + | .metadata(metadata) | |
| 305 | 319 | .customize() | |
| 306 | 320 | .account_id(acct) | |
| 307 | 321 | .send(&self.client) | |
| 308 | 322 | .await | |
| 309 | 323 | .map_err(|e| { | |
| 310 | - | tracing::error!(payment_intent_id = %payment_intent_id, error = ?e, "failed to create Stripe refund"); | |
| 324 | + | tracing::error!(payment_intent_id = %payment_intent_id, transaction_id = %transaction_id, error = ?e, "failed to create Stripe line refund"); | |
| 311 | 325 | AppError::Internal(anyhow::anyhow!("Failed to create refund")) | |
| 312 | 326 | })?; | |
| 313 | 327 | Ok(()) |
| @@ -106,8 +106,16 @@ pub trait PaymentProvider: Send + Sync { | |||
| 106 | 106 | /// Create a Stripe-hosted billing portal session. Returns the URL to redirect to. | |
| 107 | 107 | async fn create_billing_portal_session(&self, stripe_customer_id: &str, return_url: &str) -> crate::error::Result<String>; | |
| 108 | 108 | ||
| 109 | - | // Refunds | |
| 110 | - | async fn create_refund(&self, payment_intent_id: &str, connected_account_id: &str) -> crate::error::Result<()>; | |
| 109 | + | // Refunds — line-scoped: refunds `amount_cents` of the shared PaymentIntent | |
| 110 | + | // and tags the refund with the transaction id so the refund.created webhook | |
| 111 | + | // marks/revokes exactly that line (cart orders share one PaymentIntent). | |
| 112 | + | async fn create_refund_for_transaction( | |
| 113 | + | &self, | |
| 114 | + | payment_intent_id: &str, | |
| 115 | + | connected_account_id: &str, | |
| 116 | + | amount_cents: i64, | |
| 117 | + | transaction_id: crate::db::TransactionId, | |
| 118 | + | ) -> crate::error::Result<()>; | |
| 111 | 119 | ||
| 112 | 120 | // Webhooks | |
| 113 | 121 | fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent>; | |
| @@ -229,8 +237,21 @@ impl PaymentProvider for StripeClient { | |||
| 229 | 237 | StripeClient::create_billing_portal_session(self, stripe_customer_id, return_url).await | |
| 230 | 238 | } | |
| 231 | 239 | ||
| 232 | - | async fn create_refund(&self, payment_intent_id: &str, connected_account_id: &str) -> crate::error::Result<()> { | |
| 233 | - | StripeClient::create_refund(self, payment_intent_id, connected_account_id).await | |
| 240 | + | async fn create_refund_for_transaction( | |
| 241 | + | &self, | |
| 242 | + | payment_intent_id: &str, | |
| 243 | + | connected_account_id: &str, | |
| 244 | + | amount_cents: i64, | |
| 245 | + | transaction_id: crate::db::TransactionId, | |
| 246 | + | ) -> crate::error::Result<()> { | |
| 247 | + | StripeClient::create_refund_for_transaction( | |
| 248 | + | self, | |
| 249 | + | payment_intent_id, | |
| 250 | + | connected_account_id, | |
| 251 | + | amount_cents, | |
| 252 | + | transaction_id, | |
| 253 | + | ) | |
| 254 | + | .await | |
| 234 | 255 | } | |
| 235 | 256 | ||
| 236 | 257 | fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent> { |
| @@ -316,6 +316,35 @@ impl ChargeRefundData { | |||
| 316 | 316 | } | |
| 317 | 317 | } | |
| 318 | 318 | ||
| 319 | + | /// Narrow view of a Refund object (`refund.created` / `refund.updated` events). | |
| 320 | + | /// | |
| 321 | + | /// The line-scoped self-service refund tags the Stripe refund with | |
| 322 | + | /// `metadata.mnw_transaction_id`; the webhook reads it back so a cart line refund | |
| 323 | + | /// marks/revokes exactly its own transaction rather than the whole order. | |
| 324 | + | #[derive(Debug, serde::Deserialize)] | |
| 325 | + | pub struct RefundView { | |
| 326 | + | #[serde(default)] | |
| 327 | + | pub amount: i64, | |
| 328 | + | pub status: Option<String>, | |
| 329 | + | #[serde(default, deserialize_with = "deserialize_expandable_id")] | |
| 330 | + | pub payment_intent: Option<String>, | |
| 331 | + | #[serde(default)] | |
| 332 | + | pub metadata: Option<std::collections::HashMap<String, String>>, | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | impl RefundView { | |
| 336 | + | /// The MNW transaction id this refund was tagged with at creation, if any. | |
| 337 | + | /// Absent for out-of-band refunds (e.g. issued from the Stripe dashboard). | |
| 338 | + | pub fn mnw_transaction_id(&self) -> Option<&str> { | |
| 339 | + | self.metadata.as_ref()?.get("mnw_transaction_id").map(String::as_str) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | /// Stripe marks a completed refund `succeeded`; only then is the money back. | |
| 343 | + | pub fn is_succeeded(&self) -> bool { | |
| 344 | + | self.status.as_deref() == Some("succeeded") | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 319 | 348 | // --------------------------------------------------------------------------- | |
| 320 | 349 | // v2 thin event types | |
| 321 | 350 | // --------------------------------------------------------------------------- |
| @@ -19,11 +19,13 @@ pub struct RefundRequest { | |||
| 19 | 19 | pub transaction_id: TransactionId, | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | - | /// Issue a full refund for a transaction on this item. | |
| 22 | + | /// Issue a line-scoped refund for one transaction on this item. | |
| 23 | 23 | /// | |
| 24 | - | /// The refund is sent to Stripe; the existing `charge.refunded` webhook | |
| 25 | - | /// handler marks the transaction as refunded, revokes license keys, and | |
| 26 | - | /// decrements the sales count. | |
| 24 | + | /// The refund is sent to Stripe for this transaction's amount only, tagged with | |
| 25 | + | /// the transaction id; the `refund.created` webhook marks THAT transaction | |
| 26 | + | /// refunded, revokes its license keys, and decrements its sales count. Cart | |
| 27 | + | /// orders put every line under one PaymentIntent, so refunding the whole PI | |
| 28 | + | /// would silently reverse the entire order (Run #2 Payments SERIOUS). | |
| 27 | 29 | #[tracing::instrument(skip_all, name = "items::refund_transaction")] | |
| 28 | 30 | pub(in crate::routes::api) async fn refund_transaction( | |
| 29 | 31 | State(state): State<AppState>, | |
| @@ -59,10 +61,16 @@ pub(in crate::routes::api) async fn refund_transaction( | |||
| 59 | 61 | let stripe_account_id = seller.stripe_account_id.as_deref() | |
| 60 | 62 | .ok_or_else(|| AppError::BadRequest("No Stripe account connected".into()))?; | |
| 61 | 63 | ||
| 62 | - | // Issue the refund via Stripe — the webhook handler does the rest | |
| 64 | + | // Issue the line-scoped refund via Stripe — the refund.created webhook marks | |
| 65 | + | // and revokes exactly this transaction (cart orders share a PaymentIntent). | |
| 63 | 66 | let stripe = state.stripe.as_ref() | |
| 64 | 67 | .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; | |
| 65 | - | stripe.create_refund(payment_intent_id, stripe_account_id).await?; | |
| 68 | + | stripe.create_refund_for_transaction( | |
| 69 | + | payment_intent_id, | |
| 70 | + | stripe_account_id, | |
| 71 | + | tx.amount_cents.as_i64(), | |
| 72 | + | tx.id, | |
| 73 | + | ).await?; | |
| 66 | 74 | ||
| 67 | 75 | Ok(Json(serde_json::json!({ "ok": true }))) | |
| 68 | 76 | } |
| @@ -446,10 +446,24 @@ async fn checkout_seller_cart( | |||
| 446 | 446 | } | |
| 447 | 447 | }; | |
| 448 | 448 | ||
| 449 | - | create_cart_pending_transactions( | |
| 449 | + | if let Err(e) = create_cart_pending_transactions( | |
| 450 | 450 | state, user.id, seller_id, &result.id, &still_paid, share_contact, promo_code_id, | |
| 451 | 451 | ) | |
| 452 | - | .await?; | |
| 452 | + | .await | |
| 453 | + | { | |
| 454 | + | // The Stripe session is already live and cannot be un-created here. If the | |
| 455 | + | // buyer pays it, the cart-completion webhook finds no pending rows and | |
| 456 | + | // escalates it as an orphaned paid session (Run #2 Payments SERIOUS). | |
| 457 | + | // Release the promo reservation so it isn't stuck held by a dead session. | |
| 458 | + | if let Some(pc_id) = promo_code_id { | |
| 459 | + | release_promo_quietly(state, pc_id, user.id).await; | |
| 460 | + | } | |
| 461 | + | tracing::error!( | |
| 462 | + | session_id = %result.id, error = ?e, | |
| 463 | + | "failed to create cart pending transactions after opening Stripe session; session is orphaned if paid" | |
| 464 | + | ); | |
| 465 | + | return Err(e).context("create cart pending transactions"); | |
| 466 | + | } | |
| 453 | 467 | ||
| 454 | 468 | // Cart items are removed by the webhook handler on successful payment, so a | |
| 455 | 469 | // canceled Stripe checkout leaves the cart intact. |
| @@ -287,6 +287,85 @@ pub(super) async fn handle_invoice_payment_failed( | |||
| 287 | 287 | Ok(()) | |
| 288 | 288 | } | |
| 289 | 289 | ||
| 290 | + | /// Revoke a single refunded transaction: decrement its item's sales count, | |
| 291 | + | /// revoke its license keys, and revoke + decrement any bundle-child transactions. | |
| 292 | + | /// Returns `(keys_revoked, children_revoked)` for logging. Caller must have | |
| 293 | + | /// already transitioned the row to `refunded` (so this runs exactly once per | |
| 294 | + | /// transaction). Shared by the PI-wide `charge.refunded` path and the | |
| 295 | + | /// line-scoped `refund.created` path. | |
| 296 | + | async fn revoke_refunded_transaction( | |
| 297 | + | db_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, | |
| 298 | + | tx_id: db::TransactionId, | |
| 299 | + | item_id: Option<db::ItemId>, | |
| 300 | + | ) -> Result<(u64, usize)> { | |
| 301 | + | // Project-level transactions store item_id IS NULL — skip the item-scoped | |
| 302 | + | // updates for those; the project-members split rows aren't sales-counted. | |
| 303 | + | if let Some(item_id) = item_id { | |
| 304 | + | db::items::decrement_sales_count(&mut **db_tx, item_id).await.context("decrement sales count")?; | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | let keys = db::license_keys::revoke_keys_by_transaction(db_tx, tx_id).await.context("revoke license keys")?; | |
| 308 | + | ||
| 309 | + | // Revoke child transactions granted via bundle purchase | |
| 310 | + | let revoked_children = db::transactions::revoke_child_transactions(&mut **db_tx, tx_id) | |
| 311 | + | .await.context("revoke bundle child transactions")?; | |
| 312 | + | for child_item_id in &revoked_children { | |
| 313 | + | db::items::decrement_sales_count(&mut **db_tx, *child_item_id) | |
| 314 | + | .await | |
| 315 | + | .context("decrement child item sales count")?; | |
| 316 | + | } | |
| 317 | + | Ok((keys, revoked_children.len())) | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | /// Handle a `refund.created` / `refund.updated` webhook for a line-scoped refund. | |
| 321 | + | /// | |
| 322 | + | /// The self-service refund tags the Stripe refund with `mnw_transaction_id`; we | |
| 323 | + | /// mark and revoke exactly that transaction. Cart lines share a PaymentIntent, so | |
| 324 | + | /// this is what keeps a single-line refund from touching the order's other lines | |
| 325 | + | /// (Run #2 Payments SERIOUS). Refunds without our metadata (e.g. issued from the | |
| 326 | + | /// Stripe dashboard) are left to the `charge.refunded` path. Idempotent: the | |
| 327 | + | /// `status = 'completed'` transition guard means re-delivery is a no-op, and a | |
| 328 | + | /// later `charge.refunded` for the same refund finds nothing left to mark. | |
| 329 | + | pub(super) async fn handle_refund_created( | |
| 330 | + | state: &AppState, | |
| 331 | + | refund: &crate::payments::RefundView, | |
| 332 | + | ) -> Result<()> { | |
| 333 | + | if !refund.is_succeeded() { | |
| 334 | + | return Ok(()); | |
| 335 | + | } | |
| 336 | + | let Some(tx_id_str) = refund.mnw_transaction_id() else { | |
| 337 | + | return Ok(()); // out-of-band refund; charge.refunded handles full ones | |
| 338 | + | }; | |
| 339 | + | let Ok(tx_id) = tx_id_str.parse::<db::TransactionId>() else { | |
| 340 | + | tracing::warn!(mnw_transaction_id = %tx_id_str, "refund metadata transaction id unparseable; ignoring"); | |
| 341 | + | return Ok(()); | |
| 342 | + | }; | |
| 343 | + | ||
| 344 | + | let mut db_tx = state.db.begin().await.context("begin line refund")?; | |
| 345 | + | let refunded = db::transactions::refund_transaction_by_id(&mut *db_tx, tx_id) | |
| 346 | + | .await | |
| 347 | + | .context("refund transaction by id")?; | |
| 348 | + | match refunded { | |
| 349 | + | Some((tx_id, item_id)) => { | |
| 350 | + | let (keys, children) = revoke_refunded_transaction(&mut db_tx, tx_id, item_id).await?; | |
| 351 | + | db_tx.commit().await.context("commit line refund")?; | |
| 352 | + | tracing::info!( | |
| 353 | + | transaction_id = %tx_id, | |
| 354 | + | keys_revoked = keys, | |
| 355 | + | bundle_children_revoked = children, | |
| 356 | + | "line refund processed" | |
| 357 | + | ); | |
| 358 | + | } | |
| 359 | + | None => { | |
| 360 | + | tracing::info!( | |
| 361 | + | transaction_id = %tx_id, | |
| 362 | + | "line refund: transaction not in a completed state (already refunded); no-op" | |
| 363 | + | ); | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | Ok(()) | |
| 367 | + | } | |
| 368 | + | ||
| 290 | 369 | /// Handle charge.refunded webhook; revoke license keys on full refund, | |
| 291 | 370 | /// log partial refunds without revoking access. | |
| 292 | 371 | /// Process a full refund: revoke transactions/keys/access, or (for the direct | |
| @@ -330,24 +409,9 @@ pub(super) async fn handle_charge_refunded( | |||
| 330 | 409 | let mut total_children_revoked = 0usize; | |
| 331 | 410 | ||
| 332 | 411 | for (tx_id, item_id) in &refunded { | |
| 333 | - | // Project-level transactions store item_id IS NULL — skip the item-scoped | |
| 334 | - | // updates for those; the project-members split rows aren't sales-counted. | |
| 335 | - | if let Some(item_id) = item_id { | |
| 336 | - | db::items::decrement_sales_count(&mut *db_tx, *item_id).await.context("decrement sales count")?; | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | let revoked = db::license_keys::revoke_keys_by_transaction(&mut db_tx, *tx_id).await.context("revoke license keys")?; | |
| 340 | - | total_keys_revoked += revoked; | |
| 341 | - | ||
| 342 | - | // Revoke child transactions granted via bundle purchase | |
| 343 | - | let revoked_children = db::transactions::revoke_child_transactions(&mut *db_tx, *tx_id) | |
| 344 | - | .await.context("revoke bundle child transactions")?; | |
| 345 | - | for child_item_id in &revoked_children { | |
| 346 | - | db::items::decrement_sales_count(&mut *db_tx, *child_item_id) | |
| 347 | - | .await | |
| 348 | - | .context("decrement child item sales count")?; | |
| 349 | - | } | |
| 350 | - | total_children_revoked += revoked_children.len(); | |
| 412 | + | let (keys, children) = revoke_refunded_transaction(&mut db_tx, *tx_id, *item_id).await?; | |
| 413 | + | total_keys_revoked += keys; | |
| 414 | + | total_children_revoked += children; | |
| 351 | 415 | } | |
| 352 | 416 | ||
| 353 | 417 | // Commit the refund atomically | |
| @@ -373,13 +437,24 @@ pub(super) async fn handle_charge_refunded( | |||
| 373 | 437 | .context("refund tip")?; | |
| 374 | 438 | if tip_refunded { | |
| 375 | 439 | tracing::info!(payment_intent_id = %payment_intent_id, "tip refund processed"); | |
| 440 | + | } else if db::transactions::transaction_exists_for_payment_intent(&state.db, payment_intent_id) | |
| 441 | + | .await | |
| 442 | + | .context("check transaction existence for refund")? | |
| 443 | + | { | |
| 444 | + | // Transactions exist for this PI but none were 'completed' — they were | |
| 445 | + | // already refunded (line-scoped refund.created marked them, or a prior | |
| 446 | + | // delivery did). Idempotent no-op; do NOT queue a pending refund. | |
| 447 | + | tracing::info!( | |
| 448 | + | payment_intent_id = %payment_intent_id, | |
| 449 | + | "charge.refunded: transactions already refunded; no-op" | |
| 450 | + | ); | |
| 376 | 451 | } else if requeue_if_unmatched { | |
| 377 | - | // No matching transaction or tip — the payment webhook likely hasn't | |
| 378 | - | // arrived yet. Queue the refund for later matching rather than | |
| 452 | + | // No transaction at all (and no tip) — the payment webhook likely | |
| 453 | + | // hasn't arrived yet. Queue the refund for later matching rather than | |
| 379 | 454 | // silently dropping it. | |
| 380 | 455 | tracing::warn!( | |
| 381 | 456 | payment_intent_id = %payment_intent_id, | |
| 382 | - | "no completed transaction or tip found — queuing as pending refund" | |
| 457 | + | "no transaction or tip found — queuing as pending refund" | |
| 383 | 458 | ); | |
| 384 | 459 | db::pending_refunds::insert_pending_refund( | |
| 385 | 460 | &state.db, |
| @@ -14,6 +14,45 @@ use super::checkout_helpers::{ | |||
| 14 | 14 | send_tip_email, subscribe_buyer_to_mailing_list, | |
| 15 | 15 | }; | |
| 16 | 16 | ||
| 17 | + | /// A `checkout.session.completed` produced no transaction to complete. Tell a | |
| 18 | + | /// benign duplicate webhook (rows already completed) apart from an ORPHANED paid | |
| 19 | + | /// session (rows never created — buyer charged, got nothing) and escalate the | |
| 20 | + | /// latter to WAM instead of silently logging "duplicate" (Run #2 Payments | |
| 21 | + | /// SERIOUS). Shared by the purchase, cart, and guest completion handlers so the | |
| 22 | + | /// three can't drift. | |
| 23 | + | async fn escalate_if_orphaned_session( | |
| 24 | + | state: &AppState, | |
| 25 | + | session_id: &str, | |
| 26 | + | payment_intent_id: &str, | |
| 27 | + | label: &str, | |
| 28 | + | ) -> Result<()> { | |
| 29 | + | let exists = db::transactions::transaction_exists_for_checkout_session(&state.db, session_id) | |
| 30 | + | .await | |
| 31 | + | .context("check session transaction existence")?; | |
| 32 | + | if exists { | |
| 33 | + | tracing::info!(session_id = %session_id, "{label} already completed, ignoring duplicate webhook"); | |
| 34 | + | } else { | |
| 35 | + | tracing::error!( | |
| 36 | + | session_id = %session_id, payment_intent_id = %payment_intent_id, | |
| 37 | + | "orphaned paid session ({label}): payment completed but no transaction exists — manual reconciliation required" | |
| 38 | + | ); | |
| 39 | + | if let Some(wam) = state.wam.clone() { | |
| 40 | + | let sid = session_id.to_string(); | |
| 41 | + | let pi = payment_intent_id.to_string(); | |
| 42 | + | let label = label.to_string(); | |
| 43 | + | tokio::spawn(async move { | |
| 44 | + | let body = format!( | |
| 45 | + | "Checkout session {sid} (payment_intent {pi}, {label}) completed at Stripe but has \ | |
| 46 | + | NO transaction — the pending row(s) were never created. The buyer was charged and \ | |
| 47 | + | received nothing. Reconcile manually: refund the payment or recreate the order." | |
| 48 | + | ); | |
| 49 | + | wam.create_ticket("Orphaned paid session", Some(&body), "high", "stripe-orphaned-session", Some(&sid)).await; | |
| 50 | + | }); | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | Ok(()) | |
| 54 | + | } | |
| 55 | + | ||
| 17 | 56 | /// Handle checkout.session.completed for one-time purchases | |
| 18 | 57 | #[tracing::instrument(skip_all, name = "stripe::handle_purchase_checkout")] | |
| 19 | 58 | pub(super) async fn handle_purchase_checkout_completed( | |
| @@ -128,7 +167,7 @@ pub(super) async fn handle_purchase_checkout_completed( | |||
| 128 | 167 | check_pending_refund(state, &payment_intent_id).await; | |
| 129 | 168 | } | |
| 130 | 169 | Ok(None) => { | |
| 131 | - | tracing::info!(session_id = %session_id, "transaction already completed, ignoring duplicate webhook"); | |
| 170 | + | escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "transaction").await?; | |
| 132 | 171 | } | |
| 133 | 172 | Err(e) => { | |
| 134 | 173 | tracing::error!(session_id = %session_id, error = ?e, "failed to complete transaction"); | |
| @@ -165,7 +204,7 @@ pub(super) async fn handle_cart_checkout_completed( | |||
| 165 | 204 | .context("complete cart transactions")?; | |
| 166 | 205 | ||
| 167 | 206 | if completed_txs.is_empty() { | |
| 168 | - | tracing::info!(session_id = %session_id, "cart transactions already completed, ignoring duplicate webhook"); | |
| 207 | + | escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "cart transactions").await?; | |
| 169 | 208 | return Ok(()); | |
| 170 | 209 | } | |
| 171 | 210 | ||
| @@ -554,7 +593,7 @@ pub(super) async fn handle_creator_tier_checkout_completed( | |||
| 554 | 593 | pub(super) async fn handle_tip_checkout_completed( | |
| 555 | 594 | state: &AppState, | |
| 556 | 595 | session: &crate::payments::CheckoutSessionView, | |
| 557 | - | _event_id: &str, | |
| 596 | + | event_id: &str, | |
| 558 | 597 | ) -> Result<()> { | |
| 559 | 598 | let session_id = session.id.clone(); | |
| 560 | 599 | tracing::info!(session_id = %session_id, "processing completed tip checkout"); | |
| @@ -575,6 +614,16 @@ pub(super) async fn handle_tip_checkout_completed( | |||
| 575 | 614 | amount_cents = %tip.amount_cents, "tip completed" | |
| 576 | 615 | ); | |
| 577 | 616 | ||
| 617 | + | // Log the event by id for audit parity with the other checkout | |
| 618 | + | // handlers (record_tip_splits mints split revenue, so an event-id | |
| 619 | + | // ledger entry matters for reconciliation). MINOR, Run #2 Payments. | |
| 620 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 621 | + | &state.db, None, event_id, "checkout.session.completed.tip", | |
| 622 | + | &serde_json::json!({"session_id": session_id, "tip_id": tip.id}), | |
| 623 | + | ).await { | |
| 624 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log tip event"); | |
| 625 | + | } | |
| 626 | + | ||
| 578 | 627 | // Record revenue splits if the tip's project has members | |
| 579 | 628 | if let Some(project_id) = tip.project_id { | |
| 580 | 629 | record_tip_splits(state, tip.id, project_id, tip.amount_cents).await; | |
| @@ -704,7 +753,7 @@ pub(super) async fn handle_guest_checkout_completed( | |||
| 704 | 753 | } | |
| 705 | 754 | None => { | |
| 706 | 755 | db_tx.commit().await.ok(); | |
| 707 | - | tracing::info!(session_id = %session_id, "guest transaction already completed, ignoring duplicate webhook"); | |
| 756 | + | escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "guest transaction").await?; | |
| 708 | 757 | } | |
| 709 | 758 | } | |
| 710 | 759 |
| @@ -17,7 +17,7 @@ use crate::{ | |||
| 17 | 17 | error::{AppError, Result, ResultExt}, | |
| 18 | 18 | payments::{ | |
| 19 | 19 | self, AccountUpdate, AccountView, ChargeRefundData, ChargeView, | |
| 20 | - | CheckoutSessionView, InvoiceView, SubscriptionView, UntypedEvent, | |
| 20 | + | CheckoutSessionView, InvoiceView, RefundView, SubscriptionView, UntypedEvent, | |
| 21 | 21 | }, | |
| 22 | 22 | AppState, | |
| 23 | 23 | }; | |
| @@ -156,10 +156,20 @@ pub(crate) async fn process_webhook_event( | |||
| 156 | 156 | .map_err(|e| AppError::BadRequest(format!("Failed to parse Charge: {e}")))?; | |
| 157 | 157 | if let Some(refund_data) = ChargeRefundData::from_view(charge) { | |
| 158 | 158 | // Direct webhook: queue as pending if the matching payment hasn't | |
| 159 | - | // landed yet. | |
| 159 | + | // landed yet. Out-of-band (dashboard) FULL refunds are handled | |
| 160 | + | // here; per-line refunds land via refund.created below. | |
| 160 | 161 | billing::handle_charge_refunded(state, &refund_data, true).await?; | |
| 161 | 162 | } | |
| 162 | 163 | } | |
| 164 | + | "refund.created" | "refund.updated" => { | |
| 165 | + | // Line-scoped self-service refunds tag the Stripe refund with | |
| 166 | + | // mnw_transaction_id; this marks/revokes exactly that transaction so | |
| 167 | + | // a cart line refund leaves the order's other lines untouched | |
| 168 | + | // (Run #2 Payments SERIOUS). Untagged refunds are no-ops here. | |
| 169 | + | let refund: RefundView = serde_json::from_value(data_object) | |
| 170 | + | .map_err(|e| AppError::BadRequest(format!("Failed to parse Refund: {e}")))?; | |
| 171 | + | billing::handle_refund_created(state, &refund).await?; | |
| 172 | + | } | |
| 163 | 173 | "customer.subscription.updated" => { | |
| 164 | 174 | let sub: SubscriptionView = serde_json::from_value(data_object) | |
| 165 | 175 | .map_err(|e| AppError::BadRequest(format!("Failed to parse Subscription: {e}")))?; |
| @@ -71,6 +71,18 @@ pub struct MockPaymentProvider { | |||
| 71 | 71 | /// `trial_days` passed to each creator-tier checkout, in call order. Lets | |
| 72 | 72 | /// the comp-code test assert the trial was actually threaded to Stripe. | |
| 73 | 73 | creator_tier_trial_days: Mutex<Vec<Option<i32>>>, | |
| 74 | + | /// Line-scoped refunds requested, in call order. Lets tests assert a cart | |
| 75 | + | /// line refund hits Stripe for only that line's amount + transaction id. | |
| 76 | + | refunds: Mutex<Vec<MockRefund>>, | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// A line-scoped refund captured by the mock. | |
| 80 | + | #[derive(Debug, Clone)] | |
| 81 | + | #[allow(dead_code)] | |
| 82 | + | pub struct MockRefund { | |
| 83 | + | pub payment_intent_id: String, | |
| 84 | + | pub amount_cents: i64, | |
| 85 | + | pub transaction_id: makenotwork::db::TransactionId, | |
| 74 | 86 | } | |
| 75 | 87 | ||
| 76 | 88 | #[allow(dead_code)] | |
| @@ -80,9 +92,15 @@ impl MockPaymentProvider { | |||
| 80 | 92 | checkouts: Mutex::new(Vec::new()), | |
| 81 | 93 | next_checkout_id: Mutex::new(1), | |
| 82 | 94 | creator_tier_trial_days: Mutex::new(Vec::new()), | |
| 95 | + | refunds: Mutex::new(Vec::new()), | |
| 83 | 96 | } | |
| 84 | 97 | } | |
| 85 | 98 | ||
| 99 | + | /// All line-scoped refunds requested so far. | |
| 100 | + | pub fn refunds(&self) -> Vec<MockRefund> { | |
| 101 | + | self.refunds.lock().unwrap().clone() | |
| 102 | + | } | |
| 103 | + | ||
| 86 | 104 | /// Return all checkouts created so far. | |
| 87 | 105 | pub fn checkouts(&self) -> Vec<MockCheckout> { | |
| 88 | 106 | self.checkouts.lock().unwrap().clone() | |
| @@ -204,7 +222,18 @@ impl PaymentProvider for MockPaymentProvider { | |||
| 204 | 222 | Ok(format!("https://billing.stripe.test/portal?return={}", urlencoding::encode(return_url))) | |
| 205 | 223 | } | |
| 206 | 224 | ||
| 207 | - | async fn create_refund(&self, _payment_intent_id: &str, _connected_account_id: &str) -> Result<()> { | |
| 225 | + | async fn create_refund_for_transaction( | |
| 226 | + | &self, | |
| 227 | + | payment_intent_id: &str, | |
| 228 | + | _connected_account_id: &str, | |
| 229 | + | amount_cents: i64, | |
| 230 | + | transaction_id: makenotwork::db::TransactionId, | |
| 231 | + | ) -> Result<()> { | |
| 232 | + | self.refunds.lock().unwrap().push(MockRefund { | |
| 233 | + | payment_intent_id: payment_intent_id.to_string(), | |
| 234 | + | amount_cents, | |
| 235 | + | transaction_id, | |
| 236 | + | }); | |
| 208 | 237 | Ok(()) | |
| 209 | 238 | } | |
| 210 | 239 |
| @@ -1566,6 +1566,114 @@ async fn webhook_charge_refunded_no_transaction_queues_pending() { | |||
| 1566 | 1566 | assert_eq!(amount_refunded, 1500); | |
| 1567 | 1567 | } | |
| 1568 | 1568 | ||
| 1569 | + | #[tokio::test] | |
| 1570 | + | async fn webhook_refund_created_line_scoped_does_not_reverse_cart() { | |
| 1571 | + | // Run #2 Payments SERIOUS: a cart puts every line of an order under ONE | |
| 1572 | + | // payment_intent. A self-service refund of one line tags the Stripe refund | |
| 1573 | + | // with mnw_transaction_id; the refund.created webhook must mark/revoke ONLY | |
| 1574 | + | // that transaction and leave the order's other lines untouched. | |
| 1575 | + | let mut h = TestHarness::with_stripe().await; | |
| 1576 | + | ||
| 1577 | + | let buyer_id = h.signup("clbuyer", "clb@test.com", "password123").await; | |
| 1578 | + | h.client.post_form("/logout", "").await; | |
| 1579 | + | let seller_id = h.signup("clseller", "cls@test.com", "password123").await; | |
| 1580 | + | h.grant_creator(seller_id).await; | |
| 1581 | + | h.client.post_form("/logout", "").await; | |
| 1582 | + | h.login("clseller", "password123").await; | |
| 1583 | + | ||
| 1584 | + | let resp = h | |
| 1585 | + | .client | |
| 1586 | + | .post_form("/api/projects", "slug=cartproj&title=Cart+Project") | |
| 1587 | + | .await; | |
| 1588 | + | let project: Value = resp.json(); | |
| 1589 | + | let project_id = project["id"].as_str().unwrap().to_string(); | |
| 1590 | + | ||
| 1591 | + | // Two items, both sold to the same buyer in one cart (shared payment_intent). | |
| 1592 | + | let mut item_ids = Vec::new(); | |
| 1593 | + | for (n, title) in [("Line+One", "a"), ("Line+Two", "b")] { | |
| 1594 | + | let _ = title; | |
| 1595 | + | let resp = h | |
| 1596 | + | .client | |
| 1597 | + | .post_form( | |
| 1598 | + | &format!("/api/projects/{}/items", project_id), | |
| 1599 | + | &format!("title={n}&price_cents=500&item_type=audio"), | |
| 1600 | + | ) | |
| 1601 | + | .await; | |
| 1602 | + | let item: Value = resp.json(); | |
| 1603 | + | item_ids.push(item["id"].as_str().unwrap().to_string()); | |
| 1604 | + | } | |
| 1605 | + | ||
| 1606 | + | let pi_id = "pi_cart_line_refund"; | |
| 1607 | + | let mut tx_ids = Vec::new(); | |
| 1608 | + | for item_id in &item_ids { | |
| 1609 | + | sqlx::query("UPDATE items SET sales_count = 1 WHERE id = $1::uuid") | |
| 1610 | + | .bind(item_id) | |
| 1611 | + | .execute(&h.db) | |
| 1612 | + | .await | |
| 1613 | + | .unwrap(); | |
| 1614 | + | let tx_id: uuid::Uuid = sqlx::query_scalar( | |
| 1615 | + | r#"INSERT INTO transactions | |
| 1616 | + | (buyer_id, seller_id, item_id, amount_cents, status, | |
| 1617 | + | stripe_payment_intent_id, stripe_checkout_session_id, | |
| 1618 | + | item_title, seller_username, completed_at) | |
| 1619 | + | VALUES ($1, $2, $3::uuid, 500, 'completed', $4, 'cs_cart', 'Line', 'clseller', NOW()) | |
| 1620 | + | RETURNING id"#, | |
| 1621 | + | ) | |
| 1622 | + | .bind(buyer_id) | |
| 1623 | + | .bind(seller_id) | |
| 1624 | + | .bind(item_id) | |
| 1625 | + | .bind(pi_id) | |
| 1626 | + | .fetch_one(&h.db) | |
| 1627 | + | .await | |
| 1628 | + | .unwrap(); | |
| 1629 | + | tx_ids.push(tx_id); | |
| 1630 | + | } | |
| 1631 | + | ||
| 1632 | + | // Refund ONLY the first line via refund.created carrying its transaction id. | |
| 1633 | + | let refund = serde_json::json!({ | |
| 1634 | + | "id": "re_cart_line_1", | |
| 1635 | + | "object": "refund", | |
| 1636 | + | "amount": 500, | |
| 1637 | + | "status": "succeeded", | |
| 1638 | + | "payment_intent": pi_id, | |
| 1639 | + | "metadata": { "mnw_transaction_id": tx_ids[0].to_string() }, | |
| 1640 | + | }); | |
| 1641 | + | let resp = post_event_json(&mut h, "refund.created", refund).await; | |
| 1642 | + | assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); | |
| 1643 | + | ||
| 1644 | + | // Line one is refunded; line two is untouched (the bug would refund both). | |
| 1645 | + | let status_one: String = | |
| 1646 | + | sqlx::query_scalar("SELECT status FROM transactions WHERE id = $1") | |
| 1647 | + | .bind(tx_ids[0]) | |
| 1648 | + | .fetch_one(&h.db) | |
| 1649 | + | .await | |
| 1650 | + | .unwrap(); | |
| 1651 | + | let status_two: String = | |
| 1652 | + | sqlx::query_scalar("SELECT status FROM transactions WHERE id = $1") | |
| 1653 | + | .bind(tx_ids[1]) | |
| 1654 | + | .fetch_one(&h.db) | |
| 1655 | + | .await | |
| 1656 | + | .unwrap(); | |
| 1657 | + | assert_eq!(status_one, "refunded", "refunded line must be marked refunded"); | |
| 1658 | + | assert_eq!(status_two, "completed", "sibling cart line must NOT be reversed"); | |
| 1659 | + | ||
| 1660 | + | // Sales count: only the refunded line's item decremented. | |
| 1661 | + | let sales_one: i32 = | |
| 1662 | + | sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid") | |
| 1663 | + | .bind(&item_ids[0]) | |
| 1664 | + | .fetch_one(&h.db) | |
| 1665 | + | .await | |
| 1666 | + | .unwrap(); | |
| 1667 | + | let sales_two: i32 = | |
| 1668 | + | sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid") | |
| 1669 | + | .bind(&item_ids[1]) | |
| 1670 | + | .fetch_one(&h.db) | |
| 1671 | + | .await | |
| 1672 | + | .unwrap(); | |
| 1673 | + | assert_eq!(sales_one, 0, "refunded line's sale decremented"); | |
| 1674 | + | assert_eq!(sales_two, 1, "sibling line's sale stands"); | |
| 1675 | + | } | |
| 1676 | + | ||
| 1569 | 1677 | // --------------------------------------------------------------------------- | |
| 1570 | 1678 | // Subscription / invoice edge cases (test-fuzz Phase 2.1) | |
| 1571 | 1679 | // --------------------------------------------------------------------------- |