Skip to main content

max / makenotwork

Fund the Fan+ credit from the platform, not the creator (ultra-fuzz Run 12 Payments) The $5 Fan+ renewal credit is a platform-wide discount applied to a Direct Charge on the creator's connected account, with no reimbursement — so the creator absorbed MNW's marketing perk on both item and cart checkout, contradicting the "0% platform fee, creators keep everything" promise. Make the creator whole: MNW funds the credit via a platform -> connected transfer. apply_promo_to_item now returns an AppliedDiscount carrying its DiscountFunding (CreatorFunded vs PlatformFunded { credit_cents }), so both checkout paths destructure the same value and cannot diverge again — applying a platform-wide credit without recording the reimbursement is uncompilable. The obligation rides on the transaction row (platform_credit_cents); a scheduler sweep claims it, transfers the owed amount with a deterministic idempotency key (platform-credit-<txn>), and marks it settled. Crash-window rows escalate rather than blindly retry, mirroring pending_refunds. Covers the item, cart, and free-via-credit paths. Guest/project carry no platform credit; guest checkout guards the invariant.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 13:37 UTC
Signed with PGP, not checked
Commit: 7a7887861ed93a2467579fd85c1b0d8fc66fb765
Parent: 75fd955
27 files changed, +730 insertions, -84 deletions
@@ -142,7 +142,7 @@
142 142 async-stripe-shared = { version = "1.0.0-rc.5", features = ["deserialize"] }
143 143 async-stripe-billing = { version = "1.0.0-rc.5", features = ["deserialize", "subscription", "billing_portal_session"] }
144 144 async-stripe-checkout = { version = "1.0.0-rc.5", features = ["deserialize", "checkout_session"] }
145 - async-stripe-connect = { version = "1.0.0-rc.5", features = ["deserialize", "account", "account_link"] }
145 + async-stripe-connect = { version = "1.0.0-rc.5", features = ["deserialize", "account", "account_link", "transfer"] }
146 146 async-stripe-core = { version = "1.0.0-rc.5", features = ["deserialize", "balance", "refund", "customer"] }
147 147 async-stripe-payment = { version = "1.0.0-rc.5", features = ["deserialize"] }
148 148 async-stripe-product = { version = "1.0.0-rc.5", features = ["deserialize", "product", "price"] }
@@ -66,6 +66,7 @@
66 66 pub(crate) mod project_members;
67 67 pub mod idempotency; // pub so the integration test crate can exercise it directly
68 68 pub mod pending_refunds;
69 + pub mod platform_credits;
69 70 pub mod webhook_events;
70 71 pub(crate) mod scheduler_jobs;
71 72 pub(crate) mod moderation;
@@ -770,10 +770,49 @@
770 770 BelowMinPrice,
771 771 }
772 772
773 + /// Who bears the cost of an applied discount.
774 + ///
775 + /// A seller's own code reduces that seller's payout, as intended. A platform-wide
776 + /// credit (the Fan+ renewal credit) is MNW's marketing perk: the creator must be
777 + /// reimbursed for `credit_cents` so they still net the full price, honouring the
778 + /// "0% platform fee, creators keep everything" promise. Carrying the funding source
779 + /// in the return type is what makes it impossible to apply a platform-wide credit to
780 + /// a connected-account charge without recording the reimbursement obligation — the
781 + /// item/cart divergence that produced Run 12 Payments SERIOUS + its cart sibling
782 + /// cannot recur, because both paths destructure the same `AppliedDiscount`.
783 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
784 + pub enum DiscountFunding {
785 + /// Seller-scoped code — the discount comes out of the seller's payout.
786 + CreatorFunded,
787 + /// Platform-wide credit — MNW owes the creator `credit_cents` (a platform→
788 + /// connected transfer) so the creator nets the full pre-discount price.
789 + PlatformFunded { credit_cents: i32 },
790 + }
791 +
792 + impl DiscountFunding {
793 + /// Cents MNW must transfer to the creator to make them whole (`0` when the
794 + /// discount is seller-funded).
795 + pub fn platform_credit_cents(self) -> i32 {
796 + match self {
797 + DiscountFunding::CreatorFunded => 0,
798 + DiscountFunding::PlatformFunded { credit_cents } => credit_cents,
799 + }
800 + }
801 + }
802 +
803 + /// A validated promo applied to one item: the post-discount price and who funds it.
804 + #[derive(Debug, Clone, Copy)]
805 + pub struct AppliedDiscount {
806 + /// The item's price after the code (`0` for free-access, discounted otherwise).
807 + pub price_cents: i32,
808 + /// Whether MNW or the seller absorbs the discount.
809 + pub funding: DiscountFunding,
810 + }
811 +
773 812 /// Result of applying a validated promo to one item.
774 813 pub enum PromoApplication {
775 - /// The item's price after the code (`0` for free-access, discounted otherwise).
776 - Apply(i32),
814 + /// The code applies; carries the post-discount price and its funding source.
815 + Apply(AppliedDiscount),
777 816 /// The code doesn't apply to this item — cart skips it, single-item rejects.
778 817 Ineligible(PromoIneligible),
779 818 }
@@ -805,8 +844,21 @@
805 844 }
806 845 }
807 846
847 + // Funding: a seller code is creator-funded; a platform-wide credit obliges MNW
848 + // to reimburse the creator the discounted amount (base - post-discount price).
849 + let funded = |price_cents: i32| -> AppliedDiscount {
850 + let funding = if validated.is_platform_wide {
851 + DiscountFunding::PlatformFunded {
852 + credit_cents: (base_price_cents - price_cents).max(0),
853 + }
854 + } else {
855 + DiscountFunding::CreatorFunded
856 + };
857 + AppliedDiscount { price_cents, funding }
858 + };
859 +
808 860 match code.code_purpose {
809 - CodePurpose::FreeAccess => Ok(PromoApplication::Apply(0)),
861 + CodePurpose::FreeAccess => Ok(PromoApplication::Apply(funded(0))),
810 862 CodePurpose::Discount => {
811 863 if !validated.is_platform_wide && base_price_cents < code.min_price_cents {
812 864 return Ok(PromoApplication::Ineligible(PromoIneligible::BelowMinPrice));
@@ -814,10 +866,11 @@
814 866 // KNOWN value-burn (R6-Pay-N2): a platform-wide fixed credit (e.g. the $5
815 867 // Fan+ renewal credit) bypasses `min_price_cents` and `apply_discount`
816 868 // clamps it to the item price, so applying a $5 credit to a $1 item consumes
817 - // the full credit ($4 lost). No platform money loss; fan-value only. The fix
818 - // is partial-balance redemption across all platform credits, tracked as a
819 - // launchplan feature ("Credit balances (partial redemption)") rather than a
820 - // promo-code patch here.
869 + // the full credit ($4 lost). No creator money loss — MNW funds the credit and
870 + // the creator is reimbursed the discounted amount (see `DiscountFunding`). The
871 + // fix for the burned fan-value is partial-balance redemption across all
872 + // platform credits, tracked as a launchplan feature ("Credit balances
873 + // (partial redemption)") rather than a promo-code patch here.
821 874 let (dt, dv) = match (code.discount_type, code.discount_value) {
822 875 (Some(dt), Some(dv)) => (dt, dv),
823 876 _ => {
@@ -826,10 +879,10 @@
826 879 ));
827 880 }
828 881 };
829 - Ok(PromoApplication::Apply(apply_discount(base_price_cents, dt, dv)))
882 + Ok(PromoApplication::Apply(funded(apply_discount(base_price_cents, dt, dv))))
830 883 }
831 884 // Rejected up front in `lookup_and_validate_promo`.
832 - CodePurpose::FreeTrial => Ok(PromoApplication::Apply(base_price_cents)),
885 + CodePurpose::FreeTrial => Ok(PromoApplication::Apply(funded(base_price_cents))),
833 886 }
834 887 }
835 888
@@ -1103,10 +1156,12 @@
1103 1156 let promo = unscoped_discount_promo(Some(1));
1104 1157 for base in [1000, 2000, 4999] {
1105 1158 let result = apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), base).unwrap();
1106 - let PromoApplication::Apply(price) = result else {
1159 + let PromoApplication::Apply(applied) = result else {
1107 1160 panic!("expected Apply for an eligible cart line at base {base}");
1108 1161 };
1109 - assert_eq!(price, base - base / 10);
1162 + assert_eq!(applied.price_cents, base - base / 10);
1163 + // A seller-scoped code is creator-funded — no platform reimbursement.
1164 + assert_eq!(applied.funding, DiscountFunding::CreatorFunded);
1110 1165 }
1111 1166 // apply_promo_to_item never touches use_count; reservation is the
1112 1167 // handler's once-per-checkout concern.
@@ -26,6 +26,11 @@
26 26 pub promo_code_id: Option<PromoCodeId>,
27 27 /// Guest buyer's email (set for guest checkouts, None for logged-in).
28 28 pub guest_email: Option<&'a str>,
29 + /// Cents MNW owes the seller as reimbursement for a platform-funded credit
30 + /// (the Fan+ renewal credit) applied to this sale; `0` for ordinary sales. The
31 + /// scheduler settles it via a platform -> connected transfer once the
32 + /// transaction completes (see `db::platform_credits`).
33 + pub platform_credit_cents: i64,
29 34 }
30 35
31 36 /// Common parameters for claiming a free item (direct, discount code, or download code).
@@ -38,6 +43,11 @@
38 43 pub share_contact: bool,
39 44 /// If this claim was granted via a bundle purchase, the parent transaction ID.
40 45 pub parent_transaction_id: Option<TransactionId>,
46 + /// Cents MNW owes the seller when a platform-wide (Fan+) credit made this item
47 + /// free — the seller is reimbursed the item's price via a platform transfer so
48 + /// they are still paid. `0` for ordinary free claims (genuinely-free items,
49 + /// seller-issued free-access codes, bundle grants).
50 + pub platform_credit_cents: i64,
41 51 }
42 52
43 53 /// Record a new pending transaction for a Stripe checkout session.
@@ -49,8 +59,8 @@
49 59 let tx = sqlx::query_as!(
50 60 DbTransaction,
51 61 r#"
52 - INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, item_title, seller_username, share_contact, project_id, promo_code_id, guest_email)
53 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
62 + INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, item_title, seller_username, share_contact, project_id, promo_code_id, guest_email, platform_credit_cents)
63 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
54 64 RETURNING
55 65 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
56 66 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
@@ -73,6 +83,7 @@
73 83 params.project_id as Option<ProjectId>,
74 84 params.promo_code_id as Option<PromoCodeId>,
75 85 params.guest_email,
86 + params.platform_credit_cents,
76 87 )
77 88 .fetch_one(executor)
78 89 .await?;
@@ -491,8 +502,8 @@
491 502 let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id);
492 503 let result = sqlx::query!(
493 504 r#"
494 - INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact, parent_transaction_id)
495 - VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8)
505 + INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact, parent_transaction_id, platform_credit_cents)
506 + VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8, $9)
496 507 ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING
497 508 "#,
498 509 params.buyer_id as UserId,
@@ -503,6 +514,7 @@
503 514 params.seller_username,
504 515 params.share_contact,
505 516 params.parent_transaction_id as Option<TransactionId>,
517 + params.platform_credit_cents,
506 518 )
507 519 .execute(executor)
508 520 .await?;
@@ -586,12 +598,15 @@
586 598 ) -> Result<(bool, bool)> {
587 599 let mut tx = pool.begin().await?;
588 600
589 - // Step 1: Attempt to claim the item first
601 + // Step 1: Attempt to claim the item first. When a platform-wide credit (Fan+)
602 + // made the item free, `platform_credit_cents` carries the item's full price so
603 + // the scheduler reimburses the creator via transfer — the fan pays nothing but
604 + // the creator is still paid (MNW funds it).
590 605 let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id);
591 606 let result = sqlx::query!(
592 607 r#"
593 - INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact, promo_code_id)
594 - VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8)
608 + INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact, promo_code_id, platform_credit_cents)
609 + VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8, $9)
595 610 ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING
596 611 "#,
597 612 params.buyer_id as UserId,
@@ -602,6 +617,7 @@
602 617 params.seller_username,
603 618 params.share_contact,
604 619 promo_code_id as PromoCodeId,
620 + params.platform_credit_cents,
605 621 )
606 622 .execute(&mut *tx)
607 623 .await?;
@@ -1,7 +1,7 @@
1 1 //! Connected account operations: onboarding, balance, product/price creation,
2 2 //! subscription lifecycle, refunds, and billing portal.
3 3
4 - use stripe::StripeRequest;
4 + use stripe::{IdempotencyKey, RequestStrategy, StripeRequest};
5 5 use stripe_billing::subscription::{
6 6 CancelSubscription, ResumeSubscription,
7 7 UpdateSubscription,
@@ -10,6 +10,7 @@
10 10 use stripe_billing::billing_portal_session::CreateBillingPortalSession;
11 11 use stripe_connect::account::{CreateAccount, CreateAccountType, RetrieveAccount};
12 12 use stripe_connect::account_link::{CreateAccountLink, CreateAccountLinkType};
13 + use stripe_connect::transfer::CreateTransfer;
13 14 use stripe_core::balance::RetrieveForMyAccountBalance;
14 15 use stripe_core::refund::CreateRefund;
15 16 use stripe_product::product::CreateProduct;
@@ -328,6 +329,44 @@
328 329 })?;
329 330 Ok(())
330 331 }
332 +
333 + /// Reimburse a creator for a platform-funded credit (the Fan+ renewal credit)
334 + /// applied to their sale, so they still net the full pre-discount price and the
335 + /// "0% platform fee, creators keep everything" promise holds. This is a platform
336 + /// -> connected transfer funded from MNW's own balance (the platform absorbs the
337 + /// credit, not the creator).
338 + ///
339 + /// The idempotency key is deterministic (`platform-credit-{transaction_id}`), so a
340 + /// retry after a crash or transient failure returns the same transfer rather than
341 + /// paying the creator twice.
342 + #[tracing::instrument(skip_all, name = "payments::create_platform_credit_transfer")]
343 + pub async fn create_platform_credit_transfer(
344 + &self,
345 + connected_account_id: &str,
346 + amount_cents: i64,
347 + transaction_id: crate::db::TransactionId,
348 + ) -> Result<()> {
349 + let acct = Self::parse_account_id(connected_account_id)?;
350 + let key = IdempotencyKey::new(format!("platform-credit-{transaction_id}"))
351 + .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
352 + let metadata = std::collections::HashMap::from([
353 + ("mnw_transaction_id".to_string(), transaction_id.to_string()),
354 + ("reason".to_string(), "platform_funded_credit".to_string()),
355 + ]);
356 + CreateTransfer::new(Currency::USD, acct.to_string())
357 + .amount(amount_cents)
358 + .description("Fan+ credit reimbursement")
359 + .metadata(metadata)
360 + .customize()
361 + .request_strategy(RequestStrategy::Idempotent(key))
362 + .send(&self.client)
363 + .await
364 + .map_err(|e| {
365 + tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to create platform credit transfer");
366 + AppError::Internal(anyhow::anyhow!("Failed to create transfer"))
367 + })?;
368 + Ok(())
369 + }
331 370 }
332 371
333 372 #[cfg(test)]
@@ -118,6 +118,16 @@
118 118 transaction_id: crate::db::TransactionId,
119 119 ) -> crate::error::Result<()>;
120 120
121 + // Platform-funded credit reimbursement — a platform -> connected transfer that
122 + // makes the creator whole for a Fan+ credit applied to their sale (MNW funds it).
123 + // Deterministic idempotency key keeps replays/retries from double-paying.
124 + async fn create_platform_credit_transfer(
125 + &self,
126 + connected_account_id: &str,
127 + amount_cents: i64,
128 + transaction_id: crate::db::TransactionId,
129 + ) -> crate::error::Result<()>;
130 +
121 131 // Webhooks
122 132 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent>;
123 133 fn verify_webhook_v2(&self, payload: &str, signature: &str) -> crate::error::Result<serde_json::Value>;
@@ -255,6 +265,21 @@
255 265 .await
256 266 }
257 267
268 + async fn create_platform_credit_transfer(
269 + &self,
270 + connected_account_id: &str,
271 + amount_cents: i64,
272 + transaction_id: crate::db::TransactionId,
273 + ) -> crate::error::Result<()> {
274 + StripeClient::create_platform_credit_transfer(
275 + self,
276 + connected_account_id,
277 + amount_cents,
278 + transaction_id,
279 + )
280 + .await
281 + }
282 +
258 283 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent> {
259 284 StripeClient::verify_webhook(self, payload, signature)
260 285 }
@@ -234,6 +234,11 @@
234 234 // Escalate stale pending refunds (unmatched for >24 hours)
235 235 webhooks::escalate_stale_refunds(&state).await;
236 236
237 + // Settle owed platform-funded credits (Fan+ reimbursements) via transfer,
238 + // then escalate any that were claimed but never completed (>24h).
239 + webhooks::settle_platform_credits(&state).await;
240 + webhooks::escalate_stale_platform_credits(&state).await;
241 +
237 242 // Clean up stale pending transactions (>24h) and release promo code reservations
238 243 cleanup::cleanup_stale_pending_transactions(&state).await;
239 244