max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
14 files changed,
+535 insertions,
-44 deletions
| @@ -857,6 +857,68 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 | // --------------------------------------------------------------------------- |
| @@ -71,6 +71,18 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 | // --------------------------------------------------------------------------- |