diff --git a/flow.toml b/flow.toml index e66da657..a95f64c3 100644 --- a/flow.toml +++ b/flow.toml @@ -1597,3 +1597,242 @@ echo "=== Migration Complete ===" description = "Safe interactive migration for production database." dependencies = ["node", "pnpm"] shortcuts = ["ms", "safe"] + +[[tasks]] +name = "stripe-setup" +interactive = true +command = """ +set -euo pipefail + +echo "=== Stripe Payments Setup ===" +echo "" +echo "This will configure Stripe for production payments." +echo "You'll need:" +echo " - Stripe account (https://dashboard.stripe.com)" +echo " - Secret key (sk_live_... or sk_test_...)" +echo " - Webhook signing secret (whsec_...)" +echo " - Price ID for subscription (price_...)" +echo "" + +cd packages/web + +# Check if wrangler is logged in +if ! pnpm exec wrangler whoami >/dev/null 2>&1; then + echo "Not logged into Cloudflare. Running wrangler login..." + pnpm exec wrangler login +fi + +# Get existing secrets +SECRETS_OUTPUT=$(pnpm exec wrangler secret list 2>&1 || echo "") + +is_secret_set() { + echo "$SECRETS_OUTPUT" | grep -q "$1" +} + +echo "=== Current Stripe Configuration ===" +echo "" +echo "Checking existing secrets..." + +MISSING=0 + +if is_secret_set "STRIPE_SECRET_KEY"; then + echo " ✓ STRIPE_SECRET_KEY is set" +else + echo " ✗ STRIPE_SECRET_KEY is NOT set" + MISSING=1 +fi + +if is_secret_set "STRIPE_WEBHOOK_SECRET"; then + echo " ✓ STRIPE_WEBHOOK_SECRET is set" +else + echo " ✗ STRIPE_WEBHOOK_SECRET is NOT set" + MISSING=1 +fi + +if is_secret_set "STRIPE_ARCHIVE_PRICE_ID"; then + echo " ✓ STRIPE_ARCHIVE_PRICE_ID is set" +else + echo " ✗ STRIPE_ARCHIVE_PRICE_ID is NOT set" + MISSING=1 +fi + +if [ "$MISSING" -eq 0 ]; then + echo "" + echo "All Stripe secrets are configured!" + echo "" + read -p "Do you want to update any secrets? (y/N): " UPDATE + if [ "$UPDATE" != "y" ] && [ "$UPDATE" != "Y" ]; then + echo "" + echo "=== Stripe Endpoints ===" + echo "" + echo "Your Stripe integration is ready:" + echo " - Checkout: POST /api/stripe/checkout" + echo " - Billing portal: POST /api/stripe/portal" + echo " - Billing status: GET /api/stripe/billing" + echo " - Webhooks: POST /api/stripe/webhooks" + echo "" + echo "Webhook URL for Stripe Dashboard:" + echo " https://linsa.io/api/stripe/webhooks" + exit 0 + fi +fi + +echo "" +echo "=== Stripe Dashboard Setup ===" +echo "" +echo "Before continuing, ensure you have:" +echo "" +echo "1. Created a Product in Stripe Dashboard:" +echo " https://dashboard.stripe.com/products/create" +echo " - Name: 'Archive Pro' (or similar)" +echo ' - Add a recurring price (e.g., $5/month)' +echo "" +echo "2. Created a Webhook endpoint:" +echo " https://dashboard.stripe.com/webhooks/create" +echo " - URL: https://linsa.io/api/stripe/webhooks" +echo " - Events to listen for:" +echo " • checkout.session.completed" +echo " • customer.subscription.created" +echo " • customer.subscription.updated" +echo " • customer.subscription.deleted" +echo " • invoice.payment_succeeded" +echo " • invoice.payment_failed" +echo "" +read -p "Press Enter when ready to continue..." + +# STRIPE_SECRET_KEY +echo "" +echo "=== STRIPE_SECRET_KEY ===" +echo "Find this at: https://dashboard.stripe.com/apikeys" +echo "Use sk_test_... for testing, sk_live_... for production" +echo "" +if is_secret_set "STRIPE_SECRET_KEY"; then + read -p "Already set. Enter new value to update (or press Enter to skip): " STRIPE_SECRET_KEY +else + read -p "Enter STRIPE_SECRET_KEY: " STRIPE_SECRET_KEY +fi +if [ -n "$STRIPE_SECRET_KEY" ]; then + echo "$STRIPE_SECRET_KEY" | pnpm exec wrangler secret put STRIPE_SECRET_KEY + echo "✓ STRIPE_SECRET_KEY set" +fi + +# STRIPE_WEBHOOK_SECRET +echo "" +echo "=== STRIPE_WEBHOOK_SECRET ===" +echo "After creating webhook, click on it to see the signing secret (whsec_...)" +echo "" +if is_secret_set "STRIPE_WEBHOOK_SECRET"; then + read -p "Already set. Enter new value to update (or press Enter to skip): " STRIPE_WEBHOOK_SECRET +else + read -p "Enter STRIPE_WEBHOOK_SECRET: " STRIPE_WEBHOOK_SECRET +fi +if [ -n "$STRIPE_WEBHOOK_SECRET" ]; then + echo "$STRIPE_WEBHOOK_SECRET" | pnpm exec wrangler secret put STRIPE_WEBHOOK_SECRET + echo "✓ STRIPE_WEBHOOK_SECRET set" +fi + +# STRIPE_ARCHIVE_PRICE_ID +echo "" +echo "=== STRIPE_ARCHIVE_PRICE_ID ===" +echo "Find this in your Product page -> Pricing section (starts with price_...)" +echo "" +if is_secret_set "STRIPE_ARCHIVE_PRICE_ID"; then + read -p "Already set. Enter new value to update (or press Enter to skip): " STRIPE_ARCHIVE_PRICE_ID +else + read -p "Enter STRIPE_ARCHIVE_PRICE_ID: " STRIPE_ARCHIVE_PRICE_ID +fi +if [ -n "$STRIPE_ARCHIVE_PRICE_ID" ]; then + echo "$STRIPE_ARCHIVE_PRICE_ID" | pnpm exec wrangler secret put STRIPE_ARCHIVE_PRICE_ID + echo "✓ STRIPE_ARCHIVE_PRICE_ID set" +fi + +echo "" +echo "=== Verification ===" +echo "" +SECRETS_OUTPUT=$(pnpm exec wrangler secret list 2>&1 || echo "") + +check_final() { + if echo "$SECRETS_OUTPUT" | grep -q "$1"; then + echo " ✓ $1" + else + echo " ✗ $1 (MISSING)" + fi +} + +check_final "STRIPE_SECRET_KEY" +check_final "STRIPE_WEBHOOK_SECRET" +check_final "STRIPE_ARCHIVE_PRICE_ID" + +echo "" +echo "=== Setup Complete ===" +echo "" +echo "Your Stripe integration endpoints:" +echo " - Checkout: POST /api/stripe/checkout" +echo " - Portal: POST /api/stripe/portal" +echo " - Billing: GET /api/stripe/billing" +echo " - Webhooks: POST /api/stripe/webhooks" +echo "" +echo "Webhook URL (add to Stripe Dashboard):" +echo " https://linsa.io/api/stripe/webhooks" +echo "" +echo "To test:" +echo " 1. Visit https://linsa.io/archive" +echo " 2. Click 'Subscribe' to start checkout" +echo " 3. Use test card: 4242 4242 4242 4242" +echo "" +echo "Run 'f deploy' to deploy with new secrets." +""" +description = "Configure Stripe payments for production: API keys, webhook, and price ID." +dependencies = ["node", "pnpm"] +shortcuts = ["stripe", "pay"] + +[[tasks]] +name = "stripe-check" +command = """ +set -euo pipefail + +echo "=== Stripe Configuration Check ===" +echo "" + +cd packages/web + +# Get secrets +SECRETS_OUTPUT=$(pnpm exec wrangler secret list 2>&1 || echo "") + +check() { + if echo "$SECRETS_OUTPUT" | grep -q "$1"; then + echo " ✓ $1" + return 0 + else + echo " ✗ $1 (MISSING)" + return 1 + fi +} + +MISSING=0 + +echo "Stripe Secrets:" +check "STRIPE_SECRET_KEY" || MISSING=1 +check "STRIPE_WEBHOOK_SECRET" || MISSING=1 +check "STRIPE_ARCHIVE_PRICE_ID" || MISSING=1 + +echo "" +echo "API Endpoints:" +echo " - Checkout: POST https://linsa.io/api/stripe/checkout" +echo " - Portal: POST https://linsa.io/api/stripe/portal" +echo " - Billing: GET https://linsa.io/api/stripe/billing" +echo " - Webhooks: POST https://linsa.io/api/stripe/webhooks" + +echo "" +if [ "$MISSING" -eq 1 ]; then + echo "⚠ Some secrets missing. Run 'f stripe-setup' to configure." +else + echo "✓ All Stripe secrets configured!" + echo "" + echo "To test checkout flow:" + echo " curl -X POST https://linsa.io/api/stripe/checkout -H 'Cookie: '" +fi +""" +description = "Check Stripe configuration status." +dependencies = ["node", "pnpm"] +shortcuts = ["stc", "stripe-check"] diff --git a/packages/web/src/db/schema.ts b/packages/web/src/db/schema.ts index 68df35f4..9b2bc392 100644 --- a/packages/web/src/db/schema.ts +++ b/packages/web/src/db/schema.ts @@ -436,3 +436,132 @@ export type ContextItem = z.infer export type ThreadContextItem = z.infer export type BrowserSession = z.infer export type BrowserSessionTab = z.infer + +// ============================================================================= +// Creator Economy - Subscriptions & Sales +// ============================================================================= + +// Stripe Connect accounts for creators to receive payouts +export const stripe_connect_accounts = pgTable("stripe_connect_accounts", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + user_id: text("user_id") + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + stripe_account_id: text("stripe_account_id").notNull().unique(), + onboarding_complete: boolean("onboarding_complete").notNull().default(false), + payouts_enabled: boolean("payouts_enabled").notNull().default(false), + created_at: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updated_at: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}) + +// Subscription tiers that creators set up +export const creator_tiers = pgTable("creator_tiers", { + id: uuid("id").primaryKey().defaultRandom(), + creator_id: text("creator_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), // e.g., "Basic", "Pro", "VIP" + description: text("description"), + price_cents: integer("price_cents").notNull(), // Price in cents (e.g., 500 = $5) + currency: varchar("currency", { length: 3 }).notNull().default("usd"), + benefits: text("benefits"), // JSON array of benefits or plain text + stripe_price_id: text("stripe_price_id"), // Created when tier is made + is_active: boolean("is_active").notNull().default(true), + sort_order: integer("sort_order").notNull().default(0), + created_at: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updated_at: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}) + +// Users subscribing to creators +export const creator_subscriptions = pgTable("creator_subscriptions", { + id: uuid("id").primaryKey().defaultRandom(), + subscriber_id: text("subscriber_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + creator_id: text("creator_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tier_id: uuid("tier_id") + .notNull() + .references(() => creator_tiers.id, { onDelete: "cascade" }), + stripe_subscription_id: text("stripe_subscription_id").unique(), + status: varchar("status", { length: 32 }).notNull().default("active"), // active, canceled, past_due + current_period_start: timestamp("current_period_start", { withTimezone: true }), + current_period_end: timestamp("current_period_end", { withTimezone: true }), + cancel_at_period_end: boolean("cancel_at_period_end").default(false), + created_at: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updated_at: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}) + +// One-time products/items creators can sell +export const creator_products = pgTable("creator_products", { + id: uuid("id").primaryKey().defaultRandom(), + creator_id: text("creator_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description"), + price_cents: integer("price_cents").notNull(), + currency: varchar("currency", { length: 3 }).notNull().default("usd"), + type: varchar("type", { length: 32 }).notNull().default("digital"), // digital, physical, service + // For digital products + content_url: text("content_url"), // URL to downloadable content + // For display + image_url: text("image_url"), + stripe_price_id: text("stripe_price_id"), + is_active: boolean("is_active").notNull().default(true), + stock: integer("stock"), // null = unlimited + created_at: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updated_at: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}) + +// Purchase records for one-time products +export const creator_purchases = pgTable("creator_purchases", { + id: uuid("id").primaryKey().defaultRandom(), + buyer_id: text("buyer_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + product_id: uuid("product_id") + .notNull() + .references(() => creator_products.id, { onDelete: "cascade" }), + creator_id: text("creator_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + stripe_payment_intent_id: text("stripe_payment_intent_id"), + amount_cents: integer("amount_cents").notNull(), + currency: varchar("currency", { length: 3 }).notNull().default("usd"), + status: varchar("status", { length: 32 }).notNull().default("completed"), // pending, completed, refunded + created_at: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}) + +// Schema exports for creator economy +export const selectStripeConnectAccountSchema = createSelectSchema(stripe_connect_accounts) +export const selectCreatorTierSchema = createSelectSchema(creator_tiers) +export const selectCreatorSubscriptionSchema = createSelectSchema(creator_subscriptions) +export const selectCreatorProductSchema = createSelectSchema(creator_products) +export const selectCreatorPurchaseSchema = createSelectSchema(creator_purchases) + +export type StripeConnectAccount = z.infer +export type CreatorTier = z.infer +export type CreatorSubscription = z.infer +export type CreatorProduct = z.infer +export type CreatorPurchase = z.infer