Skip to main content

max / makenotwork

PricingModel trait, project paywall, wizard type simplification, back-nav bug fix - New pricing module: PricingModel trait with FreePricing, FixedPricing, PwywPricing, SubscriptionPricing impls. Centralizes all access control into AccessContext + can_access(). 46 unit tests. - Rename PricingModel enum to PricingKind (DB discriminant vs trait name). - Fix P3 bug: item subscriptions now check item-level access instead of incorrectly granting project-wide access. - Project paywall (P1): projects can be paywalled with buy-once, PWYW, or subscription pricing. New paywall template + checkout route. - Wizard type step: only show options that produce different wizard behavior (text editor vs audio upload vs file upload). Skip entirely when all allowed types share one behavior group. 8 new tests. - Fix item wizard back-navigation bug: going back to type step and re-submitting errored because step_save had no "type" handler. Now updates item type on existing item instead of 404. - Project wizard monetization: pricing model selector with conditional fields for price/PWYW min/subscription tiers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-28 18:27 UTC
Commit: 1bb601b45f21a508ea985151ed0e09678bed9f34
Parent: d1fc7c8
27 files changed, +1774 insertions, -110 deletions
@@ -3350,7 +3350,7 @@ dependencies = [
3350 3350
3351 3351 [[package]]
3352 3352 name = "makenotwork"
3353 - version = "0.3.10"
3353 + version = "0.3.11"
3354 3354 dependencies = [
3355 3355 "anyhow",
3356 3356 "argon2",
@@ -0,0 +1,32 @@
1 + -- P1: Project pricing
2 + ALTER TABLE projects ADD COLUMN pricing_model VARCHAR(20) NOT NULL DEFAULT 'free';
3 + ALTER TABLE projects ADD COLUMN price_cents INT NOT NULL DEFAULT 0 CHECK (price_cents >= 0);
4 + ALTER TABLE projects ADD COLUMN pwyw_min_cents INT;
5 +
6 + -- P1: Project purchases (reuse transactions table)
7 + ALTER TABLE transactions ADD COLUMN project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
8 + CREATE UNIQUE INDEX idx_transactions_buyer_project_completed
9 + ON transactions (buyer_id, project_id)
10 + WHERE status = 'completed' AND project_id IS NOT NULL;
11 +
12 + -- P2: Item-level subscription tiers
13 + ALTER TABLE subscription_tiers ALTER COLUMN project_id DROP NOT NULL;
14 + ALTER TABLE subscription_tiers ADD COLUMN item_id UUID REFERENCES items(id) ON DELETE CASCADE;
15 + ALTER TABLE subscription_tiers ADD CONSTRAINT tier_exactly_one_target
16 + CHECK ((project_id IS NOT NULL AND item_id IS NULL)
17 + OR (project_id IS NULL AND item_id IS NOT NULL));
18 +
19 + -- P2: Item-level subscriptions
20 + ALTER TABLE subscriptions ALTER COLUMN project_id DROP NOT NULL;
21 + ALTER TABLE subscriptions ADD COLUMN item_id UUID REFERENCES items(id) ON DELETE CASCADE;
22 + ALTER TABLE subscriptions ADD CONSTRAINT sub_exactly_one_target
23 + CHECK ((project_id IS NOT NULL AND item_id IS NULL)
24 + OR (project_id IS NULL AND item_id IS NOT NULL));
25 + CREATE UNIQUE INDEX idx_subscriptions_subscriber_item_active
26 + ON subscriptions (subscriber_id, item_id)
27 + WHERE status = 'active' AND item_id IS NOT NULL;
28 +
29 + -- Indexes
30 + CREATE INDEX idx_subscription_tiers_item_id ON subscription_tiers (item_id) WHERE item_id IS NOT NULL;
31 + CREATE INDEX idx_subscriptions_item_id ON subscriptions (item_id) WHERE item_id IS NOT NULL;
32 + CREATE INDEX idx_transactions_project_id ON transactions (project_id) WHERE project_id IS NOT NULL;
@@ -331,6 +331,20 @@ impl ItemType {
331 331 Self::Digital => "Digital",
332 332 }
333 333 }
334 +
335 + /// Which wizard content-input group this type belongs to.
336 + ///
337 + /// Determines what the content step looks like:
338 + /// - `"text"` → Markdown editor
339 + /// - `"audio"` → Audio file upload
340 + /// - `"file"` → Generic file upload
341 + pub fn wizard_group(&self) -> &'static str {
342 + match self {
343 + Self::Text => "text",
344 + Self::Audio => "audio",
345 + _ => "file",
346 + }
347 + }
334 348 }
335 349
336 350 // ── Git Issues ──
@@ -598,6 +612,38 @@ impl ProjectFeature {
598 612 ("image", "Image", "Photos, artwork, graphics"),
599 613 ]
600 614 }
615 +
616 + /// Item type cards filtered to one per distinct wizard behavior group.
617 + ///
618 + /// The wizard only needs a type selector when the allowed types produce
619 + /// different content-step UIs (text editor vs audio upload vs file upload).
620 + /// Returns one card per group, using the first allowed type as the value.
621 + /// If all types share one group, returns a single card (caller should skip
622 + /// the type step entirely).
623 + pub fn wizard_type_cards(
624 + features: &[String],
625 + ) -> Vec<(&'static str, &'static str, &'static str)> {
626 + let allowed = Self::allowed_item_type_cards(features);
627 + let mut seen_groups = std::collections::HashSet::new();
628 + let mut cards = Vec::new();
629 +
630 + for (value, _, _) in &allowed {
631 + let Ok(item_type) = value.parse::<ItemType>() else {
632 + continue;
633 + };
634 + let group = item_type.wizard_group();
635 + if seen_groups.insert(group) {
636 + let (label, desc) = match group {
637 + "text" => ("Text", "Write in the editor"),
638 + "audio" => ("Audio", "Upload audio files"),
639 + _ => ("File", "Upload any file"),
640 + };
641 + cards.push((*value, label, desc));
642 + }
643 + }
644 +
645 + cards
646 + }
601 647 }
602 648
603 649 // ── Projects ──
@@ -681,6 +727,25 @@ impl_str_enum!(BuildStatus {
681 727 Cancelled => "cancelled",
682 728 });
683 729
730 + // ── Project Pricing ──
731 +
732 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
733 + #[serde(rename_all = "snake_case")]
734 + pub enum PricingKind {
735 + #[default]
736 + Free,
737 + BuyOnce,
738 + Pwyw,
739 + Subscription,
740 + }
741 +
742 + impl_str_enum!(PricingKind {
743 + Free => "free",
744 + BuyOnce => "buy_once",
745 + Pwyw => "pwyw",
746 + Subscription => "subscription",
747 + });
748 +
684 749 // ── Mailing Lists ──
685 750
686 751 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -1033,6 +1098,16 @@ mod tests {
1033 1098 }
1034 1099
1035 1100 #[test]
1101 + fn pricing_kind_round_trip() {
1102 + assert_eq!(PricingKind::Free.to_string(), "free");
1103 + assert_eq!("buy_once".parse::<PricingKind>().unwrap(), PricingKind::BuyOnce);
1104 + assert_eq!("pwyw".parse::<PricingKind>().unwrap(), PricingKind::Pwyw);
1105 + assert_eq!("subscription".parse::<PricingKind>().unwrap(), PricingKind::Subscription);
1106 + assert_eq!(PricingKind::default(), PricingKind::Free);
1107 + assert!("bogus".parse::<PricingKind>().is_err());
1108 + }
1109 +
1110 + #[test]
1036 1111 fn mailing_list_type_round_trip() {
1037 1112 assert_eq!(MailingListType::Content.to_string(), "content");
1038 1113 assert_eq!("devlog".parse::<MailingListType>().unwrap(), MailingListType::Devlog);
@@ -1048,4 +1123,74 @@ mod tests {
1048 1123 let back: SubscriptionStatus = serde_json::from_str(&json).unwrap();
1049 1124 assert_eq!(back, s);
1050 1125 }
1126 +
1127 + // ── ItemType::wizard_group ──
1128 +
1129 + #[test]
1130 + fn wizard_group_text() {
1131 + assert_eq!(ItemType::Text.wizard_group(), "text");
1132 + }
1133 +
1134 + #[test]
1135 + fn wizard_group_audio() {
1136 + assert_eq!(ItemType::Audio.wizard_group(), "audio");
1137 + }
1138 +
1139 + #[test]
1140 + fn wizard_group_file_types() {
1141 + for t in [
1142 + ItemType::Digital,
1143 + ItemType::Video,
1144 + ItemType::Course,
1145 + ItemType::Plugin,
1146 + ItemType::Sample,
1147 + ItemType::Preset,
1148 + ItemType::Template,
1149 + ItemType::Image,
1150 + ] {
1151 + assert_eq!(t.wizard_group(), "file", "{t:?} should be in file group");
1152 + }
1153 + }
1154 +
1155 + // ── ProjectFeature::wizard_type_cards ──
1156 +
1157 + #[test]
1158 + fn wizard_cards_text_only_one_group() {
1159 + let cards = ProjectFeature::wizard_type_cards(&["text".into()]);
1160 + assert_eq!(cards.len(), 1);
1161 + assert_eq!(cards[0].0, "text");
1162 + }
1163 +
1164 + #[test]
1165 + fn wizard_cards_downloads_only_one_group() {
1166 + // All download types are in the "file" group → single card
1167 + let cards = ProjectFeature::wizard_type_cards(&["downloads".into()]);
1168 + assert_eq!(cards.len(), 1);
1169 + assert_eq!(cards[0].0, "digital"); // first type in file group
1170 + }
1171 +
1172 + #[test]
1173 + fn wizard_cards_audio_feature_two_groups() {
1174 + // Audio feature allows audio (audio group) + sample, preset (file group)
1175 + let cards = ProjectFeature::wizard_type_cards(&["audio".into()]);
1176 + assert_eq!(cards.len(), 2);
1177 + let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1178 + assert!(groups.contains(&"audio"));
1179 + // sample is the first file-group type among audio feature's allowed types
1180 + assert!(groups.contains(&"sample"));
1181 + }
1182 +
1183 + #[test]
1184 + fn wizard_cards_text_and_audio_three_groups() {
1185 + let cards =
1186 + ProjectFeature::wizard_type_cards(&["text".into(), "audio".into()]);
1187 + assert_eq!(cards.len(), 3);
1188 + }
1189 +
1190 + #[test]
1191 + fn wizard_cards_empty_features_all_three_groups() {
1192 + // No content features → all types → 3 wizard groups
1193 + let cards = ProjectFeature::wizard_type_cards(&[]);
1194 + assert_eq!(cards.len(), 3);
1195 + }
1051 1196 }
@@ -204,6 +204,12 @@ pub struct DbProject {
204 204 pub mt_community_id: Option<uuid::Uuid>,
205 205 /// Platform features enabled for this project (e.g. audio, blog, downloads).
206 206 pub features: Vec<String>,
207 + /// Pricing model: free, buy_once, pwyw, or subscription.
208 + pub pricing_model: super::PricingKind,
209 + /// Price in cents (for buy_once); 0 for free projects.
210 + pub price_cents: i32,
211 + /// Minimum price in cents when PWYW is enabled (floor).
212 + pub pwyw_min_cents: Option<i32>,
207 213 }
208 214
209 215 /// A git repository tracked on disk, optionally linked to a project.
@@ -451,6 +457,8 @@ pub struct DbTransaction {
451 457 pub seller_username: Option<String>,
452 458 /// Whether the buyer opted to share their email with the creator.
453 459 pub share_contact: bool,
460 + /// Purchased project ID (for project-level purchases). Nullable.
461 + pub project_id: Option<ProjectId>,
454 462 }
455 463
456 464 impl DbTransaction {
@@ -1170,11 +1178,11 @@ pub struct DbFollow {
1170 1178 pub created_at: DateTime<Utc>,
1171 1179 }
1172 1180
1173 - /// A per-project subscription tier defined by a creator.
1181 + /// A subscription tier, scoped to either a project or an item.
1174 1182 #[derive(Debug, Clone, FromRow)]
1175 1183 pub struct DbSubscriptionTier {
1176 1184 pub id: SubscriptionTierId,
1177 - pub project_id: ProjectId,
1185 + pub project_id: Option<ProjectId>,
1178 1186 pub name: String,
1179 1187 pub description: Option<String>,
1180 1188 pub price_cents: i32,
@@ -1184,6 +1192,7 @@ pub struct DbSubscriptionTier {
1184 1192 pub is_active: bool,
1185 1193 pub created_at: DateTime<Utc>,
1186 1194 pub updated_at: DateTime<Utc>,
1195 + pub item_id: Option<ItemId>,
1187 1196 }
1188 1197
1189 1198 /// Active subscription billing period.
@@ -1195,18 +1204,19 @@ pub struct SubscriptionPeriod {
1195 1204 pub end: DateTime<Utc>,
1196 1205 }
1197 1206
1198 - /// A user's subscription to a project tier.
1207 + /// A user's subscription to a project or item tier.
1199 1208 ///
1200 1209 /// **State invariant:** When `status` is `Active` or `PastDue`,
1201 1210 /// `current_period_start` and `current_period_end` are both `Some`.
1202 1211 /// When `status == Canceled`, `canceled_at` is `Some`.
1203 1212 /// When `status == Unpaid`, period fields may or may not be set.
1213 + /// Exactly one of `project_id` or `item_id` is `Some`.
1204 1214 #[derive(Debug, Clone, FromRow)]
1205 1215 pub struct DbSubscription {
1206 1216 pub id: SubscriptionId,
1207 1217 pub subscriber_id: UserId,
1208 1218 pub tier_id: SubscriptionTierId,
1209 - pub project_id: ProjectId,
1219 + pub project_id: Option<ProjectId>,
1210 1220 pub stripe_subscription_id: String,
1211 1221 pub stripe_customer_id: String,
1212 1222 pub status: super::SubscriptionStatus,
@@ -1218,6 +1228,7 @@ pub struct DbSubscription {
1218 1228 pub canceled_at: Option<DateTime<Utc>>,
1219 1229 pub created_at: DateTime<Utc>,
1220 1230 pub updated_at: DateTime<Utc>,
1231 + pub item_id: Option<ItemId>,
1221 1232 }
1222 1233
1223 1234 impl DbSubscription {
@@ -1778,6 +1789,7 @@ mod tests {
1778 1789 item_title: None,
1779 1790 seller_username: None,
1780 1791 share_contact: false,
1792 + project_id: None,
1781 1793 }
1782 1794 }
1783 1795
@@ -1888,7 +1900,7 @@ mod tests {
1888 1900 id: SubscriptionId::nil(),
1889 1901 subscriber_id: UserId::nil(),
1890 1902 tier_id: SubscriptionTierId::nil(),
1891 - project_id: ProjectId::nil(),
1903 + project_id: Some(ProjectId::nil()),
1892 1904 stripe_subscription_id: "sub_123".to_string(),
1893 1905 stripe_customer_id: "cus_123".to_string(),
1894 1906 status,
@@ -1897,6 +1909,7 @@ mod tests {
1897 1909 canceled_at: canceled,
1898 1910 created_at: Utc::now(),
1899 1911 updated_at: Utc::now(),
1912 + item_id: None,
1900 1913 }
1901 1914 }
1902 1915
@@ -245,6 +245,46 @@ pub async fn get_projects_without_mt_community(pool: &PgPool) -> Result<Vec<DbPr
245 245 Ok(projects)
246 246 }
247 247
248 + /// Set or clear a project's image URL (stored in cover_image_url column).
249 + pub async fn update_project_image_url(
250 + pool: &PgPool,
251 + id: ProjectId,
252 + url: &str,
253 + ) -> Result<()> {
254 + sqlx::query("UPDATE projects SET cover_image_url = $1, updated_at = NOW() WHERE id = $2")
255 + .bind(url)
256 + .bind(id)
257 + .execute(pool)
258 + .await?;
259 +
260 + Ok(())
261 + }
262 +
263 + /// Update a project's pricing model, price, and PWYW minimum.
264 + pub async fn update_project_pricing(
265 + pool: &PgPool,
266 + id: ProjectId,
267 + pricing_model: super::PricingKind,
268 + price_cents: i32,
269 + pwyw_min_cents: Option<i32>,
270 + ) -> Result<()> {
271 + sqlx::query(
272 + r#"
273 + UPDATE projects
274 + SET pricing_model = $2, price_cents = $3, pwyw_min_cents = $4, updated_at = NOW()
275 + WHERE id = $1
276 + "#,
277 + )
278 + .bind(id)
279 + .bind(pricing_model)
280 + .bind(price_cents)
281 + .bind(pwyw_min_cents)
282 + .execute(pool)
283 + .await?;
284 +
285 + Ok(())
286 + }
287 +
248 288 /// Atomically increment the project's cache generation counter.
249 289 /// Call after any write that changes project-visible dashboard data.
250 290 pub async fn bump_cache_generation(pool: &PgPool, project_id: ProjectId) -> Result<()> {
@@ -327,6 +327,80 @@ pub async fn get_project_subscriber_count(
327 327 Ok(count)
328 328 }
329 329
330 + // ── Item-level subscriptions ──
331 +
332 + /// Check if a user has an active subscription to a specific item.
333 + pub async fn has_active_subscription_to_item(
334 + pool: &PgPool,
335 + user_id: UserId,
336 + item_id: super::ItemId,
337 + ) -> Result<bool> {
338 + let count: i64 = sqlx::query_scalar(
339 + "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND item_id = $2 AND status = 'active'",
340 + )
341 + .bind(user_id)
342 + .bind(item_id)
343 + .fetch_one(pool)
344 + .await?;
345 +
346 + Ok(count > 0)
347 + }
348 +
349 + /// Get all active subscription tiers for an item, ordered by sort_order.
350 + pub async fn get_active_tiers_by_item(
351 + pool: &PgPool,
352 + item_id: super::ItemId,
353 + ) -> Result<Vec<DbSubscriptionTier>> {
354 + let tiers = sqlx::query_as::<_, DbSubscriptionTier>(
355 + "SELECT * FROM subscription_tiers WHERE item_id = $1 AND is_active = true ORDER BY sort_order, created_at",
356 + )
357 + .bind(item_id)
358 + .fetch_all(pool)
359 + .await?;
360 +
361 + Ok(tiers)
362 + }
363 +
364 + /// Create a subscription tier linked to an item (not a project).
365 + pub async fn create_item_subscription_tier(
366 + pool: &PgPool,
367 + item_id: super::ItemId,
368 + name: &str,
369 + description: Option<&str>,
370 + price_cents: i32,
371 + ) -> Result<DbSubscriptionTier> {
372 + let tier = sqlx::query_as::<_, DbSubscriptionTier>(
373 + r#"
374 + INSERT INTO subscription_tiers (item_id, name, description, price_cents)
375 + VALUES ($1, $2, $3, $4)
376 + RETURNING *
377 + "#,
378 + )
379 + .bind(item_id)
380 + .bind(name)
381 + .bind(description)
382 + .bind(price_cents)
383 + .fetch_one(pool)
384 + .await?;
385 +
386 + Ok(tier)
387 + }
388 +
389 + /// Get all item IDs that a user has active subscriptions to (for batch access checks).
390 + pub async fn get_user_subscribed_item_ids(
391 + pool: &PgPool,
392 + user_id: UserId,
393 + ) -> Result<Vec<super::ItemId>> {
394 + let item_ids: Vec<super::ItemId> = sqlx::query_scalar(
395 + "SELECT DISTINCT item_id FROM subscriptions WHERE subscriber_id = $1 AND status = 'active' AND item_id IS NOT NULL",
396 + )
397 + .bind(user_id)
398 + .fetch_all(pool)
399 + .await?;
400 +
401 + Ok(item_ids)
402 + }
403 +
330 404 // ── Export ──
331 405
332 406 /// Export all subscribers across a creator's projects.
@@ -18,6 +18,8 @@ pub struct CreateTransactionParams<'a> {
18 18 pub item_title: &'a str,
19 19 pub seller_username: &'a str,
20 20 pub share_contact: bool,
21 + /// Set for project-level purchases; `None` for item purchases.
22 + pub project_id: Option<ProjectId>,
21 23 }
22 24
23 25 /// Common parameters for claiming a free item (direct, discount code, or download code).
@@ -37,8 +39,8 @@ pub async fn create_transaction(
37 39 ) -> Result<DbTransaction> {
38 40 let tx = sqlx::query_as::<_, DbTransaction>(
39 41 r#"
40 - INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, item_title, seller_username, share_contact)
41 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
42 + 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)
43 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
42 44 RETURNING *
43 45 "#,
44 46 )
@@ -51,6 +53,7 @@ pub async fn create_transaction(
51 53 .bind(params.item_title)
52 54 .bind(params.seller_username)
53 55 .bind(params.share_contact)
56 + .bind(params.project_id)
54 57 .fetch_one(pool)
55 58 .await?;
56 59
@@ -232,6 +235,59 @@ pub async fn claim_free_with_promo_code(
232 235 Ok((true, claimed))
233 236 }
234 237
238 + // ── Project purchases ──
239 +
240 + /// Check whether a user has a completed purchase for a given project.
241 + pub async fn has_purchased_project(pool: &PgPool, user_id: UserId, project_id: ProjectId) -> Result<bool> {
242 + let count: i64 = sqlx::query_scalar(
243 + "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND project_id = $2 AND status = 'completed'",
244 + )
245 + .bind(user_id)
246 + .bind(project_id)
247 + .fetch_one(pool)
248 + .await?;
249 +
250 + Ok(count > 0)
251 + }
252 +
253 + /// Parameters for creating a pending project purchase transaction.
254 + pub struct CreateProjectTransactionParams<'a> {
255 + pub buyer_id: UserId,
256 + pub seller_id: UserId,
257 + pub project_id: ProjectId,
258 + pub amount_cents: i32,
259 + pub stripe_checkout_session_id: &'a str,
260 + pub project_title: &'a str,
261 + pub seller_username: &'a str,
262 + pub share_contact: bool,
263 + }
264 +
265 + /// Record a new pending transaction for a project purchase.
266 + pub async fn create_project_transaction(
267 + pool: &PgPool,
268 + params: &CreateProjectTransactionParams<'_>,
269 + ) -> Result<DbTransaction> {
270 + let tx = sqlx::query_as::<_, DbTransaction>(
271 + r#"
272 + INSERT INTO transactions (buyer_id, seller_id, project_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, item_title, seller_username, share_contact)
273 + VALUES ($1, $2, $3, $4, 0, $5, $6, $7, $8)
274 + RETURNING *
275 + "#,
276 + )
277 + .bind(params.buyer_id)
278 + .bind(params.seller_id)
279 + .bind(params.project_id)
280 + .bind(params.amount_cents)
281 + .bind(params.stripe_checkout_session_id)
282 + .bind(params.project_title)
283 + .bind(params.seller_username)
284 + .bind(params.share_contact)
285 + .fetch_one(pool)
286 + .await?;
287 +
288 + Ok(tx)
289 + }
290 +
235 291 /// Get items purchased by a user, including any associated license key.
236 292 ///
237 293 /// Reads from the `purchases` VIEW (which filters `transactions` to
@@ -13,6 +13,7 @@ pub mod helpers;
13 13 pub mod monitor;
14 14 pub mod mt_client;
15 15 pub mod payments;
16 + pub mod pricing;
16 17 pub mod scheduler;
17 18 pub mod routes;
18 19 pub mod rss;
@@ -0,0 +1,694 @@
1 + //! Pricing model trait and concrete implementations.
2 + //!
3 + //! Centralizes all pricing/access logic into a single interface. Each pricing
4 + //! strategy (free, fixed, PWYW, subscription) is a concrete struct implementing
5 + //! the `PricingModel` trait. Routes pre-fetch an `AccessContext` from the DB,
6 + //! then call `pricing.can_access(&ctx)` for uniform access control.
7 +
8 + use crate::db;
9 + use crate::helpers;
10 +
11 + /// Pre-fetched access state for a user viewing a priced resource.
12 + ///
13 + /// Routes populate this from DB lookups, then pass it to `PricingModel::can_access()`.
14 + /// Adding a new payment method means adding a new bool field here.
15 + #[derive(Debug, Clone, Default)]
16 + pub struct AccessContext {
17 + pub is_creator: bool,
18 + pub has_purchased: bool,
19 + pub has_active_subscription: bool,
20 + }
21 +
22 + /// What kind of checkout flow a pricing model requires.
23 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
24 + pub enum CheckoutType {
25 + /// Free content, no checkout needed.
26 + None,
27 + /// Standard one-time purchase.
28 + OneTime,
29 + /// Buyer chooses the amount.
30 + PayWhatYouWant,
31 + /// Recurring via subscription tiers.
32 + Subscription,
33 + }
34 +
35 + /// Unified pricing interface for items and projects.
36 + pub trait PricingModel: Send + Sync + std::fmt::Debug {
37 + /// Whether this content is free (no payment of any kind).
38 + fn is_free(&self) -> bool;
39 +
40 + /// Whether the given access context grants access to this content.
41 + fn can_access(&self, ctx: &AccessContext) -> bool;
42 +
43 + /// Human-readable price string for display (e.g. "$9.99", "Free", "PWYW").
44 + fn price_display(&self) -> String;
45 +
46 + /// Raw price in cents (0 for free/subscription).
47 + fn price_cents(&self) -> i32;
48 +
49 + /// Minimum amount in cents for PWYW; `None` for other models.
50 + fn minimum_cents(&self) -> Option<i32> {
51 + None
52 + }
53 +
54 + /// What checkout flow this pricing requires.
55 + fn checkout_type(&self) -> CheckoutType;
56 +
57 + /// Validate a buyer-submitted amount in cents. Returns `Ok(())` or an error message.
58 + fn validate_amount(&self, amount_cents: i32) -> Result<(), String>;
59 +
60 + /// The DB discriminant for this pricing model.
61 + fn kind(&self) -> db::PricingKind;
62 + }
63 +
64 + // ============================================================================
65 + // Concrete implementations
66 + // ============================================================================
67 +
68 + /// Free content — always accessible, no checkout.
69 + #[derive(Debug)]
70 + pub struct FreePricing;
71 +
72 + impl PricingModel for FreePricing {
73 + fn is_free(&self) -> bool {
74 + true
75 + }
76 +
77 + fn can_access(&self, _ctx: &AccessContext) -> bool {
78 + true
79 + }
80 +
81 + fn price_display(&self) -> String {
82 + "Free".to_string()
83 + }
84 +
85 + fn price_cents(&self) -> i32 {
86 + 0
87 + }
88 +
89 + fn checkout_type(&self) -> CheckoutType {
90 + CheckoutType::None
91 + }
92 +
93 + fn validate_amount(&self, _amount_cents: i32) -> Result<(), String> {
94 + Ok(())
95 + }
96 +
97 + fn kind(&self) -> db::PricingKind {
98 + db::PricingKind::Free
99 + }
100 + }
101 +
102 + /// Fixed-price one-time purchase.
103 + ///
104 + /// `can_access` also checks `has_active_subscription` to handle hybrid items
105 + /// that are both buy-once and subscribable.
106 + #[derive(Debug)]
107 + pub struct FixedPricing {
108 + pub price_cents: i32,
109 + }
110 +
111 + impl PricingModel for FixedPricing {
112 + fn is_free(&self) -> bool {
113 + false
114 + }
115 +
116 + fn can_access(&self, ctx: &AccessContext) -> bool {
117 + ctx.is_creator || ctx.has_purchased || ctx.has_active_subscription
118 + }
119 +
120 + fn price_display(&self) -> String {
121 + helpers::format_price(self.price_cents)
122 + }
123 +
124 + fn price_cents(&self) -> i32 {
125 + self.price_cents
126 + }
127 +
128 + fn checkout_type(&self) -> CheckoutType {
129 + CheckoutType::OneTime
130 + }
131 +
132 + fn validate_amount(&self, amount_cents: i32) -> Result<(), String> {
133 + if amount_cents < self.price_cents {
134 + Err(format!(
135 + "Amount must be at least {}",
136 + helpers::format_price(self.price_cents)
137 + ))
138 + } else {
139 + Ok(())
140 + }
141 + }
142 +
143 + fn kind(&self) -> db::PricingKind {
144 + db::PricingKind::BuyOnce
145 + }
146 + }
147 +
148 + /// Pay-what-you-want pricing with optional minimum.
149 + ///
150 + /// Always shows checkout even with $0 min — `is_free()` returns false.
151 + #[derive(Debug)]
152 + pub struct PwywPricing {
153 + pub min_cents: Option<i32>,
154 + }
155 +
156 + impl PricingModel for PwywPricing {
157 + fn is_free(&self) -> bool {
158 + false
159 + }
160 +
161 + fn can_access(&self, ctx: &AccessContext) -> bool {
162 + ctx.is_creator || ctx.has_purchased || ctx.has_active_subscription
163 + }
164 +
165 + fn price_display(&self) -> String {
166 + match self.min_cents {
167 + Some(min) if min > 0 => format!("From {}", helpers::format_price(min)),
168 + _ => "Pay what you want".to_string(),
169 + }
170 + }
171 +
172 + fn price_cents(&self) -> i32 {
173 + self.min_cents.unwrap_or(0)
174 + }
175 +
176 + fn minimum_cents(&self) -> Option<i32> {
177 + self.min_cents
178 + }
179 +
180 + fn checkout_type(&self) -> CheckoutType {
181 + CheckoutType::PayWhatYouWant
182 + }
183 +
184 + fn validate_amount(&self, amount_cents: i32) -> Result<(), String> {
185 + let min = self.min_cents.unwrap_or(0);
186 + if amount_cents < min {
187 + Err(format!(
188 + "Amount must be at least ${:.2}",
189 + min as f64 / 100.0
190 + ))
191 + } else {
192 + Ok(())
193 + }
194 + }
195 +
196 + fn kind(&self) -> db::PricingKind {
197 + db::PricingKind::Pwyw
198 + }
199 + }
200 +
201 + /// Subscription-only pricing.
202 + ///
203 + /// `can_access` does NOT check `has_purchased` — subscribing is recurring, not one-time.
204 + /// Creator access still works.
205 + #[derive(Debug)]
206 + pub struct SubscriptionPricing;
207 +
208 + impl PricingModel for SubscriptionPricing {
209 + fn is_free(&self) -> bool {
210 + false
211 + }
212 +
213 + fn can_access(&self, ctx: &AccessContext) -> bool {
214 + ctx.is_creator || ctx.has_active_subscription
215 + }
216 +
217 + fn price_display(&self) -> String {
218 + "Subscription".to_string()
219 + }
220 +
221 + fn price_cents(&self) -> i32 {
222 + 0
223 + }
224 +
225 + fn checkout_type(&self) -> CheckoutType {
226 + CheckoutType::Subscription
227 + }
228 +
229 + fn validate_amount(&self, _amount_cents: i32) -> Result<(), String> {
230 + Err("Subscription items cannot be purchased directly".to_string())
231 + }
232 +
233 + fn kind(&self) -> db::PricingKind {
234 + db::PricingKind::Subscription
235 + }
236 + }
237 +
238 + // ============================================================================
239 + // Constructors
240 + // ============================================================================
241 +
242 + /// Build a pricing model from a project's DB row.
243 + pub fn for_project(project: &db::DbProject) -> Box<dyn PricingModel> {
244 + match project.pricing_model {
245 + db::PricingKind::Free => Box::new(FreePricing),
246 + db::PricingKind::BuyOnce => Box::new(FixedPricing {
247 + price_cents: project.price_cents,
248 + }),
249 + db::PricingKind::Pwyw => Box::new(PwywPricing {
250 + min_cents: project.pwyw_min_cents,
251 + }),
252 + db::PricingKind::Subscription => Box::new(SubscriptionPricing),
253 + }
254 + }
255 +
256 + /// Build a pricing model from an item's DB row.
257 + ///
258 + /// Items derive pricing from existing fields (`price_cents`, `pwyw_enabled`,
259 + /// `pwyw_min_cents`). No new column needed.
260 + pub fn for_item(item: &db::DbItem) -> Box<dyn PricingModel> {
261 + if item.pwyw_enabled {
262 + Box::new(PwywPricing {
263 + min_cents: item.pwyw_min_cents,
264 + })
265 + } else if item.price_cents == 0 {
266 + Box::new(FreePricing)
267 + } else {
268 + Box::new(FixedPricing {
269 + price_cents: item.price_cents,
270 + })
271 + }
272 + }
273 +
274 + /// Build an access context for a project, fetching purchase/subscription state from DB.
275 + pub async fn build_project_access_context(
276 + pool: &sqlx::PgPool,
277 + maybe_user_id: Option<db::UserId>,
278 + project_id: db::ProjectId,
279 + creator_user_id: db::UserId,
280 + ) -> crate::error::Result<AccessContext> {
281 + let Some(user_id) = maybe_user_id else {
282 + return Ok(AccessContext::default());
283 + };
284 +
285 + let is_creator = user_id == creator_user_id;
286 + let has_purchased =
287 + db::transactions::has_purchased_project(pool, user_id, project_id).await?;
288 + let has_active_subscription =
289 + db::subscriptions::has_active_subscription_to_project(pool, user_id, project_id).await?;
290 +
291 + Ok(AccessContext {
292 + is_creator,
293 + has_purchased,
294 + has_active_subscription,
295 + })
296 + }
297 +
298 + // ============================================================================
299 + // Tests
300 + // ============================================================================
301 +
302 + #[cfg(test)]
303 + mod tests {
304 + use super::*;
305 +
306 + // ── FreePricing ──
307 +
308 + #[test]
309 + fn free_is_free() {
310 + assert!(FreePricing.is_free());
311 + }
312 +
313 + #[test]
314 + fn free_always_accessible() {
315 + assert!(FreePricing.can_access(&AccessContext::default()));
316 + }
317 +
318 + #[test]
319 + fn free_price_display() {
320 + assert_eq!(FreePricing.price_display(), "Free");
321 + }
322 +
323 + #[test]
324 + fn free_price_cents() {
325 + assert_eq!(FreePricing.price_cents(), 0);
326 + }
327 +
328 + #[test]
329 + fn free_checkout_type() {
330 + assert_eq!(FreePricing.checkout_type(), CheckoutType::None);
331 + }
332 +
333 + #[test]
334 + fn free_validate_amount() {
335 + assert!(FreePricing.validate_amount(0).is_ok());
336 + assert!(FreePricing.validate_amount(100).is_ok());
337 + }
338 +
339 + #[test]
340 + fn free_kind() {
341 + assert_eq!(FreePricing.kind(), db::PricingKind::Free);
342 + }
343 +
344 + // ── FixedPricing ──
345 +
346 + #[test]
347 + fn fixed_not_free() {
348 + let p = FixedPricing { price_cents: 999 };
349 + assert!(!p.is_free());
350 + }
351 +
352 + #[test]
353 + fn fixed_access_creator() {
354 + let p = FixedPricing { price_cents: 999 };
355 + assert!(p.can_access(&AccessContext {
356 + is_creator: true,
357 + ..Default::default()
358 + }));
359 + }
360 +
361 + #[test]
362 + fn fixed_access_purchased() {
363 + let p = FixedPricing { price_cents: 999 };
364 + assert!(p.can_access(&AccessContext {
365 + has_purchased: true,
366 + ..Default::default()
367 + }));
368 + }
369 +
370 + #[test]
371 + fn fixed_access_subscribed() {
372 + let p = FixedPricing { price_cents: 999 };
373 + assert!(p.can_access(&AccessContext {
374 + has_active_subscription: true,
375 + ..Default::default()
376 + }));
377 + }
378 +
379 + #[test]
380 + fn fixed_access_denied() {
381 + let p = FixedPricing { price_cents: 999 };
382 + assert!(!p.can_access(&AccessContext::default()));
383 + }
384 +
385 + #[test]
386 + fn fixed_price_display_whole() {
387 + let p = FixedPricing { price_cents: 1000 };
388 + assert_eq!(p.price_display(), "$10");
389 + }
390 +
391 + #[test]
392 + fn fixed_price_display_cents() {
393 + let p = FixedPricing { price_cents: 999 };
394 + assert_eq!(p.price_display(), "$9.99");
395 + }
396 +
397 + #[test]
398 + fn fixed_validate_amount_ok() {
399 + let p = FixedPricing { price_cents: 999 };
400 + assert!(p.validate_amount(999).is_ok());
401 + assert!(p.validate_amount(1500).is_ok());
402 + }
403 +
404 + #[test]
405 + fn fixed_validate_amount_too_low() {
406 + let p = FixedPricing { price_cents: 999 };
407 + assert!(p.validate_amount(500).is_err());
408 + }
409 +
410 + #[test]
411 + fn fixed_kind() {
412 + let p = FixedPricing { price_cents: 999 };
413 + assert_eq!(p.kind(), db::PricingKind::BuyOnce);
414 + }
415 +
416 + // ── PwywPricing ──
417 +
418 + #[test]
419 + fn pwyw_not_free() {
420 + let p = PwywPricing { min_cents: Some(0) };
421 + assert!(!p.is_free());
422 + }
423 +
424 + #[test]
425 + fn pwyw_not_free_even_zero_min() {
426 + let p = PwywPricing { min_cents: None };
427 + assert!(!p.is_free());
428 + }
429 +
430 + #[test]
431 + fn pwyw_access_creator() {
432 + let p = PwywPricing { min_cents: Some(500) };
433 + assert!(p.can_access(&AccessContext {
434 + is_creator: true,
435 + ..Default::default()
436 + }));
437 + }
438 +
439 + #[test]
440 + fn pwyw_access_purchased() {
441 + let p = PwywPricing { min_cents: Some(500) };
442 + assert!(p.can_access(&AccessContext {
443 + has_purchased: true,
444 + ..Default::default()
445 + }));
446 + }
447 +
448 + #[test]
449 + fn pwyw_access_denied() {
450 + let p = PwywPricing { min_cents: Some(500) };
451 + assert!(!p.can_access(&AccessContext::default()));
452 + }
453 +
454 + #[test]
455 + fn pwyw_price_display_with_min() {
456 + let p = PwywPricing {
457 + min_cents: Some(500),
458 + };
459 + assert_eq!(p.price_display(), "From $5");
460 + }
461 +
462 + #[test]
463 + fn pwyw_price_display_no_min() {
464 + let p = PwywPricing { min_cents: None };
465 + assert_eq!(p.price_display(), "Pay what you want");
466 + }
467 +
468 + #[test]
469 + fn pwyw_price_display_zero_min() {
470 + let p = PwywPricing { min_cents: Some(0) };
471 + assert_eq!(p.price_display(), "Pay what you want");
472 + }
473 +
474 + #[test]
475 + fn pwyw_validate_amount_ok() {
476 + let p = PwywPricing {
477 + min_cents: Some(500),
478 + };
479 + assert!(p.validate_amount(500).is_ok());
480 + assert!(p.validate_amount(1000).is_ok());
481 + }
482 +
483 + #[test]
484 + fn pwyw_validate_amount_too_low() {
485 + let p = PwywPricing {
486 + min_cents: Some(500),
487 + };
488 + assert!(p.validate_amount(400).is_err());
489 + }
490 +
491 + #[test]
492 + fn pwyw_validate_amount_zero_min() {
493 + let p = PwywPricing { min_cents: Some(0) };
494 + assert!(p.validate_amount(0).is_ok());
495 + }
496 +
497 + #[test]
498 + fn pwyw_minimum_cents() {
499 + let p = PwywPricing {
500 + min_cents: Some(500),
Lines truncated
@@ -153,7 +153,8 @@ pub(super) async fn update_tier(
153 153 .await?
154 154 .ok_or(AppError::NotFound)?;
155 155
156 - verify_project_ownership(&state, tier.project_id, user.id).await?;
156 + let tier_project_id = tier.project_id.ok_or(AppError::NotFound)?;
157 + verify_project_ownership(&state, tier_project_id, user.id).await?;
157 158
158 159 // Validate input
159 160 validation::validate_tier_name(&req.name)?;
@@ -169,7 +170,7 @@ pub(super) async fn update_tier(
169 170 req.is_active,
170 171 ).await?;
171 172
172 - db::projects::bump_cache_generation(&state.db, tier.project_id).await?;
173 + db::projects::bump_cache_generation(&state.db, tier_project_id).await?;
173 174
174 175 Ok(Json(TierResponse {
175 176 id: updated.id,
@@ -194,11 +195,12 @@ pub(super) async fn delete_tier(
194 195 .await?
195 196 .ok_or(AppError::NotFound)?;
196 197
197 - verify_project_ownership(&state, tier.project_id, user.id).await?;
198 + let tier_project_id = tier.project_id.ok_or(AppError::NotFound)?;
199 + verify_project_ownership(&state, tier_project_id, user.id).await?;
198 200
199 201 db::subscriptions::delete_subscription_tier(&state.db, tier_id).await?;
200 202
201 - db::projects::bump_cache_generation(&state.db, tier.project_id).await?;
203 + db::projects::bump_cache_generation(&state.db, tier_project_id).await?;
202 204
203 205 if is_htmx_request(&headers) {
204 206 return Ok(htmx_toast_response("Tier deleted", "success").into_response());
@@ -71,21 +71,50 @@ async fn verify_item_wizard_access(
71 71 // =============================================================================
72 72
73 73 /// Render the full item wizard page with step 1 (type) inline.
74 + ///
75 + /// If all allowed item types share the same wizard behavior (e.g. all are
76 + /// file uploads), the type step is skipped: an item is created automatically
77 + /// and the user lands on the details step.
74 78 #[tracing::instrument(skip_all, name = "wizard::item_page")]
75 79 pub async fn wizard_page(
76 80 State(state): State<AppState>,
77 81 session: Session,
78 82 AuthUser(user): AuthUser,
79 83 Path(slug): Path<String>,
80 - ) -> Result<impl IntoResponse> {
84 + ) -> Result<Response> {
81 85 let slug_val = Slug::from_trusted(slug.clone());
82 86 let project = db::projects::get_project_by_user_and_slug(&state.db, user.id, &slug_val)
83 87 .await?
84 88 .ok_or(AppError::NotFound)?;
85 89
90 + let type_cards = ProjectFeature::wizard_type_cards(&project.features);
91 +
92 + // Only 1 wizard behavior group → skip the type selector
93 + if type_cards.len() == 1 {
94 + let item_type: ItemType = type_cards[0]
95 + .0
96 + .parse()
97 + .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?;
98 +
99 + let item = db::items::create_item(
100 + &state.db,
101 + project.id,
102 + "Untitled",
103 + None,
104 + 0,
105 + item_type,
106 + )
107 + .await?;
108 +
109 + return Ok(axum::response::Redirect::to(&format!(
110 + "/dashboard/project/{}/new-item/{}/step/details",
111 + slug, item.id
112 + ))
113 + .into_response());
114 + }
115 +
86 116 let csrf_token = get_csrf_token(&session).await;
87 117 let nav = build_step_nav(ITEM_STEPS, ITEM_LABELS, "type");
88 - let type_cards = ProjectFeature::allowed_item_type_cards(&project.features);
89 118
90 119 Ok(WizardItemTemplate {
91 120 csrf_token,
@@ -93,7 +122,8 @@ pub async fn wizard_page(
93 122 nav,
94 123 project_slug: slug,
95 124 item_type_cards: type_cards,
96 - })
125 + }
126 + .into_response())
97 127 }
98 128
99 129 // =============================================================================
@@ -126,11 +156,11 @@ pub async fn step_type_create(
126 156 .parse()
127 157 .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?;
128 158
129 - // Validate item type is allowed by project features
130 - let allowed = ProjectFeature::allowed_item_type_cards(&project.features);
131 - if !allowed.iter().any(|(v, _, _)| *v == form.item_type.as_str()) {
159 + // Validate the selected type is in the allowed wizard cards
160 + let cards = ProjectFeature::wizard_type_cards(&project.features);
161 + if !cards.iter().any(|(v, _, _)| *v == form.item_type.as_str()) {
132 162 return Err(AppError::Validation(format!(
133 - "Item type '{}' is not available for this project's features",
163 + "Item type '{}' is not available for this project",
134 164 form.item_type
135 165 )));
136 166 }
@@ -182,6 +212,7 @@ pub async fn step_save(
182 212 let (project, item) = verify_item_wizard_access(&state, &user, &slug, &id).await?;
183 213
184 214 match step.as_str() {
215 + "type" => save_type(&state, &project, &item, &form).await?,
185 216 "details" => save_details(&state, &item, &form).await?,
186 217 "content" => save_content(&state, &item, &form).await?,
187 218 "pricing" => save_pricing(&state, &item, &form).await?,
@@ -203,6 +234,45 @@ pub async fn step_save(
203 234 // Step save handlers
204 235 // =============================================================================
205 236
237 + /// Update the item type when going back to step 1 and re-submitting.
238 + async fn save_type(
239 + state: &AppState,
240 + project: &db::DbProject,
241 + item: &db::DbItem,
242 + form: &HashMap<String, String>,
243 + ) -> Result<()> {
244 + let type_str = form.get("item_type").ok_or(AppError::BadRequest(
245 + "Missing item_type".to_string(),
246 + ))?;
247 + let item_type: ItemType = type_str
248 + .parse()
249 + .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?;
250 +
251 + // Validate the selected type is in the allowed wizard cards
252 + let cards = ProjectFeature::wizard_type_cards(&project.features);
253 + if !cards.iter().any(|(v, _, _)| *v == type_str.as_str()) {
254 + return Err(AppError::Validation(format!(
255 + "Item type '{type_str}' is not available for this project",
256 + )));
257 + }
258 +
259 + db::items::update_item(
260 + &state.db,
261 + item.id,
262 + None,
263 + None,
264 + None,
265 + Some(item_type),
266 + None,
267 + None,
268 + None,
269 + None,
270 + None,
271 + )
272 + .await?;
273 + Ok(())
274 + }
275 +
206 276 async fn save_details(
207 277 state: &AppState,
208 278 item: &db::DbItem,
@@ -440,7 +510,19 @@ async fn render_step(
440 510
441 511 match step {
442 512 "type" => {
443 - let type_cards = ProjectFeature::allowed_item_type_cards(&project.features);
513 + let type_cards = ProjectFeature::wizard_type_cards(&project.features);
514 + // If only 1 behavior group, skip forward to details
515 + if type_cards.len() <= 1 {
516 + let nav = build_step_nav(ITEM_STEPS, ITEM_LABELS, "details");
517 + return Ok(WizardItemDetailsTemplate {
518 + nav,
519 + project_slug,
520 + item_id,
521 + title: item.title.clone(),
522 + description: item.description.clone().unwrap_or_default(),
523 + }
524 + .into_response());
525 + }
444 526 Ok(WizardItemTypeTemplate {
445 527 nav,
446 528 project_slug,
@@ -8,6 +8,7 @@ use axum::{
8 8 response::{IntoResponse, Response},
9 9 Form,
10 10 };
11 + use axum_extra::extract::Form as HtmlForm;
11 12 use tower_sessions::Session;
12 13
13 14 use crate::{
@@ -15,6 +16,7 @@ use crate::{
15 16 db::{self, Slug},
16 17 error::{AppError, Result},
17 18 helpers::get_csrf_token,
19 + pricing,
18 20 templates::*,
19 21 validation,
20 22 AppState,
@@ -100,7 +102,7 @@ pub async fn step_basics_create(
100 102 State(state): State<AppState>,
101 103 session: Session,
102 104 AuthUser(user): AuthUser,
103 - Form(form): Form<BasicsForm>,
105 + HtmlForm(form): HtmlForm<BasicsForm>,
104 106 ) -> Result<Response> {
105 107 user.check_not_suspended()?;
106 108 if !user.can_create_projects {
@@ -207,17 +209,12 @@ async fn save_appearance(
207 209 project: &db::DbProject,
208 210 form: &HashMap<String, String>,
209 211 ) -> Result<()> {
210 - // Cover image is uploaded via presign flow (client-side S3 upload).
211 - // The form just sends the confirmed S3 URL if one was uploaded.
212 - if let Some(cover_url) = form.get("cover_image_url")
213 - && !cover_url.is_empty()
212 + // Image URL is set by the presign/confirm flow (JS stores it in hidden field).
213 + // On form submit we persist whatever URL the client confirmed.
214 + if let Some(image_url) = form.get("cover_image_url")
215 + && !image_url.is_empty()
214 216 {
215 - // Cover URL uploaded via presign flow; save to DB
216 - sqlx::query("UPDATE projects SET cover_image_url = $1, updated_at = NOW() WHERE id = $2")
217 - .bind(cover_url)
218 - .bind(project.id)
219 - .execute(&state.db)
220 - .await?;
217 + db::projects::update_project_image_url(&state.db, project.id, image_url).await?;
221 218 }
222 219 Ok(())
223 220 }
@@ -228,6 +225,35 @@ async fn save_monetization(
228 225 project: &db::DbProject,
229 226 form: &HashMap<String, String>,
230 227 ) -> Result<()> {
228 + // Save project pricing model
229 + let pricing_kind: db::PricingKind = form
230 + .get("pricing_model")
231 + .and_then(|s| s.parse().ok())
232 + .unwrap_or(db::PricingKind::Free);
233 +
234 + let price_cents = if pricing_kind == db::PricingKind::BuyOnce {
235 + let dollars: f64 = form
236 + .get("price_dollars")
237 + .and_then(|p| p.parse().ok())
238 + .unwrap_or(0.0);
239 + (dollars * 100.0).round() as i32
240 + } else {
241 + 0
242 + };
243 +
244 + let pwyw_min_cents = if pricing_kind == db::PricingKind::Pwyw {
245 + let dollars: f64 = form
246 + .get("pwyw_min_dollars")
247 + .and_then(|p| p.parse().ok())
248 + .unwrap_or(0.0);
249 + Some((dollars * 100.0).round() as i32)
250 + } else {
251 + None
252 + };
253 +
254 + db::projects::update_project_pricing(&state.db, project.id, pricing_kind, price_cents, pwyw_min_cents)
255 + .await?;
256 +
231 257 // Parse tier entries: tier_name_0, tier_price_0, tier_desc_0, etc.
232 258 let mut i = 0;
233 259 loop {
@@ -380,6 +406,7 @@ async fn render_step(
380 406 Ok(WizardProjectAppearanceTemplate {
381 407 nav,
382 408 slug,
409 + project_id: project.id.to_string(),
383 410 cover_image_url: project.cover_image_url.clone(),
384 411 project_title: project.title.clone(),
385 412 }
@@ -417,6 +444,16 @@ async fn render_step(
417 444 })
418 445 .collect(),
419 446 stripe_connected,
447 + pricing_model: project.pricing_model.to_string(),
448 + price_dollars: format!(
449 + "{}.{:02}",
450 + project.price_cents / 100,
451 + project.price_cents.unsigned_abs() % 100
452 + ),
453 + pwyw_min_dollars: project
454 + .pwyw_min_cents
455 + .map(|c| format!("{}.{:02}", c / 100, c.unsigned_abs() % 100))
456 + .unwrap_or_else(|| "0.00".to_string()),
420 457 }
421 458 .into_response())
422 459 }
@@ -435,6 +472,7 @@ async fn render_step(
435 472 db::subscriptions::get_all_tiers_by_project(&state.db, project.id).await?;
436 473 let category_name =
437 474 db::categories::get_project_category_name(&state.db, project.id).await?;
475 + let project_pricing = pricing::for_project(project);
438 476
439 477 Ok(WizardProjectPreviewTemplate {
440 478 csrf_token,
@@ -466,6 +504,7 @@ async fn render_step(
466 504 })
467 505 .collect(),
468 506 is_public: project.is_public,
507 + pricing_display: project_pricing.price_display(),
469 508 }
470 509 .into_response())
471 510 }
@@ -13,6 +13,7 @@ use crate::{
13 13 db::{self, ContentData, FollowTargetType, ItemId, ItemType, Slug, Username},
14 14 error::{AppError, Result},
15 15 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
16 + pricing,
16 17 templates::*,
17 18 types::*,
18 19 AppState,
@@ -109,6 +110,34 @@ pub(crate) async fn render_project_page(
109 110 .await?
110 111 .ok_or(AppError::NotFound)?;
111 112
113 + // Project-level paywall gate
114 + let project_pricing = pricing::for_project(db_project);
115 + if !project_pricing.is_free() {
116 + let project_ctx = pricing::build_project_access_context(
117 + &state.db,
118 + maybe_user.as_ref().map(|u| u.id),
119 + db_project.id,
120 + db_project.user_id,
121 + )
122 + .await?;
123 + if !project_pricing.can_access(&project_ctx) {
124 + let db_tiers = db::subscriptions::get_active_tiers_by_project(&state.db, db_project.id).await?;
125 + let subscription_tiers: Vec<SubscriptionTier> = db_tiers.iter().map(SubscriptionTier::from).collect();
126 + let project = Project::from_db(db_project, 0);
127 + return Ok(ProjectPaywallTemplate {
128 + csrf_token,
129 + session_user: maybe_user,
130 + project,
131 + creator_username: db_user.username.to_string(),
132 + price_display: project_pricing.price_display(),
133 + checkout_type: project_pricing.checkout_type(),
134 + subscription_tiers,
135 + host_url: state.config.host_url.clone(),
136 + }
137 + .into_response());
138 + }
139 + }
140 +
112 141 let db_items = db::items::get_public_items_by_project(&state.db, db_project.id).await?;
113 142
114 143 let is_creator = maybe_user.as_ref().map(|u| u.id == db_project.user_id).unwrap_or(false);
@@ -119,6 +148,12 @@ pub(crate) async fn render_project_page(
119 148 std::collections::HashSet::new()
120 149 };
121 150
151 + let subscribed_item_ids: std::collections::HashSet<ItemId> = if let Some(ref user) = maybe_user {
152 + db::subscriptions::get_user_subscribed_item_ids(&state.db, user.id).await?.into_iter().collect()
153 + } else {
154 + std::collections::HashSet::new()
155 + };
156 +
122 157 let has_subscription = if let Some(ref user) = maybe_user {
123 158 db::subscriptions::has_active_subscription_to_project(&state.db, user.id, db_project.id).await?
124 159 } else {
@@ -131,8 +166,14 @@ pub(crate) async fn render_project_page(
131 166 let tags_map = db::tags::get_tags_for_items(&state.db, &item_ids).await?;
132 167 let mut items: Vec<Item> = Vec::with_capacity(db_items.len());
133 168 for i in &db_items {
134 - let is_free = i.price_cents == 0;
135 - let can_access = is_free || is_creator || purchased_item_ids.contains(&i.id) || has_subscription;
169 + let item_pricing = pricing::for_item(i);
170 + let ctx = pricing::AccessContext {
171 + is_creator,
172 + has_purchased: purchased_item_ids.contains(&i.id),
173 + has_active_subscription: subscribed_item_ids.contains(&i.id),
174 + };
175 + let can_access = item_pricing.can_access(&ctx);
176 + let is_free = item_pricing.is_free();
136 177 let item_tags = tags_map.get(&i.id).map(|v| v.as_slice()).unwrap_or(&[]);
137 178 items.push(Item::from_db_list(i, item_tags, is_free, can_access));
138 179 }
@@ -235,18 +276,24 @@ pub(crate) async fn render_item_page(
235 276 _ => (None, None),
236 277 };
237 278
238 - let is_free = db_item.price_cents == 0;
279 + let item_pricing = pricing::for_item(db_item);
239 280 let in_library = if let Some(ref user) = maybe_user {
240 281 db::transactions::has_purchased_item(&state.db, user.id, db_item.id).await?
241 282 } else {
242 283 false
243 284 };
244 - let has_subscription = if let Some(ref user) = maybe_user {
245 - db::subscriptions::has_active_subscription_to_project(&state.db, user.id, db_item.project_id).await?
285 + let has_item_sub = if let Some(ref user) = maybe_user {
286 + db::subscriptions::has_active_subscription_to_item(&state.db, user.id, db_item.id).await?
246 287 } else {
247 288 false
248 289 };
249 - let has_access = is_free || in_library || has_subscription;
290 + let ctx = pricing::AccessContext {
291 + is_creator: is_owner,
292 + has_purchased: in_library,
293 + has_active_subscription: has_item_sub,
294 + };
295 + let has_access = item_pricing.can_access(&ctx);
296 + let is_free = item_pricing.is_free();
250 297
251 298 let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?;
252 299 let item = Item::from_db_detail(db_item, &item_tags, body_html.clone(), reading_time.clone(), is_free, has_access);
@@ -12,9 +12,10 @@ use tower_governor::GovernorLayer;
12 12 use crate::{
13 13 auth::{AuthUser, MaybeUser},
14 14 constants,
15 - db::{self, ContentData, ItemId, VersionId},
15 + db::{self, ContentData, ItemId, ProjectId, VersionId},
16 16 error::{AppError, Result},
17 - storage::{FileType, S3Client, CACHE_CONTROL_IMMUTABLE},
17 + pricing,
18 + storage::{self, FileType, S3Client, CACHE_CONTROL_IMMUTABLE},
18 19 AppState,
19 20 };
20 21
@@ -30,6 +31,8 @@ pub fn storage_routes() -> Router<AppState> {
30 31 .route("/api/upload/confirm", post(confirm_upload))
31 32 .route("/api/versions/{version_id}/upload/presign", post(version_presign_upload))
32 33 .route("/api/versions/{version_id}/upload/confirm", post(version_confirm_upload))
34 + .route("/api/projects/image/presign", post(project_image_presign))
35 + .route("/api/projects/image/confirm", post(project_image_confirm))
33 36 .route_layer(GovernorLayer {
34 37 config: upload_rate_limit,
35 38 });
@@ -105,6 +108,28 @@ pub struct VersionDownloadResponse {
105 108 pub expires_in: u64,
106 109 }
107 110
111 + /// JSON input for requesting a presigned project image upload URL.
112 + #[derive(Debug, Deserialize)]
113 + pub struct ProjectImagePresignRequest {
114 + pub project_id: ProjectId,
115 + pub file_name: String,
116 + pub content_type: String,
117 + }
118 +
119 + /// JSON input for confirming a completed project image upload.
120 + #[derive(Debug, Deserialize)]
121 + pub struct ProjectImageConfirmRequest {
122 + pub project_id: ProjectId,
123 + pub s3_key: String,
124 + }
125 +
126 + /// JSON response from a successful project image confirm.
127 + #[derive(Debug, Serialize)]
128 + pub struct ProjectImageConfirmResponse {
129 + pub success: bool,
130 + pub image_url: String,
131 + }
132 +
108 133 // =============================================================================
109 134 // Helpers
110 135 // =============================================================================
@@ -361,15 +386,17 @@ async fn stream_url(
361 386 };
362 387
363 388 // Access control
364 - let is_free = item.price_cents == 0;
389 + let item_pricing = pricing::for_item(&item);
390 + let is_free = item_pricing.is_free();
365 391
366 392 if !is_free {
367 - // Paid content - must be logged in and have purchased or subscribed
368 393 let user = maybe_user.ok_or(AppError::Unauthorized)?;
369 - let has_purchased = db::transactions::has_purchased_item(&state.db, user.id, item_id).await?;
370 - let has_subscription = db::subscriptions::has_active_subscription_to_project(&state.db, user.id, item.project_id).await?;
371 -
372 - if !has_purchased && !has_subscription {
394 + let ctx = pricing::AccessContext {
395 + is_creator: false, // stream is for consumers
396 + has_purchased: db::transactions::has_purchased_item(&state.db, user.id, item_id).await?,
397 + has_active_subscription: db::subscriptions::has_active_subscription_to_item(&state.db, user.id, item_id).await?,
398 + };
399 + if !item_pricing.can_access(&ctx) {
373 400 return Err(AppError::Forbidden);
374 401 }
375 402 }
@@ -565,12 +592,16 @@ async fn version_download(
565 592 }
566 593
567 594 // Access control
568 - let is_free = item.price_cents == 0;
595 + let item_pricing = pricing::for_item(&item);
596 + let is_free = item_pricing.is_free();
569 597 if !is_free {
570 598 let user = maybe_user.ok_or(AppError::Unauthorized)?;
571 - let has_purchased = db::transactions::has_purchased_item(&state.db, user.id, version.item_id).await?;
572 - let has_subscription = db::subscriptions::has_active_subscription_to_project(&state.db, user.id, item.project_id).await?;
573 - if !has_purchased && !has_subscription {
599 + let ctx = pricing::AccessContext {
600 + is_creator: false,
601 + has_purchased: db::transactions::has_purchased_item(&state.db, user.id, version.item_id).await?,
602 + has_active_subscription: db::subscriptions::has_active_subscription_to_item(&state.db, user.id, version.item_id).await?,
603 + };
604 + if !item_pricing.can_access(&ctx) {
574 605 return Err(AppError::Forbidden);
575 606 }
576 607 }
@@ -589,3 +620,128 @@ async fn version_download(
589 620 expires_in,
590 621 }))
591 622 }
623 +
624 + /// Generate a presigned URL for uploading a project image
625 + ///
626 + /// POST /api/projects/image/presign
627 + ///
628 + /// Requires authentication. User must own the project.
629 + #[tracing::instrument(skip_all, name = "storage::project_image_presign")]
630 + async fn project_image_presign(
631 + State(state): State<AppState>,
632 + AuthUser(user): AuthUser,
633 + Json(req): Json<ProjectImagePresignRequest>,
634 + ) -> Result<impl IntoResponse> {
635 + user.check_not_suspended()?;
636 + let s3 = state.require_s3()?;
637 +
638 + let file_type = FileType::Cover;
639 + S3Client::validate_content_type(file_type, &req.content_type)?;
640 + S3Client::validate_extension(file_type, &req.file_name)?;
641 +
642 + // Verify user owns the project
643 + let project = db::projects::get_project_by_id(&state.db, req.project_id)
644 + .await?
645 + .ok_or(AppError::NotFound)?;
646 +
647 + if project.user_id != user.id {
648 + return Err(AppError::Forbidden);
649 + }
650 +
651 + let s3_key = S3Client::generate_project_image_key(req.project_id, &req.file_name);
652 + let expires_in = 3600;
653 + let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE)).await?;
654 +
655 + Ok(Json(PresignUploadResponse {
656 + upload_url,
657 + s3_key,
658 + expires_in,
659 + }))
660 + }
661 +
662 + /// Confirm a project image upload, scan, store URL
663 + ///
664 + /// POST /api/projects/image/confirm
665 + ///
666 + /// Requires authentication. User must own the project.
667 + #[tracing::instrument(skip_all, name = "storage::project_image_confirm")]
668 + async fn project_image_confirm(
669 + State(state): State<AppState>,
670 + AuthUser(user): AuthUser,
671 + Json(req): Json<ProjectImageConfirmRequest>,
672 + ) -> Result<impl IntoResponse> {
673 + user.check_not_suspended()?;
674 + let s3 = state.require_s3()?;
675 +
676 + // Verify user owns the project
677 + let project = db::projects::get_project_by_id(&state.db, req.project_id)
678 + .await?
679 + .ok_or(AppError::NotFound)?;
680 +
681 + if project.user_id != user.id {
682 + return Err(AppError::Forbidden);
683 + }
684 +
685 + // Verify the object exists in S3
686 + if !s3.object_exists(&req.s3_key).await? {
687 + return Err(AppError::BadRequest(
688 + "Upload not found. Please try uploading again.".to_string(),
689 + ));
690 + }
691 +
692 + // Enforce file size limit
693 + let file_size_bytes = s3.object_size(&req.s3_key).await?.unwrap_or(0);
694 + if file_size_bytes as u64 > FileType::Cover.max_size() {
695 + s3.delete_object(&req.s3_key).await.ok();
696 + return Err(AppError::BadRequest(format!(
697 + "File exceeds maximum size of {} MB",
698 + FileType::Cover.max_size() / (1024 * 1024)
699 + )));
700 + }
701 +
702 + // Enforce tier-based limits
703 + let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await {
704 + Ok(max) => max,
705 + Err(e) => {
706 + s3.delete_object(&req.s3_key).await.ok();
707 + return Err(e);
708 + }
709 + };
710 +
711 + // Scan + classify
712 + let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, FileType::Cover, user.id).await?;
713 + // Project images don't have an item-level scan status, just log it
714 + if status == db::FileScanStatus::Quarantined {
715 + if let Some(err) = malware_err {
716 + return Err(err);
717 + }
718 + }
719 +
720 + // Build permanent URL
721 + let image_url = storage::build_project_image_url(
722 + s3.as_ref(),
723 + state.config.cdn_base_url.as_deref(),
724 + &req.s3_key,
725 + ).await?;
726 +
727 + // Store URL in database
728 + db::projects::update_project_image_url(&state.db, req.project_id, &image_url).await?;
729 +
730 + // Atomically increment storage
731 + db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await?;
732 +
733 + // Bump cache
734 + db::projects::bump_cache_generation(&state.db, req.project_id).await?;
735 +
736 + tracing::info!(
737 + "Project image confirmed: project={}, key={}, size={}",
738 + req.project_id,
739 + req.s3_key,
740 + file_size_bytes
741 + );
742 +
743 + Ok(Json(ProjectImageConfirmResponse {
744 + success: true,
745 + image_url,
746 + }))
747 + }
@@ -12,6 +12,7 @@ use crate::{
12 12 db::{self, CodePurpose, ItemId, PromoCodeId, SubscriptionTierId},
13 13 error::{AppError, Result},
14 14 helpers::{self, spawn_email},
15 + pricing::{self, CheckoutType},
15 16 AppState,
16 17 };
17 18
@@ -48,8 +49,9 @@ pub(super) async fn create_checkout(
48 49 return Err(AppError::BadRequest("This item is not available for purchase".to_string()));
49 50 }
50 51
51 - // Free items don't need checkout (unless PWYW with a chosen amount)
52 - if item.price_cents == 0 && !item.pwyw_enabled {
52 + // Free items don't need checkout
53 + let item_pricing = pricing::for_item(&item);
54 + if item_pricing.checkout_type() == CheckoutType::None {
53 55 return Err(AppError::BadRequest("This item is free".to_string()));
54 56 }
55 57
@@ -73,13 +75,11 @@ pub(super) async fn create_checkout(
73 75 .ok_or(AppError::NotFound)?;
74 76
75 77 // Determine base price: PWYW uses buyer's chosen amount, otherwise item price
76 - let base_price_cents = if item.pwyw_enabled {
78 + let base_price_cents = if item_pricing.checkout_type() == CheckoutType::PayWhatYouWant {
77 79 let amount = form.amount_cents
78 80 .ok_or_else(|| AppError::BadRequest("Amount is required for pay-what-you-want items".to_string()))?;
79 - let min = item.pwyw_min_cents.unwrap_or(0);
80 - if amount < min {
81 - return Err(AppError::BadRequest(format!("Amount must be at least ${:.2}", min as f64 / 100.0)));
82 - }
81 + item_pricing.validate_amount(amount)
82 + .map_err(AppError::BadRequest)?;
83 83 // PWYW with $0 is valid when min is $0 — falls through to the
84 84 // free-claim path at `final_price_cents == 0` below.
85 85 amount
@@ -318,6 +318,7 @@ pub(super) async fn create_checkout(
318 318 item_title: &item.title,
319 319 seller_username: &seller.username,
320 320 share_contact: form.share_contact,
321 + project_id: None,
321 322 },
322 323 ).await?;
323 324
@@ -444,7 +445,9 @@ pub(super) async fn create_subscription_checkout(
444 445 .ok_or_else(|| AppError::BadRequest("Subscription tier is not configured for payments".to_string()))?;
445 446
446 447 // Get the project and creator
447 - let project = db::projects::get_project_by_id(&state.db, tier.project_id)
448 + let tier_project_id = tier.project_id
449 + .ok_or_else(|| AppError::BadRequest("This tier is not a project subscription".to_string()))?;
450 + let project = db::projects::get_project_by_id(&state.db, tier_project_id)
448 451 .await?
449 452 .ok_or(AppError::NotFound)?;
450 453
@@ -466,7 +469,7 @@ pub(super) async fn create_subscription_checkout(
466 469 }
467 470
468 471 // Check if user already has an active subscription to this project
469 - if db::subscriptions::has_active_subscription_to_project(&state.db, user.id, tier.project_id).await? {
472 + if db::subscriptions::has_active_subscription_to_project(&state.db, user.id, tier_project_id).await? {
470 473 return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
471 474 }
472 475
@@ -504,7 +507,7 @@ pub(super) async fn create_subscription_checkout(
504 507 }
505 508
506 509 // Check project scope
507 - if let Some(scoped_project) = pc.project_id && tier.project_id != scoped_project {
510 + if let Some(scoped_project) = pc.project_id && tier_project_id != scoped_project {
508 511 return Err(AppError::BadRequest("This code is not valid for this project".to_string()));
509 512 }
510 513
@@ -523,7 +526,7 @@ pub(super) async fn create_subscription_checkout(
523 526 connected_account_id: stripe_account_id,
524 527 stripe_price_id,
525 528 subscriber_id: user.id,
526 - project_id: tier.project_id,
529 + project_id: tier_project_id,
527 530 tier_id: tier_uuid,
528 531 success_url: &success_url,
529 532 cancel_url: &cancel_url,
@@ -568,6 +571,158 @@ pub struct CancelQuery {
568 571 pub item_id: Option<String>,
569 572 }
570 573
574 + /// Form data for project checkout.
575 + #[derive(Debug, Deserialize)]
576 + pub(super) struct ProjectCheckoutForm {
577 + #[serde(default)]
578 + share_contact: bool,
579 + /// PWYW: buyer-chosen amount in cents.
580 + amount_cents: Option<i32>,
581 + }
582 +
583 + /// POST /stripe/checkout/project/{project_id} — Purchase project-level access.
584 + #[tracing::instrument(skip_all, name = "stripe::project_checkout")]
585 + pub(super) async fn create_project_checkout(
586 + State(state): State<AppState>,
587 + AuthUser(user): AuthUser,
588 + Path(project_id): Path<String>,
589 + Form(form): Form<ProjectCheckoutForm>,
590 + ) -> Result<Response> {
591 + user.check_not_suspended()?;
592 +
593 + let project_uuid: db::ProjectId = project_id
594 + .parse()
595 + .map_err(|_| AppError::NotFound)?;
596 +
597 + let project = db::projects::get_project_by_id(&state.db, project_uuid)
598 + .await?
599 + .ok_or(AppError::NotFound)?;
600 +
601 + if !project.is_public {
602 + return Err(AppError::BadRequest(
603 + "This project is not available for purchase".to_string(),
604 + ));
605 + }
606 +
607 + let project_pricing = pricing::for_project(&project);
608 + if project_pricing.checkout_type() == CheckoutType::None {
609 + return Err(AppError::BadRequest("This project is free".to_string()));
610 + }
611 +
612 + // Check if already purchased
613 + if db::transactions::has_purchased_project(&state.db, user.id, project_uuid).await? {
614 + return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
615 + }
616 +
617 + let seller_id = project.user_id;
618 + if user.id == seller_id {
619 + return Err(AppError::BadRequest(
620 + "You cannot purchase your own project".to_string(),
621 + ));
622 + }
623 +
624 + let seller = db::users::get_user_by_id(&state.db, seller_id)
625 + .await?
626 + .ok_or(AppError::NotFound)?;
627 +
628 + // Determine price
629 + let base_price_cents = if project_pricing.checkout_type() == CheckoutType::PayWhatYouWant {
630 + let amount = form.amount_cents.ok_or_else(|| {
631 + AppError::BadRequest("Amount is required for pay-what-you-want projects".to_string())
632 + })?;
633 + project_pricing
634 + .validate_amount(amount)
635 + .map_err(AppError::BadRequest)?;
636 + amount
637 + } else {
638 + project_pricing.price_cents()
639 + };
640 +
641 + // If price is $0 (PWYW with $0 min), record a free claim
642 + if base_price_cents == 0 {
643 + // Record project purchase as a transaction with project_id set, no item_id
644 + let mut tx = state.db.begin().await?;
645 + sqlx::query(
646 + r#"
647 + INSERT INTO transactions (buyer_id, seller_id, project_id, amount_cents, platform_fee_cents,
648 + status, completed_at, item_title, seller_username, share_contact)
649 + VALUES ($1, $2, $3, 0, 0, 'completed', NOW(), $4, $5, $6)
650 + ON CONFLICT DO NOTHING
651 + "#,
652 + )
653 + .bind(user.id)
654 + .bind(seller_id)
655 + .bind(project_uuid)
656 + .bind(&project.title)
657 + .bind(&seller.username)
658 + .bind(form.share_contact)
659 + .execute(&mut *tx)
660 + .await?;
661 + tx.commit().await?;
662 +
663 + return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
664 + }
665 +
666 + // Stripe checkout
667 + let stripe_account_id = seller
668 + .stripe_account_id
669 + .as_ref()
670 + .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
671 +
672 + if !seller.stripe_charges_enabled {
673 + return Err(AppError::BadRequest(
674 + "Creator's payment account is not ready".to_string(),
675 + ));
676 + }
677 +
678 + let stripe = state
679 + .stripe
680 + .as_ref()
681 + .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
682 +
683 + let success_url = format!(
684 + "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
685 + state.config.host_url
686 + );
687 + let cancel_url = format!("{}/p/{}", state.config.host_url, project.slug);
688 +
689 + let checkout_params = crate::payments::CheckoutParams {
690 + connected_account_id: stripe_account_id,
691 + item_title: &project.title,
692 + amount_cents: base_price_cents as i64,
693 + buyer_id: user.id,
694 + seller_id,
695 + item_id: db::ItemId::nil(), // no item for project purchases
696 + success_url: &success_url,
697 + cancel_url: &cancel_url,
698 + promo_code_id: None,
699 + };
700 + let session = stripe.create_checkout_session(&checkout_params).await?;
701 +
702 + db::transactions::create_transaction(
703 + &state.db,
704 + &db::transactions::CreateTransactionParams {
705 + buyer_id: user.id,
706 + seller_id,
707 + item_id: db::ItemId::nil(),
708 + amount_cents: base_price_cents,
709 + platform_fee_cents: 0,
710 + stripe_checkout_session_id: session.id.as_ref(),
711 + item_title: &project.title,
712 + seller_username: &seller.username,
713 + share_contact: form.share_contact,
714 + project_id: Some(project_uuid),
715 + },
716 + )
717 + .await?;
718 +
719 + let checkout_url = session
720 + .url
721 + .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
722 +
723 + Ok(Redirect::to(&checkout_url).into_response())
724 + }
725 +
571 726 /// GET /stripe/cancel - Handle cancelled payment
572 727 #[tracing::instrument(skip_all, name = "stripe_checkout::checkout_cancel")]
573 728 pub(super) async fn checkout_cancel(
@@ -24,6 +24,7 @@ pub fn stripe_routes() -> Router<AppState> {
24 24 .route("/stripe/fan-plus", post(checkout::create_fan_plus_checkout))
25 25 .route("/stripe/creator-tier", post(checkout::create_creator_tier_checkout))
26 26 .route("/stripe/checkout/{item_id}", post(checkout::create_checkout))
27 + .route("/stripe/checkout/project/{project_id}", post(checkout::create_project_checkout))
27 28 .route("/stripe/subscribe/{tier_id}", post(checkout::create_subscription_checkout))
28 29 .route("/stripe/success", get(checkout::checkout_success))
29 30 .route("/stripe/cancel", get(checkout::checkout_cancel))
@@ -131,7 +131,7 @@ pub(super) async fn handle_subscription_deleted(
131 131 if let (Ok(Some(subscriber)), Ok(Some(tier)), Ok(Some(project))) = (
132 132 db::users::get_user_by_id(&state.db, db_sub.subscriber_id).await,
133 133 db::subscriptions::get_subscription_tier_by_id(&state.db, db_sub.tier_id).await,
134 - db::projects::get_project_by_id(&state.db, db_sub.project_id).await,
134 + async { match db_sub.project_id { Some(pid) => db::projects::get_project_by_id(&state.db, pid).await, None => Ok(None) } }.await,
135 135 ) {
136 136 let sub_email = subscriber.email.clone();
137 137 let sub_name = subscriber.display_name.clone();
@@ -10,7 +10,7 @@ use std::str::FromStr;
10 10 use std::time::Duration;
11 11
12 12 use crate::config::StorageConfig;
13 - use crate::db::{ItemId, UserId};
13 + use crate::db::{ItemId, ProjectId, UserId};
14 14 use crate::error::{AppError, Result};
15 15
16 16 /// Allowed audio file extensions and their MIME types
@@ -215,6 +215,17 @@ impl S3Client {
215 215 format!("{}/insertions/{}", user_id, safe_filename)
216 216 }
217 217
218 + /// Generate an S3 key for a project image (logo/avatar).
219 + /// Format: projects/{project_id}/image/{sanitized_filename}
220 + pub fn generate_project_image_key(project_id: ProjectId, filename: &str) -> String {
221 + let safe_filename: String = filename
222 + .chars()
223 + .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
224 + .collect();
225 +
226 + format!("projects/{}/image/{}", project_id, safe_filename)
227 + }
228 +
218 229 /// Validate content type for the given file type
219 230 pub fn validate_content_type(file_type: FileType, content_type: &str) -> Result<()> {
220 231 let is_valid = if file_type == FileType::Download {
@@ -465,6 +476,19 @@ impl S3Client {
465 476
466 477 }
467 478
479 + /// Build a permanent URL for a project image.
480 + /// CDN configured: permanent CDN URL. No CDN: 24-hour presigned S3 URL.
481 + pub async fn build_project_image_url(
482 + s3: &dyn StorageBackend,
483 + cdn_base_url: Option<&str>,
484 + s3_key: &str,
485 + ) -> Result<String> {
486 + if let Some(cdn_base) = cdn_base_url {
487 + return Ok(format!("{}/{}", cdn_base, s3_key));
488 + }
489 + s3.presign_download(s3_key, Some(86400)).await
490 + }
491 +
468 492 #[async_trait::async_trait]
469 493 impl StorageBackend for S3Client {
470 494 async fn presign_upload(&self, s3_key: &str, content_type: &str, expiry_secs: Option<u64>, cache_control: Option<&str>) -> Result<String> {
@@ -748,6 +772,20 @@ mod tests {
748 772 }
749 773
750 774 #[test]
775 + fn generate_project_image_key_format() {
776 + let project_id: ProjectId = "33333333-3333-3333-3333-333333333333".parse().unwrap();
777 + let key = S3Client::generate_project_image_key(project_id, "logo.png");
778 + assert_eq!(key, "projects/33333333-3333-3333-3333-333333333333/image/logo.png");
779 + }
780 +
781 + #[test]
782 + fn generate_project_image_key_sanitizes() {
783 + let project_id: ProjectId = "33333333-3333-3333-3333-333333333333".parse().unwrap();
784 + let key = S3Client::generate_project_image_key(project_id, "my logo (v2).png");
785 + assert_eq!(key, "projects/33333333-3333-3333-3333-333333333333/image/mylogov2.png");
786 + }
787 +
788 + #[test]
751 789 fn cdn_url_from_s3_key() {
752 790 let cdn_base = "https://cdn.makenot.work";
753 791 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
@@ -238,6 +238,7 @@ pub struct WizardProjectBasicsTemplate {
238 238 pub struct WizardProjectAppearanceTemplate {
239 239 pub nav: Vec<StepNavItem>,
240 240 pub slug: String,
241 + pub project_id: String,
241 242 pub cover_image_url: Option<String>,
242 243 pub project_title: String,
243 244 }
@@ -249,6 +250,12 @@ pub struct WizardProjectMonetizationTemplate {
249 250 pub slug: String,
250 251 pub tiers: Vec<WizardTierRow>,
251 252 pub stripe_connected: bool,
253 + /// Current pricing model string value (free, buy_once, pwyw, subscription).
254 + pub pricing_model: String,
255 + /// Current fixed price in dollars (for buy_once).
256 + pub price_dollars: String,
257 + /// Current PWYW minimum in dollars.
258 + pub pwyw_min_dollars: String,
252 259 }
253 260
254 261 #[derive(Template)]
@@ -275,6 +282,8 @@ pub struct WizardProjectPreviewTemplate {
275 282 pub item_count: u32,
276 283 pub tiers: Vec<WizardTierRow>,
277 284 pub is_public: bool,
285 + /// Human-readable pricing display (e.g. "Free", "$9.99", "PWYW").
286 + pub pricing_display: String,
278 287 }
279 288
280 289 // --- Item step partials ---
@@ -60,6 +60,7 @@ impl_into_response!(
60 60 ResetPasswordTemplate,
61 61 UserTemplate,
62 62 ProjectTemplate,
63 + ProjectPaywallTemplate,
63 64 ItemTemplate,
64 65 TextReaderTemplate,
65 66 AudioPlayerTemplate,