diff --git a/migrations/20260211100000_add_pricing_to_stack_template.down.sql b/migrations/20260211100000_add_pricing_to_stack_template.down.sql new file mode 100644 index 0000000..72351e9 --- /dev/null +++ b/migrations/20260211100000_add_pricing_to_stack_template.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE stack_template DROP COLUMN IF EXISTS price; +ALTER TABLE stack_template DROP COLUMN IF EXISTS billing_cycle; +ALTER TABLE stack_template DROP COLUMN IF EXISTS currency; diff --git a/migrations/20260211100000_add_pricing_to_stack_template.up.sql b/migrations/20260211100000_add_pricing_to_stack_template.up.sql new file mode 100644 index 0000000..7804428 --- /dev/null +++ b/migrations/20260211100000_add_pricing_to_stack_template.up.sql @@ -0,0 +1,5 @@ +-- Add pricing columns to stack_template +-- Creator sets price during template submission; webhook sends it to User Service products table +ALTER TABLE stack_template ADD COLUMN IF NOT EXISTS price DOUBLE PRECISION DEFAULT 0; +ALTER TABLE stack_template ADD COLUMN IF NOT EXISTS billing_cycle VARCHAR(50) DEFAULT 'free'; +ALTER TABLE stack_template ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'USD'; diff --git a/src/connectors/user_service/marketplace_webhook.rs b/src/connectors/user_service/marketplace_webhook.rs index 780f23c..d1ac264 100644 --- a/src/connectors/user_service/marketplace_webhook.rs +++ b/src/connectors/user_service/marketplace_webhook.rs @@ -36,10 +36,10 @@ pub struct MarketplaceWebhookPayload { /// Template description pub description: Option, - /// Price in specified currency (if not free) + /// Price in specified currency (set by creator during submission) pub price: Option, - /// Billing cycle: "one_time" or "monthly"/"yearly" + /// Billing cycle: "free", "one_time", or "subscription" #[serde(skip_serializing_if = "Option::is_none")] pub billing_cycle: Option, @@ -50,7 +50,7 @@ pub struct MarketplaceWebhookPayload { /// Creator/vendor user ID from Stacker pub vendor_user_id: Option, - /// Vendor name or email + /// Vendor display name (creator_name from template) pub vendor_name: Option, /// Category of template @@ -60,6 +60,34 @@ pub struct MarketplaceWebhookPayload { /// Tags/keywords #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option, + + /// Full description (long_description from template) + #[serde(skip_serializing_if = "Option::is_none")] + pub long_description: Option, + + /// Tech stack metadata (JSON object of services/apps) + #[serde(skip_serializing_if = "Option::is_none")] + pub tech_stack: Option, + + /// Creator display name + #[serde(skip_serializing_if = "Option::is_none")] + pub creator_name: Option, + + /// Total deployments count + #[serde(skip_serializing_if = "Option::is_none")] + pub deploy_count: Option, + + /// Total views count + #[serde(skip_serializing_if = "Option::is_none")] + pub view_count: Option, + + /// When the template was approved + #[serde(skip_serializing_if = "Option::is_none")] + pub approved_at: Option, + + /// Minimum plan required to deploy + #[serde(skip_serializing_if = "Option::is_none")] + pub required_plan_name: Option, } /// Response from User Service webhook endpoint @@ -159,17 +187,28 @@ impl MarketplaceWebhookSender { .short_description .clone() .or_else(|| template.long_description.clone()), - price: None, // Pricing not stored in Stacker (User Service responsibility) - billing_cycle: None, - currency: None, + price: template.price, + billing_cycle: template.billing_cycle.clone(), + currency: template.currency.clone(), vendor_user_id: Some(vendor_id.to_string()), - vendor_name: Some(vendor_id.to_string()), + vendor_name: template.creator_name.clone(), category: category_code, tags: if let serde_json::Value::Array(_) = template.tags { Some(template.tags.clone()) } else { None }, + long_description: template.long_description.clone(), + tech_stack: if template.tech_stack != serde_json::json!({}) { + Some(template.tech_stack.clone()) + } else { + None + }, + creator_name: template.creator_name.clone(), + deploy_count: template.deploy_count, + view_count: template.view_count, + approved_at: template.approved_at.map(|dt| dt.to_rfc3339()), + required_plan_name: template.required_plan_name.clone(), }; self.send_webhook(&payload).instrument(span).await @@ -198,17 +237,28 @@ impl MarketplaceWebhookSender { .short_description .clone() .or_else(|| template.long_description.clone()), - price: None, - billing_cycle: None, - currency: None, + price: template.price, + billing_cycle: template.billing_cycle.clone(), + currency: template.currency.clone(), vendor_user_id: Some(vendor_id.to_string()), - vendor_name: Some(vendor_id.to_string()), + vendor_name: template.creator_name.clone(), category: category_code, tags: if let serde_json::Value::Array(_) = template.tags { Some(template.tags.clone()) } else { None }, + long_description: template.long_description.clone(), + tech_stack: if template.tech_stack != serde_json::json!({}) { + Some(template.tech_stack.clone()) + } else { + None + }, + creator_name: template.creator_name.clone(), + deploy_count: template.deploy_count, + view_count: template.view_count, + approved_at: template.approved_at.map(|dt| dt.to_rfc3339()), + required_plan_name: template.required_plan_name.clone(), }; self.send_webhook(&payload).instrument(span).await @@ -239,6 +289,13 @@ impl MarketplaceWebhookSender { vendor_name: None, category: None, tags: None, + long_description: None, + tech_stack: None, + creator_name: None, + deploy_count: None, + view_count: None, + approved_at: None, + required_plan_name: None, }; self.send_webhook(&payload).instrument(span).await diff --git a/src/db/marketplace.rs b/src/db/marketplace.rs index 7f0fabc..30d7ee0 100644 --- a/src/db/marketplace.rs +++ b/src/db/marketplace.rs @@ -26,6 +26,9 @@ pub async fn list_approved( t.view_count, t.deploy_count, t.required_plan_name, + t.price, + t.billing_cycle, + t.currency, t.created_at, t.updated_at, t.approved_at @@ -107,6 +110,9 @@ pub async fn get_by_slug_and_user( t.view_count, t.deploy_count, t.required_plan_name, + t.price, + t.billing_cycle, + t.currency, t.created_at, t.updated_at, t.approved_at @@ -150,6 +156,9 @@ pub async fn get_by_slug_with_latest( t.view_count, t.deploy_count, t.required_plan_name, + t.price, + t.billing_cycle, + t.currency, t.created_at, t.updated_at, t.approved_at @@ -218,7 +227,10 @@ pub async fn get_by_id( t.created_at, t.updated_at, t.approved_at, - t.required_plan_name + t.required_plan_name, + t.price, + t.billing_cycle, + t.currency FROM stack_template t LEFT JOIN stack_category c ON t.category_id = c.id WHERE t.id = $1"#, @@ -246,16 +258,21 @@ pub async fn create_draft( category_code: Option<&str>, tags: serde_json::Value, tech_stack: serde_json::Value, + price: f64, + billing_cycle: &str, + currency: &str, ) -> Result { let query_span = tracing::info_span!("marketplace_create_draft", slug = %slug); + let price_f64 = price; + let rec = sqlx::query_as!( StackTemplate, r#"INSERT INTO stack_template ( creator_user_id, creator_name, name, slug, short_description, long_description, category_id, - tags, tech_stack, status - ) VALUES ($1,$2,$3,$4,$5,$6,(SELECT id FROM stack_category WHERE name = $7),$8,$9,'draft') + tags, tech_stack, status, price, billing_cycle, currency + ) VALUES ($1,$2,$3,$4,$5,$6,(SELECT id FROM stack_category WHERE name = $7),$8,$9,'draft',$10,$11,$12) RETURNING id, creator_user_id, @@ -273,6 +290,9 @@ pub async fn create_draft( view_count, deploy_count, required_plan_name, + price, + billing_cycle, + currency, created_at, updated_at, approved_at @@ -285,7 +305,10 @@ pub async fn create_draft( long_description, category_code, tags, - tech_stack + tech_stack, + price_f64, + billing_cycle, + currency ) .fetch_one(pool) .instrument(query_span) @@ -370,6 +393,9 @@ pub async fn update_metadata( category_code: Option<&str>, tags: Option, tech_stack: Option, + price: Option, + billing_cycle: Option<&str>, + currency: Option<&str>, ) -> Result { let query_span = tracing::info_span!("marketplace_update_metadata", template_id = %template_id); @@ -397,7 +423,10 @@ pub async fn update_metadata( long_description = COALESCE($4, long_description), category_id = COALESCE((SELECT id FROM stack_category WHERE name = $5), category_id), tags = COALESCE($6, tags), - tech_stack = COALESCE($7, tech_stack) + tech_stack = COALESCE($7, tech_stack), + price = COALESCE($8, price), + billing_cycle = COALESCE($9, billing_cycle), + currency = COALESCE($10, currency) WHERE id = $1::uuid"#, template_id, name, @@ -405,7 +434,10 @@ pub async fn update_metadata( long_description, category_code, tags, - tech_stack + tech_stack, + price, + billing_cycle, + currency ) .execute(pool) .instrument(query_span) @@ -538,6 +570,9 @@ pub async fn list_mine(pool: &PgPool, user_id: &str) -> Result Result, S t.view_count, t.deploy_count, t.required_plan_name, + t.price, + t.billing_cycle, + t.currency, t.created_at, t.updated_at, t.approved_at diff --git a/src/models/marketplace.rs b/src/models/marketplace.rs index 28b9c0f..e6a35ab 100644 --- a/src/models/marketplace.rs +++ b/src/models/marketplace.rs @@ -28,6 +28,9 @@ pub struct StackTemplate { pub view_count: Option, pub deploy_count: Option, pub required_plan_name: Option, + pub price: Option, + pub billing_cycle: Option, + pub currency: Option, pub created_at: Option>, pub updated_at: Option>, pub approved_at: Option>, diff --git a/src/routes/marketplace/creator.rs b/src/routes/marketplace/creator.rs index 2593595..3fcfad2 100644 --- a/src/routes/marketplace/creator.rs +++ b/src/routes/marketplace/creator.rs @@ -18,6 +18,12 @@ pub struct CreateTemplateRequest { pub version: Option, pub stack_definition: Option, pub definition_format: Option, + /// Pricing: "free", "one_time", or "subscription" + pub plan_type: Option, + /// Price amount (e.g. 9.99). Ignored when plan_type is "free" + pub price: Option, + /// ISO 4217 currency code, default "USD" + pub currency: Option, } #[tracing::instrument(name = "Create draft template")] @@ -34,6 +40,11 @@ pub async fn create_handler( let creator_name = format!("{} {}", user.first_name, user.last_name); + // Normalize pricing: plan_type "free" forces price to 0 + let billing_cycle = req.plan_type.unwrap_or_else(|| "free".to_string()); + let price = if billing_cycle == "free" { 0.0 } else { req.price.unwrap_or(0.0) }; + let currency = req.currency.unwrap_or_else(|| "USD".to_string()); + // Check if template with this slug already exists for this user let existing = db::marketplace::get_by_slug_and_user(pg_pool.get_ref(), &req.slug, &user.id) .await @@ -51,6 +62,9 @@ pub async fn create_handler( req.category_code.as_deref(), Some(tags.clone()), Some(tech_stack.clone()), + Some(price), + Some(billing_cycle.as_str()), + Some(currency.as_str()), ) .await .map_err(|err| JsonResponse::::build().internal_server_error(err))?; @@ -83,6 +97,9 @@ pub async fn create_handler( req.category_code.as_deref(), tags, tech_stack, + price, + &billing_cycle, + ¤cy, ) .await .map_err(|err| { @@ -121,6 +138,9 @@ pub struct UpdateTemplateRequest { pub category_code: Option, pub tags: Option, pub tech_stack: Option, + pub plan_type: Option, + pub price: Option, + pub currency: Option, } #[tracing::instrument(name = "Update template metadata")] @@ -158,6 +178,9 @@ pub async fn update_handler( req.category_code.as_deref(), req.tags, req.tech_stack, + req.price, + req.plan_type.as_deref(), + req.currency.as_deref(), ) .await .map_err(|err| JsonResponse::::build().bad_request(err))?;