| 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 |
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 |
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 |
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 |
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 |
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 |
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| {
|