Skip to main content

max / makenotwork

payments: seal StripeAccountId + cart ItemType, add DB-layer tests Drive Payments A- -> A+ (ultra-fuzz Run 7 --deep). Codebase-wide StripeAccountId newtype: the in-memory and DB representation of a creator's connected account across every payout path (DbUser, DbProjectMemberWithUser, CartItem, the User view type), converting to &str only at the Stripe wire boundary. create_connect_account and the fan-out helper carry the newtype; validate_stripe_account_id checks the acct_ shape at input boundaries. Retires the bare String that let any string stand in for a connected-account id. Seal CartItem.item_type as the existing ItemType enum (the lone holdout; the type already backs items/discover/tags). DB-layer tests for the Payments cold-spot modules (tips/license_keys/ pending_refunds had zero direct coverage): pending-refund claim/unclaim/ complete lifecycle + crash-window sweep, tip create-guard + complete/refund idempotency, license-key finalize idempotency + activation-cap enforcement. Widen those three db modules to pub mod so the integration crate can pin their contracts directly. Gate: cargo clippy --all-targets clean, cargo test --lib (1764), cargo test --test integration (983). sqlx offline unchanged (no macro SQL touched). No version bump, no deploy.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-25 00:07 UTC
Signed with PGP, not checked
Commit: b412e48d45a8cee0c81fe20a14136f8a4d0594d0
Parent: 832b7b7
19 files changed, +336 insertions, -26 deletions
@@ -3,7 +3,8 @@
3 3 use chrono::{DateTime, Duration, Utc};
4 4 use sqlx::PgPool;
5 5
6 - use super::{ItemId, ProjectId, UserId};
6 + use super::validated_types::StripeAccountId;
7 + use super::{ItemId, ItemType, ProjectId, UserId};
7 8 use crate::error::Result;
8 9
9 10
@@ -135,7 +136,7 @@
135 136 /// a `get_item_by_id` per item at checkout.
136 137 pub project_id: ProjectId,
137 138 pub title: String,
138 - pub item_type: String,
139 + pub item_type: ItemType,
139 140 pub price_cents: i32,
140 141 pub pwyw_enabled: bool,
141 142 pub pwyw_min_cents: Option<i32>,
@@ -143,7 +144,7 @@
143 144 pub amount_cents: Option<i32>,
144 145 pub creator_username: String,
145 146 pub seller_id: UserId,
146 - pub seller_stripe_account_id: Option<String>,
147 + pub seller_stripe_account_id: Option<StripeAccountId>,
147 148 pub seller_charges_enabled: bool,
148 149 pub project_slug: String,
149 150 pub added_at: DateTime<Utc>,
@@ -333,7 +334,7 @@
333 334 item_id: ItemId::nil(),
334 335 project_id: ProjectId::nil(),
335 336 title: String::new(),
336 - item_type: String::from("audio"),
337 + item_type: ItemType::Audio,
337 338 price_cents,
338 339 pwyw_enabled,
339 340 pwyw_min_cents,
@@ -23,7 +23,7 @@
23 23 pub(crate) mod auth;
24 24 pub mod waitlist;
25 25 pub(crate) mod blog_posts;
26 - pub(crate) mod license_keys;
26 + pub mod license_keys;
27 27 pub mod synckit; // pub so the integration test crate can exercise compaction directly
28 28 pub mod synckit_billing;
29 29 pub(crate) mod oauth;
@@ -62,10 +62,10 @@
62 62 pub(crate) mod email_signups;
63 63 pub(crate) mod imports;
64 64 pub(crate) mod media_files;
65 - pub(crate) mod tips;
65 + 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 - pub(crate) mod pending_refunds;
68 + pub mod pending_refunds;
69 69 pub mod webhook_events;
70 70 pub(crate) mod scheduler_jobs;
71 71 pub(crate) mod moderation;
@@ -119,6 +119,12 @@
119 119 KeyCode => crate::validation::validate_key_code,
120 120 Slug => crate::validation::validate_slug,
121 121 Username => crate::validation::validate_username,
122 + // StripeAccountId: Stripe Connect account ID (`acct_...`). The in-memory and
123 + // DB representation of a creator's connected account across every payout
124 + // path; convert to `&str` (via `Deref`/`as_str`) only at the Stripe wire
125 + // boundary. `new()` validates the `acct_` shape; DB reads use `from_trusted`
126 + // (Stripe is the source of truth for the stored value).
127 + StripeAccountId => crate::validation::validate_stripe_account_id,
122 128 );
123 129
124 130 // ── Email ──
@@ -16,6 +16,7 @@
16 16 use stripe_product::price::{CreatePrice, CreatePriceRecurring, CreatePriceRecurringInterval};
17 17 use stripe_types::Currency;
18 18
19 + use crate::db::StripeAccountId;
19 20 use crate::error::{AppError, Result};
20 21 use super::StripeClient;
21 22
@@ -28,7 +29,7 @@
28 29 impl StripeClient {
29 30 /// Create a Stripe Standard connected account for a creator.
30 31 #[tracing::instrument(skip_all, name = "payments::create_connect_account")]
31 - pub async fn create_connect_account(&self, email: &str) -> Result<String> {
32 + pub async fn create_connect_account(&self, email: &str) -> Result<StripeAccountId> {
32 33 let account = CreateAccount::new()
33 34 .type_(CreateAccountType::Standard)
34 35 .email(email.to_string())
@@ -38,7 +39,8 @@
38 39 tracing::error!(error = ?e, "failed to create Stripe connected account");
39 40 AppError::BadRequest("Failed to create Stripe account".to_string())
40 41 })?;
41 - Ok(account.id.to_string())
42 + // Stripe minted this id; trust its shape rather than re-validating.
43 + Ok(StripeAccountId::from_trusted(account.id.to_string()))
42 44 }
43 45
44 46 /// Create an Account Link for Stripe Connect onboarding.
@@ -13,6 +13,7 @@
13 13 use std::sync::Arc;
14 14
15 15 use crate::background::BackgroundTx;
16 + use crate::db::StripeAccountId;
16 17 use crate::payments::PaymentProvider;
17 18
18 19 /// Which Stripe subscription operation to apply to each fan subscription.
@@ -62,7 +63,7 @@
62 63 pub fn spawn_fan_sub_fanout(
63 64 bg: &BackgroundTx,
64 65 stripe: Arc<dyn PaymentProvider>,
65 - account_id: String,
66 + account_id: StripeAccountId,
66 67 sub_ids: Vec<String>,
67 68 op: FanSubOp,
68 69 ) {
@@ -73,7 +74,7 @@
73 74 let total = sub_ids.len();
74 75 let mut failed = 0u32;
75 76 for sub_id in &sub_ids {
76 - if let Err(e) = op.apply(&stripe, sub_id, &account_id).await {
77 + if let Err(e) = op.apply(&stripe, sub_id, account_id.as_str()).await {
77 78 failed += 1;
78 79 tracing::warn!(stripe_sub_id = %sub_id, op = op.label(), error = ?e, "fan subscription op failed");
79 80 }
@@ -88,7 +88,7 @@
88 88 async fn create_cart_checkout_session(&self, params: &CartCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>;
89 89
90 90 // Connect
91 - async fn create_connect_account(&self, email: &str) -> crate::error::Result<String>;
91 + async fn create_connect_account(&self, email: &str) -> crate::error::Result<crate::db::StripeAccountId>;
92 92 async fn create_account_link(&self, account_id: &str, return_url: &str, refresh_url: &str) -> crate::error::Result<String>;
93 93 async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate>;
94 94 async fn create_subscription_product_and_price(&self, connected_account_id: &str, tier_name: &str, tier_description: Option<&str>, price_cents: i64) -> crate::error::Result<(String, String)>;
@@ -177,7 +177,7 @@
177 177 Ok(CheckoutResult { id: session.id.to_string(), url: session.url })
178 178 }
179 179
180 - async fn create_connect_account(&self, email: &str) -> crate::error::Result<String> {
180 + async fn create_connect_account(&self, email: &str) -> crate::error::Result<crate::db::StripeAccountId> {
181 181 StripeClient::create_connect_account(self, email).await
182 182 }
183 183
@@ -1,6 +1,7 @@
1 1 //! View types for user-facing surfaces: profile, waitlist, invites.
2 2
3 3 use super::json_escape;
4 + use crate::db::StripeAccountId;
4 5
5 6 /// User profile data
6 7 #[derive(Clone)]
@@ -13,7 +14,7 @@
13 14 pub avatar_url: Option<String>,
14 15 // Stripe Connect status
15 16 pub stripe_connected: bool,
16 - pub stripe_account_id: Option<String>,
17 + pub stripe_account_id: Option<StripeAccountId>,
17 18 pub stripe_onboarding_complete: bool,
18 19 pub stripe_payouts_enabled: bool,
19 20 pub stripe_charges_enabled: bool,