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
Commit: 7a7887861ed93a2467579fd85c1b0d8fc66fb765
Parent: 75fd955
28 files changed, +886 insertions, -240 deletions
@@ -1,159 +0,0 @@
1 - {
2 - "db_name": "PostgreSQL",
3 - "query": "\n 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)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)\n RETURNING\n id AS \"id: TransactionId\", buyer_id AS \"buyer_id: UserId\", seller_id AS \"seller_id: UserId\",\n item_id AS \"item_id: ItemId\", amount_cents AS \"amount_cents: Cents\", platform_fee_cents AS \"platform_fee_cents: Cents\",\n currency, status AS \"status: super::TransactionStatus\", stripe_payment_intent_id, stripe_checkout_session_id,\n created_at AS \"created_at: chrono::DateTime<chrono::Utc>\", completed_at AS \"completed_at: chrono::DateTime<chrono::Utc>\",\n item_title, seller_username, share_contact, project_id AS \"project_id: ProjectId\",\n parent_transaction_id AS \"parent_transaction_id: TransactionId\", promo_code_id AS \"promo_code_id: PromoCodeId\",\n guest_email, claim_token AS \"claim_token: ClaimToken\", claimed_by AS \"claimed_by: UserId\",\n download_token AS \"download_token: DownloadToken\"\n ",
4 - "describe": {
5 - "columns": [
6 - {
7 - "ordinal": 0,
8 - "name": "id: TransactionId",
9 - "type_info": "Uuid"
10 - },
11 - {
12 - "ordinal": 1,
13 - "name": "buyer_id: UserId",
14 - "type_info": "Uuid"
15 - },
16 - {
17 - "ordinal": 2,
18 - "name": "seller_id: UserId",
19 - "type_info": "Uuid"
20 - },
21 - {
22 - "ordinal": 3,
23 - "name": "item_id: ItemId",
24 - "type_info": "Uuid"
25 - },
26 - {
27 - "ordinal": 4,
28 - "name": "amount_cents: Cents",
29 - "type_info": "Int4"
30 - },
31 - {
32 - "ordinal": 5,
33 - "name": "platform_fee_cents: Cents",
34 - "type_info": "Int4"
35 - },
36 - {
37 - "ordinal": 6,
38 - "name": "currency",
39 - "type_info": "Varchar"
40 - },
41 - {
42 - "ordinal": 7,
43 - "name": "status: super::TransactionStatus",
44 - "type_info": "Varchar"
45 - },
46 - {
47 - "ordinal": 8,
48 - "name": "stripe_payment_intent_id",
49 - "type_info": "Varchar"
50 - },
51 - {
52 - "ordinal": 9,
53 - "name": "stripe_checkout_session_id",
54 - "type_info": "Varchar"
55 - },
56 - {
57 - "ordinal": 10,
58 - "name": "created_at: chrono::DateTime<chrono::Utc>",
59 - "type_info": "Timestamptz"
60 - },
61 - {
62 - "ordinal": 11,
63 - "name": "completed_at: chrono::DateTime<chrono::Utc>",
64 - "type_info": "Timestamptz"
65 - },
66 - {
67 - "ordinal": 12,
68 - "name": "item_title",
69 - "type_info": "Varchar"
70 - },
71 - {
72 - "ordinal": 13,
73 - "name": "seller_username",
74 - "type_info": "Varchar"
75 - },
76 - {
77 - "ordinal": 14,
78 - "name": "share_contact",
79 - "type_info": "Bool"
80 - },
81 - {
82 - "ordinal": 15,
83 - "name": "project_id: ProjectId",
84 - "type_info": "Uuid"
85 - },
86 - {
87 - "ordinal": 16,
88 - "name": "parent_transaction_id: TransactionId",
89 - "type_info": "Uuid"
90 - },
91 - {
92 - "ordinal": 17,
93 - "name": "promo_code_id: PromoCodeId",
94 - "type_info": "Uuid"
95 - },
96 - {
97 - "ordinal": 18,
98 - "name": "guest_email",
99 - "type_info": "Varchar"
100 - },
101 - {
102 - "ordinal": 19,
103 - "name": "claim_token: ClaimToken",
104 - "type_info": "Uuid"
105 - },
106 - {
107 - "ordinal": 20,
108 - "name": "claimed_by: UserId",
109 - "type_info": "Uuid"
110 - },
111 - {
112 - "ordinal": 21,
113 - "name": "download_token: DownloadToken",
114 - "type_info": "Uuid"
115 - }
116 - ],
117 - "parameters": {
118 - "Left": [
119 - "Uuid",
120 - "Uuid",
121 - "Uuid",
122 - "Int4",
123 - "Int4",
124 - "Varchar",
125 - "Varchar",
126 - "Varchar",
127 - "Bool",
128 - "Uuid",
129 - "Uuid",
130 - "Varchar"
131 - ]
132 - },
133 - "nullable": [
134 - false,
135 - true,
136 - true,
137 - true,
138 - false,
139 - false,
140 - false,
141 - false,
142 - true,
143 - true,
144 - false,
145 - true,
146 - true,
147 - true,
148 - false,
149 - true,
150 - true,
151 - true,
152 - true,
153 - true,
154 - true,
155 - true
156 - ]
157 - },
158 - "hash": "0e3c392cde613d2447dbbba61bccccf276fb01ea2a550f50afbf540cd5f6ea2c"
159 - }
@@ -0,0 +1,32 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "\n UPDATE transactions\n SET platform_credit_claimed_at = NOW()\n WHERE id = (\n SELECT id FROM transactions\n WHERE status = 'completed'\n AND platform_credit_cents > 0\n AND platform_credit_settled_at IS NULL\n AND platform_credit_claimed_at IS NULL\n ORDER BY completed_at\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n )\n RETURNING id AS \"transaction_id!: TransactionId\",\n seller_id AS \"seller_id!: UserId\",\n platform_credit_cents AS \"amount_cents!: Cents\"\n ",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "transaction_id!: TransactionId",
9 + "type_info": "Uuid"
10 + },
11 + {
12 + "ordinal": 1,
13 + "name": "seller_id!: UserId",
14 + "type_info": "Uuid"
15 + },
16 + {
17 + "ordinal": 2,
18 + "name": "amount_cents!: Cents",
19 + "type_info": "Int8"
20 + }
21 + ],
22 + "parameters": {
23 + "Left": []
24 + },
25 + "nullable": [
26 + false,
27 + true,
28 + false
29 + ]
30 + },
31 + "hash": "4a03016f8c84ebe12ea6ba0e603d02e60b133a248fd856b7b1b7bb574c0fd110"
32 + }
@@ -1,21 +0,0 @@
1 - {
2 - "db_name": "PostgreSQL",
3 - "query": "\n 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)\n VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8)\n ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING\n ",
4 - "describe": {
5 - "columns": [],
6 - "parameters": {
7 - "Left": [
8 - "Uuid",
9 - "Uuid",
10 - "Uuid",
11 - "Varchar",
12 - "Varchar",
13 - "Varchar",
14 - "Bool",
15 - "Uuid"
16 - ]
17 - },
18 - "nullable": []
19 - },
20 - "hash": "9b9425daacc5761981d36a28f2a73ab92069152dabb8254d86eb20a68dbbb273"
21 - }
@@ -0,0 +1,22 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "\n 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)\n VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8, $9)\n ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING\n ",
4 + "describe": {
5 + "columns": [],
6 + "parameters": {
7 + "Left": [
8 + "Uuid",
9 + "Uuid",
10 + "Uuid",
11 + "Varchar",
12 + "Varchar",
13 + "Varchar",
14 + "Bool",
15 + "Uuid",
16 + "Int8"
17 + ]
18 + },
19 + "nullable": []
20 + },
21 + "hash": "a472ba70a270f88f2c4982782b3f3839623b180b5d9adf18de81640a0fa822c8"
22 + }
@@ -0,0 +1,14 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "UPDATE transactions SET platform_credit_settled_at = NOW() WHERE id = $1",
4 + "describe": {
5 + "columns": [],
6 + "parameters": {
7 + "Left": [
8 + "Uuid"
9 + ]
10 + },
11 + "nullable": []
12 + },
13 + "hash": "ac0939dffb67bf2225724cc35d075a04b725bf527e8011788a2abaf68105ee96"
14 + }
@@ -0,0 +1,14 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "UPDATE transactions SET platform_credit_escalated_at = NOW() WHERE id = $1",
4 + "describe": {
5 + "columns": [],
6 + "parameters": {
7 + "Left": [
8 + "Uuid"
9 + ]
10 + },
11 + "nullable": []
12 + },
13 + "hash": "b5b1f430427585cb8c47aeaef2b0737203a26f22eabe00b854eb3ec73fa1d2f6"
14 + }
@@ -1,21 +0,0 @@
1 - {
2 - "db_name": "PostgreSQL",
3 - "query": "\n 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)\n VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8)\n ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING\n ",
4 - "describe": {
5 - "columns": [],
6 - "parameters": {
7 - "Left": [
8 - "Uuid",
9 - "Uuid",
10 - "Uuid",
11 - "Varchar",
12 - "Varchar",
13 - "Varchar",
14 - "Bool",
15 - "Uuid"
16 - ]
17 - },
18 - "nullable": []
19 - },
20 - "hash": "b91edf292b64865f5818a2c1781c47cf7a18e4586d6d00499e7827363a991432"
21 - }
@@ -0,0 +1,14 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "UPDATE transactions SET platform_credit_claimed_at = NULL WHERE id = $1",
4 + "describe": {
5 + "columns": [],
6 + "parameters": {
7 + "Left": [
8 + "Uuid"
9 + ]
10 + },
11 + "nullable": []
12 + },
13 + "hash": "c34b90d2d787400935e825f35c867a9ba8eaf8b1308385a631842a4f2de8ed6b"
14 + }
@@ -0,0 +1,22 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "\n 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)\n VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8, $9)\n ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING\n ",
4 + "describe": {
5 + "columns": [],
6 + "parameters": {
7 + "Left": [
8 + "Uuid",
9 + "Uuid",
10 + "Uuid",
11 + "Varchar",
12 + "Varchar",
13 + "Varchar",
14 + "Bool",
15 + "Uuid",
16 + "Int8"
17 + ]
18 + },
19 + "nullable": []
20 + },
21 + "hash": "e78d2ee33b1ead47c1faeee416582a91bbd83e0af359bf20b95b8bea479410ba"
22 + }
@@ -0,0 +1,160 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "\n 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)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)\n RETURNING\n id AS \"id: TransactionId\", buyer_id AS \"buyer_id: UserId\", seller_id AS \"seller_id: UserId\",\n item_id AS \"item_id: ItemId\", amount_cents AS \"amount_cents: Cents\", platform_fee_cents AS \"platform_fee_cents: Cents\",\n currency, status AS \"status: super::TransactionStatus\", stripe_payment_intent_id, stripe_checkout_session_id,\n created_at AS \"created_at: chrono::DateTime<chrono::Utc>\", completed_at AS \"completed_at: chrono::DateTime<chrono::Utc>\",\n item_title, seller_username, share_contact, project_id AS \"project_id: ProjectId\",\n parent_transaction_id AS \"parent_transaction_id: TransactionId\", promo_code_id AS \"promo_code_id: PromoCodeId\",\n guest_email, claim_token AS \"claim_token: ClaimToken\", claimed_by AS \"claimed_by: UserId\",\n download_token AS \"download_token: DownloadToken\"\n ",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "id: TransactionId",
9 + "type_info": "Uuid"
10 + },
11 + {
12 + "ordinal": 1,
13 + "name": "buyer_id: UserId",
14 + "type_info": "Uuid"
15 + },
16 + {
17 + "ordinal": 2,
18 + "name": "seller_id: UserId",
19 + "type_info": "Uuid"
20 + },
21 + {
22 + "ordinal": 3,
23 + "name": "item_id: ItemId",
24 + "type_info": "Uuid"
25 + },
26 + {
27 + "ordinal": 4,
28 + "name": "amount_cents: Cents",
29 + "type_info": "Int4"
30 + },
31 + {
32 + "ordinal": 5,
33 + "name": "platform_fee_cents: Cents",
34 + "type_info": "Int4"
35 + },
36 + {
37 + "ordinal": 6,
38 + "name": "currency",
39 + "type_info": "Varchar"
40 + },
41 + {
42 + "ordinal": 7,
43 + "name": "status: super::TransactionStatus",
44 + "type_info": "Varchar"
45 + },
46 + {
47 + "ordinal": 8,
48 + "name": "stripe_payment_intent_id",
49 + "type_info": "Varchar"
50 + },
51 + {
52 + "ordinal": 9,
53 + "name": "stripe_checkout_session_id",
54 + "type_info": "Varchar"
55 + },
56 + {
57 + "ordinal": 10,
58 + "name": "created_at: chrono::DateTime<chrono::Utc>",
59 + "type_info": "Timestamptz"
60 + },
61 + {
62 + "ordinal": 11,
63 + "name": "completed_at: chrono::DateTime<chrono::Utc>",
64 + "type_info": "Timestamptz"
65 + },
66 + {
67 + "ordinal": 12,
68 + "name": "item_title",
69 + "type_info": "Varchar"
70 + },
71 + {
72 + "ordinal": 13,
73 + "name": "seller_username",
74 + "type_info": "Varchar"
75 + },
76 + {
77 + "ordinal": 14,
78 + "name": "share_contact",
79 + "type_info": "Bool"
80 + },
81 + {
82 + "ordinal": 15,
83 + "name": "project_id: ProjectId",
84 + "type_info": "Uuid"
85 + },
86 + {
87 + "ordinal": 16,
88 + "name": "parent_transaction_id: TransactionId",
89 + "type_info": "Uuid"
90 + },
91 + {
92 + "ordinal": 17,
93 + "name": "promo_code_id: PromoCodeId",
94 + "type_info": "Uuid"
95 + },
96 + {
97 + "ordinal": 18,
98 + "name": "guest_email",
99 + "type_info": "Varchar"
100 + },
101 + {
102 + "ordinal": 19,
103 + "name": "claim_token: ClaimToken",
104 + "type_info": "Uuid"
105 + },
106 + {
107 + "ordinal": 20,
108 + "name": "claimed_by: UserId",
109 + "type_info": "Uuid"
110 + },
111 + {
112 + "ordinal": 21,
113 + "name": "download_token: DownloadToken",
114 + "type_info": "Uuid"
115 + }
116 + ],
117 + "parameters": {
118 + "Left": [
119 + "Uuid",
120 + "Uuid",
121 + "Uuid",
122 + "Int4",
123 + "Int4",
124 + "Varchar",
125 + "Varchar",
126 + "Varchar",
127 + "Bool",
128 + "Uuid",
129 + "Uuid",
130 + "Varchar",
131 + "Int8"
132 + ]
133 + },
134 + "nullable": [
135 + false,
136 + true,
137 + true,
138 + true,
139 + false,
140 + false,
141 + false,
142 + false,
143 + true,
144 + true,
145 + false,
146 + true,
147 + true,
148 + true,
149 + false,
150 + true,
151 + true,
152 + true,
153 + true,
154 + true,
155 + true,
156 + true
157 + ]
158 + },
159 + "hash": "f8c96f1a4805725e1035dceb0143bd6f608eaebce32e39f0139eb7a63ce7b6d2"
160 + }
@@ -142,7 +142,7 @@ async-stripe = { version = "1.0.0-rc.5", features = ["default-tls"] }
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"] }
@@ -0,0 +1,30 @@
1 + -- Platform-funded credits (Fan+ renewal credit reimbursement).
2 + --
3 + -- A platform-wide credit (the $5 Fan+ renewal credit) reduces the amount a buyer
4 + -- pays on a creator's Direct Charge. To honour the "0% platform fee, creators keep
5 + -- everything" promise, MNW reimburses the creator the discounted amount via a
6 + -- platform -> connected transfer. `platform_credit_cents` records how much MNW owes
7 + -- the creator on a given transaction; the scheduler sweep settles it and stamps
8 + -- `platform_credit_settled_at`.
9 + --
10 + -- Lifecycle (mirrors pending_refunds): a completed transaction with
11 + -- platform_credit_cents > 0 and settled_at IS NULL is claimed (claimed_at set),
12 + -- the transfer is created with a deterministic idempotency key, then settled_at is
13 + -- stamped. A graceful transfer failure clears claimed_at for retry; a process death
14 + -- between claim and settle leaves claimed_at set / settled_at NULL and is escalated
15 + -- by the stale sweep (escalated_at) rather than blindly retried.
16 +
17 + ALTER TABLE transactions
18 + ADD COLUMN IF NOT EXISTS platform_credit_cents BIGINT NOT NULL DEFAULT 0,
19 + ADD COLUMN IF NOT EXISTS platform_credit_claimed_at TIMESTAMPTZ,
20 + ADD COLUMN IF NOT EXISTS platform_credit_settled_at TIMESTAMPTZ,
21 + ADD COLUMN IF NOT EXISTS platform_credit_escalated_at TIMESTAMPTZ;
22 +
23 + ALTER TABLE transactions
24 + ADD CONSTRAINT platform_credit_cents_nonneg CHECK (platform_credit_cents >= 0);
25 +
26 + -- Partial index for the settlement sweep: only rows that actually owe a credit and
27 + -- have not yet settled.
28 + CREATE INDEX IF NOT EXISTS idx_transactions_platform_credit_owed
29 + ON transactions (completed_at)
30 + WHERE platform_credit_cents > 0 AND platform_credit_settled_at IS NULL;
@@ -66,6 +66,7 @@ pub mod tips;
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;
@@ -0,0 +1,136 @@
1 + //! Settlement of platform-funded credits (Fan+ renewal credit reimbursement).
2 + //!
3 + //! A platform-wide credit reduces what a buyer pays on a creator's Direct Charge;
4 + //! MNW owes the creator the discounted amount so they still net the full price
5 + //! ("0% platform fee, creators keep everything"). The obligation lives on the
6 + //! `transactions` row (`platform_credit_cents`); the scheduler sweep claims it,
7 + //! creates a platform -> connected transfer with a deterministic idempotency key,
8 + //! then stamps `platform_credit_settled_at`.
9 + //!
10 + //! Lifecycle mirrors [`super::pending_refunds`]: claim (set `claimed_at`) -> settle
11 + //! (set `settled_at`). A graceful transfer failure clears `claimed_at` for retry; a
12 + //! process death between claim and settle leaves the row claimed-but-unsettled and
13 + //! is surfaced by [`get_stale_credits`] for human reconciliation rather than blindly
14 + //! retried.
15 +
16 + use sqlx::PgPool;
17 +
18 + use super::validated_types::Cents;
19 + use super::{TransactionId, UserId};
20 + use crate::error::Result;
21 +
22 + /// A completed transaction owing a platform-funded credit to its seller.
23 + #[derive(Debug, sqlx::FromRow)]
24 + pub struct PlatformCredit {
25 + pub transaction_id: TransactionId,
26 + pub seller_id: UserId,
27 + pub amount_cents: Cents,
28 + }
29 +
30 + /// Atomically claim the oldest unsettled platform credit (set `claimed_at`), so a
31 + /// single scheduler worker settles it once. Returns `None` when nothing is owed.
32 + /// Settlement is recorded separately by [`mark_settled`] only after the transfer
33 + /// succeeds — a claim that never settles (process killed mid-transfer) is escalated
34 + /// by [`get_stale_credits`], not auto-retried.
35 + pub async fn claim_unsettled_credit(pool: &PgPool) -> Result<Option<PlatformCredit>> {
36 + let row = sqlx::query_as!(
37 + PlatformCredit,
38 + r#"
39 + UPDATE transactions
40 + SET platform_credit_claimed_at = NOW()
41 + WHERE id = (
42 + SELECT id FROM transactions
43 + WHERE status = 'completed'
44 + AND platform_credit_cents > 0
45 + AND platform_credit_settled_at IS NULL
46 + AND platform_credit_claimed_at IS NULL
47 + ORDER BY completed_at
48 + LIMIT 1
49 + FOR UPDATE SKIP LOCKED
50 + )
51 + RETURNING id AS "transaction_id!: TransactionId",
52 + seller_id AS "seller_id!: UserId",
53 + platform_credit_cents AS "amount_cents!: Cents"
54 + "#,
55 + )
56 + .fetch_optional(pool)
57 + .await?;
58 +
59 + Ok(row)
60 + }
61 +
62 + /// Record that a claimed credit's transfer succeeded. Idempotent.
63 + pub async fn mark_settled(pool: &PgPool, transaction_id: TransactionId) -> Result<()> {
64 + sqlx::query!(
65 + "UPDATE transactions SET platform_credit_settled_at = NOW() WHERE id = $1",
66 + transaction_id as TransactionId,
67 + )
68 + .execute(pool)
69 + .await?;
70 + Ok(())
71 + }
72 +
73 + /// Release a claimed-but-unsettled credit back to the queue after a *graceful*
74 + /// transfer failure (transient error where nothing external happened). A
75 + /// non-graceful failure (process killed) cannot reach here; that row stays
76 + /// claimed-but-unsettled and is escalated by the stale sweep. Idempotent.
77 + pub async fn unclaim_credit(pool: &PgPool, transaction_id: TransactionId) -> Result<()> {
78 + sqlx::query!(
79 + "UPDATE transactions SET platform_credit_claimed_at = NULL WHERE id = $1",
80 + transaction_id as TransactionId,
81 + )
82 + .execute(pool)
83 + .await?;
84 + Ok(())
85 + }
86 +
87 + /// Per-tick cap on the stale-credit escalation sweep (mirrors `STALE_REFUND_BATCH`).
88 + pub const STALE_CREDIT_BATCH: i64 = 100;
89 +
90 + /// A stale platform credit needing human reconciliation.
91 + #[derive(Debug, sqlx::FromRow)]
92 + pub struct StaleCredit {
93 + pub transaction_id: TransactionId,
94 + pub seller_id: UserId,
95 + pub amount_cents: Cents,
96 + pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
97 + }
98 +
99 + /// Up to [`STALE_CREDIT_BATCH`] credits claimed more than `age` ago that never
100 + /// settled (the crash-window between claim and transfer), oldest first. The transfer
101 + /// uses a deterministic idempotency key so a human can safely re-trigger it.
102 + pub async fn get_stale_credits(pool: &PgPool, age: chrono::Duration) -> Result<Vec<StaleCredit>> {
103 + let cutoff = chrono::Utc::now() - age;
104 + // runtime-checked bind of a chrono cutoff, per db::pending_refunds::get_stale_refunds.
105 + let rows = sqlx::query_as::<_, StaleCredit>(
106 + r#"
107 + SELECT id AS transaction_id, seller_id, platform_credit_cents AS amount_cents, completed_at
108 + FROM transactions
109 + WHERE status = 'completed'
110 + AND platform_credit_cents > 0
111 + AND platform_credit_settled_at IS NULL
112 + AND platform_credit_claimed_at IS NOT NULL
113 + AND platform_credit_claimed_at < $1
114 + AND platform_credit_escalated_at IS NULL
115 + ORDER BY completed_at
116 + LIMIT $2
117 + "#,
118 + )
119 + .bind(cutoff)
120 + .bind(STALE_CREDIT_BATCH)
121 + .fetch_all(pool)
122 + .await?;
123 +
124 + Ok(rows)
125 + }
126 +
127 + /// Mark a stale credit escalated (alert sent, won't be re-alerted).
128 + pub async fn mark_escalated(pool: &PgPool, transaction_id: TransactionId) -> Result<()> {
129 + sqlx::query!(
130 + "UPDATE transactions SET platform_credit_escalated_at = NOW() WHERE id = $1",
131 + transaction_id as TransactionId,
132 + )
133 + .execute(pool)
134 + .await?;
135 + Ok(())
136 + }
@@ -770,10 +770,49 @@ pub enum PromoIneligible {
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 @@ pub fn apply_promo_to_item(
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 @@ pub fn apply_promo_to_item(
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 @@ pub fn apply_promo_to_item(
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 @@ mod tests {
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 @@ pub struct CreateTransactionParams<'a> {
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 @@ pub struct ClaimParams<'a> {
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 @@ pub async fn create_transaction<'e>(
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 @@ pub async fn create_transaction<'e>(
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 @@ pub async fn claim_free_item<'e>(
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 @@ pub async fn claim_free_item<'e>(
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 @@ pub async fn claim_free_with_promo_code(
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 @@ pub async fn claim_free_with_promo_code(
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 @@ use stripe_billing::subscription::{
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 @@ impl StripeClient {
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 @@ pub trait PaymentProvider: Send + Sync {
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 @@ impl PaymentProvider for StripeClient {
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 }
@@ -97,7 +97,17 @@ pub(super) async fn create_guest_checkout(
97 97 {
98 98 use db::promo_codes::{PromoApplication, PromoIneligible};
99 99 match db::promo_codes::apply_promo_to_item(&validated, item_id, item.project_id, item.price_cents)? {
100 - PromoApplication::Apply(price) => final_price_cents = price,
100 + PromoApplication::Apply(applied) => {
101 + // Guests have no account and thus no platform-wide Fan+ credit, so a
102 + // guest discount is always creator-funded. Guard the invariant rather
103 + // than silently dropping a reimbursement obligation.
104 + if applied.funding.platform_credit_cents() > 0 {
105 + return Err(AppError::BadRequest(
106 + "This code cannot be used for guest checkout".to_string(),
107 + ));
108 + }
109 + final_price_cents = applied.price_cents;
110 + }
101 111 PromoApplication::Ineligible(PromoIneligible::ScopeMismatch) => {
102 112 return Err(AppError::BadRequest("This promo code is not valid for this item".to_string()));
103 113 }
@@ -199,6 +209,7 @@ pub(super) async fn create_guest_checkout(
199 209 project_id: Some(item.project_id),
200 210 promo_code_id,
201 211 guest_email: None, // Set by webhook when Stripe provides it
212 + platform_credit_cents: 0, // guests have no platform-wide credit (guarded above)
202 213 },
203 214 ).await {
204 215 Ok(_) => {}
@@ -626,6 +626,8 @@ pub(super) async fn claim_promo_code(
626 626 seller_username: &seller.username,
627 627 share_contact: false,
628 628 parent_transaction_id: None,
629 + // Item-scoped seller free-access code (guarded above): creator-funded.
630 + platform_credit_cents: 0,
629 631 },
630 632 lk_params.as_ref(),
631 633 )