Skip to main content

max / makenotwork

server: make SyncKit Stripe creates idempotent to close activate race Two concurrent `activate` (or `setup`) requests each pass the `billing_status = 'draft'` read before either writes, so each was calling Stripe to create a live customer/product/subscription. The DB `activate_billing` UPDATE is already `WHERE billing_status = 'draft'`-guarded (the loser gets a Conflict), but the loser's Stripe subscription was orphaned — a live billable object with no local row. Key the SyncKit customer/product/subscription creates on the app id via `RequestStrategy::Idempotent`, mirroring the refund/transfer path in connect.rs. Concurrent creates now return the same Stripe object, so the race can no longer leak a second billable subscription. Checkout-session creates are intentionally left un-keyed: they don't charge until paid, and a stable key would block a legitimate re-checkout within Stripe's 24h idempotency-key window. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 16:41 UTC
Commit: bd33ce7c725782c37d5a126646e13b008678ca7b
Parent: 0337663
4 files changed, +31 insertions, -7 deletions
@@ -151,7 +151,7 @@ pub trait PaymentProvider: Send + Sync {
151 151 // SyncKit v2 developer billing — one customer + subscription per app,
152 152 // separate from creator-tier and Fan+ subscriptions. See
153 153 // `synckit_billing.rs` for the rationale on per-app customers.
154 - async fn create_synckit_customer(&self, developer_user_id: crate::db::UserId, email: &str, app_name: &str) -> crate::error::Result<String>;
154 + async fn create_synckit_customer(&self, developer_user_id: crate::db::UserId, app_id: crate::db::SyncAppId, email: &str, app_name: &str) -> crate::error::Result<String>;
155 155 async fn create_synckit_subscription(&self, customer_id: &str, app_id: crate::db::SyncAppId, app_name: &str, price_cents: i64) -> crate::error::Result<SynckitSubResult>;
156 156 async fn update_synckit_subscription_price(&self, subscription_id: &str, new_price_cents: i64, app_name: &str) -> crate::error::Result<()>;
157 157 /// Re-price an end-user SyncKit app subscription. Used by the cap-change
@@ -304,8 +304,8 @@ impl PaymentProvider for StripeClient {
304 304 StripeClient::verify_webhook_v2(self, payload, signature)
305 305 }
306 306
307 - async fn create_synckit_customer(&self, developer_user_id: crate::db::UserId, email: &str, app_name: &str) -> crate::error::Result<String> {
308 - StripeClient::create_synckit_customer(self, developer_user_id, email, app_name).await
307 + async fn create_synckit_customer(&self, developer_user_id: crate::db::UserId, app_id: crate::db::SyncAppId, email: &str, app_name: &str) -> crate::error::Result<String> {
308 + StripeClient::create_synckit_customer(self, developer_user_id, app_id, email, app_name).await
309 309 }
310 310
311 311 async fn create_synckit_subscription(&self, customer_id: &str, app_id: crate::db::SyncAppId, app_name: &str, price_cents: i64) -> crate::error::Result<SynckitSubResult> {
@@ -21,6 +21,7 @@ use stripe_billing::subscription::{
21 21 UpdateSubscriptionItemsPriceDataRecurring, UpdateSubscriptionItemsPriceDataRecurringInterval,
22 22 UpdateSubscriptionProrationBehavior,
23 23 };
24 + use stripe::{IdempotencyKey, RequestStrategy, StripeRequest};
24 25 use stripe_core::customer::CreateCustomer;
25 26 use stripe_product::product::CreateProduct;
26 27 use stripe_types::Currency;
@@ -29,6 +30,19 @@ use super::StripeClient;
29 30 use crate::db::{SyncAppId, UserId};
30 31 use crate::error::{AppError, Result};
31 32
33 + /// Build a deterministic Stripe idempotency key. Keying the SyncKit
34 + /// customer / product / subscription creates on the app id means two racing
35 + /// `activate` (or `setup`) requests — which each pass the `billing_status =
36 + /// 'draft'` read before either writes — return the *same* live Stripe object
37 + /// instead of orphaning a duplicate subscription that would bill with no local
38 + /// row (audit Run 17 Concurrency). The DB `activate_billing` UPDATE is already
39 + /// `WHERE billing_status = 'draft'`-guarded, so the loser gets a Conflict; this
40 + /// keeps the Stripe side from leaking a second billable object in that window.
41 + fn synckit_idempotency_key(prefix: &str, app_id: SyncAppId) -> Result<IdempotencyKey> {
42 + IdempotencyKey::new(format!("{prefix}-{app_id}"))
43 + .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))
44 + }
45 +
32 46 /// Result of creating a SyncKit subscription. Carries enough information for
33 47 /// the route handler to stamp the local `sync_apps` row in one go.
34 48 pub struct SynckitSubResult {
@@ -56,6 +70,7 @@ impl StripeClient {
56 70 pub async fn create_synckit_customer(
57 71 &self,
58 72 developer_user_id: UserId,
73 + app_id: SyncAppId,
59 74 email: &str,
60 75 app_name: &str,
61 76 ) -> Result<String> {
@@ -63,10 +78,13 @@ impl StripeClient {
63 78 metadata.insert("mnw_user_id".to_string(), developer_user_id.to_string());
64 79 metadata.insert("synckit_app_name".to_string(), app_name.to_string());
65 80
81 + let key = synckit_idempotency_key("synckit-customer", app_id)?;
66 82 let customer = CreateCustomer::new()
67 83 .email(email.to_string())
68 84 .name(format!("SyncKit — {app_name}"))
69 85 .metadata(metadata)
86 + .customize()
87 + .request_strategy(RequestStrategy::Idempotent(key))
70 88 .send(&self.client)
71 89 .await
72 90 .map_err(|e| {
@@ -79,8 +97,11 @@ impl StripeClient {
79 97
80 98 /// Create a Stripe Product for a SyncKit app. Called once during billing
81 99 /// activation; the same product is reused on re-price.
82 - async fn create_synckit_product(&self, app_name: &str) -> Result<String> {
100 + async fn create_synckit_product(&self, app_id: SyncAppId, app_name: &str) -> Result<String> {
101 + let key = synckit_idempotency_key("synckit-product", app_id)?;
83 102 let product = CreateProduct::new(format!("SyncKit — {app_name}"))
103 + .customize()
104 + .request_strategy(RequestStrategy::Idempotent(key))
84 105 .send(&self.client)
85 106 .await
86 107 .map_err(|e| {
@@ -110,7 +131,7 @@ impl StripeClient {
110 131 }
111 132
112 133 // We need a Product id to use inline price_data; create one per app.
113 - let product_id = self.create_synckit_product(app_name).await?;
134 + let product_id = self.create_synckit_product(app_id, app_name).await?;
114 135
115 136 let price_data = CreateSubscriptionItemsPriceData {
116 137 currency: Currency::USD,
@@ -129,10 +150,13 @@ impl StripeClient {
129 150 let mut metadata = HashMap::new();
130 151 metadata.insert("synckit_app_id".to_string(), app_id.to_string());
131 152
153 + let key = synckit_idempotency_key("synckit-sub", app_id)?;
132 154 let subscription = CreateSubscription::new()
133 155 .customer(customer_id.to_string())
134 156 .items(vec![item])
135 157 .metadata(metadata)
158 + .customize()
159 + .request_strategy(RequestStrategy::Idempotent(key))
136 160 .send(&self.client)
137 161 .await
138 162 .map_err(|e| {
@@ -68,7 +68,7 @@ pub(super) async fn setup(
68 68 .await?
69 69 .ok_or(AppError::Unauthorized)?;
70 70 let id = stripe
71 - .create_synckit_customer(user.id, developer.email.as_str(), &app.name)
71 + .create_synckit_customer(user.id, app_id, developer.email.as_str(), &app.name)
72 72 .await?;
73 73 db::synckit_billing::set_stripe_customer(&state.db, app_id, &id).await?;
74 74 id
@@ -269,7 +269,7 @@ impl PaymentProvider for MockPaymentProvider {
269 269 Ok(())
270 270 }
271 271
272 - async fn create_synckit_customer(&self, _developer_user_id: makenotwork::db::UserId, _email: &str, _app_name: &str) -> Result<String> {
272 + async fn create_synckit_customer(&self, _developer_user_id: makenotwork::db::UserId, _app_id: makenotwork::db::SyncAppId, _email: &str, _app_name: &str) -> Result<String> {
273 273 // Deterministic dummy; tests assert on shape, not content.
274 274 Ok("cus_test_synckit".to_string())
275 275 }