From 49f35f88cbab9e23214626b8b4ac02a75ee05bb3 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 21 Oct 2025 16:56:42 +1100 Subject: [PATCH 01/76] Initial draft of db migration --- .../20251020160838_paid-content-stripe.js | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 apps/backend/migrations/20251020160838_paid-content-stripe.js diff --git a/apps/backend/migrations/20251020160838_paid-content-stripe.js b/apps/backend/migrations/20251020160838_paid-content-stripe.js new file mode 100644 index 0000000000..0498f96a98 --- /dev/null +++ b/apps/backend/migrations/20251020160838_paid-content-stripe.js @@ -0,0 +1,93 @@ +exports.up = function (knex) { + return knex.schema + // First, drop the existing foreign key from users to stripe_accounts + .table('users', function (table) { + table.dropForeign(['stripe_account_id']) + table.dropColumn('stripe_account_id') + }) + // Add Stripe columns to groups table + .table('groups', function (table) { + table.bigInteger('stripe_account_id').unsigned().references('id').inTable('stripe_accounts') + table.boolean('stripe_charges_enabled').defaultTo(false) + table.boolean('stripe_payouts_enabled').defaultTo(false) + table.boolean('stripe_details_submitted').defaultTo(false) + }) + // Create stripe_products table to track products associated with groups + .createTable('stripe_products', function (table) { + table.bigIncrements('id').primary() + table.bigInteger('group_id').unsigned().notNullable().references('id').inTable('groups').onDelete('CASCADE') + table.string('stripe_product_id', 255).notNullable() + table.string('stripe_price_id', 255).notNullable() + table.string('name', 255).notNullable() + table.text('description') + table.integer('price_in_cents').notNullable() + table.string('currency', 3).notNullable().defaultTo('usd') + table.boolean('active').defaultTo(true) + table.bigInteger('track_id').unsigned().references('id').inTable('tracks') + table.timestamps(true, true) + + table.index(['group_id']) + table.index(['stripe_product_id']) + }) + // Create content_access table to track all content access grants (paid and free) + // This supports both Stripe purchases and admin-granted free access + .createTable('content_access', function (table) { + table.bigIncrements('id').primary() + table.bigInteger('user_id').unsigned().notNullable().references('id').inTable('users') + table.bigInteger('group_id').unsigned().notNullable().references('id').inTable('groups') + table.bigInteger('product_id').unsigned().references('id').inTable('stripe_products') + table.bigInteger('track_id').unsigned().references('id').inTable('tracks') + table.integer('role_id').unsigned().references('id').inTable('groups_roles') + + // Access type: 'stripe_purchase', 'admin_grant', 'free' + table.string('access_type', 50).notNullable() + + // Stripe-specific fields (nullable for non-Stripe grants) + table.string('stripe_session_id', 255) + table.string('stripe_payment_intent_id', 255) + table.integer('amount_paid').defaultTo(0) // 0 for free grants + table.string('currency', 3).defaultTo('usd') + + // Status: 'active', 'expired', 'revoked' + table.string('status', 50).notNullable().defaultTo('active') + + // Who granted access (for admin grants) + table.bigInteger('granted_by_id').unsigned().references('id').inTable('users') + + // Optional expiration + table.timestamp('expires_at') + + // Flexible metadata for additional info + table.jsonb('metadata').defaultTo('{}') + + table.timestamps(true, true) + + table.index(['user_id', 'status']) + table.index(['group_id']) + table.index(['product_id']) + table.index(['track_id']) + table.index(['role_id']) + table.index(['stripe_session_id']) + table.index(['access_type']) + table.index(['status']) + }) +} + +exports.down = function (knex) { + return knex.schema + // Drop the new tables + .dropTableIfExists('content_access') + .dropTableIfExists('stripe_products') + // Remove Stripe columns from groups table + .table('groups', function (table) { + table.dropForeign(['stripe_account_id']) + table.dropColumn('stripe_account_id') + table.dropColumn('stripe_charges_enabled') + table.dropColumn('stripe_payouts_enabled') + table.dropColumn('stripe_details_submitted') + }) + // Restore the stripe_account_id column to users table + .table('users', function (table) { + table.bigInteger('stripe_account_id').unsigned().references('id').inTable('stripe_accounts') + }) +} From 8d67ead192b727efc3980eecb31b586e2f039614 Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 22 Oct 2025 14:57:39 +1100 Subject: [PATCH 02/76] Deeper tweaks to migration --- .../20251020160838_paid-content-stripe.js | 391 ++++++++++++++---- 1 file changed, 301 insertions(+), 90 deletions(-) diff --git a/apps/backend/migrations/20251020160838_paid-content-stripe.js b/apps/backend/migrations/20251020160838_paid-content-stripe.js index 0498f96a98..96f3e894ff 100644 --- a/apps/backend/migrations/20251020160838_paid-content-stripe.js +++ b/apps/backend/migrations/20251020160838_paid-content-stripe.js @@ -1,93 +1,304 @@ -exports.up = function (knex) { - return knex.schema - // First, drop the existing foreign key from users to stripe_accounts - .table('users', function (table) { - table.dropForeign(['stripe_account_id']) - table.dropColumn('stripe_account_id') - }) - // Add Stripe columns to groups table - .table('groups', function (table) { - table.bigInteger('stripe_account_id').unsigned().references('id').inTable('stripe_accounts') - table.boolean('stripe_charges_enabled').defaultTo(false) - table.boolean('stripe_payouts_enabled').defaultTo(false) - table.boolean('stripe_details_submitted').defaultTo(false) - }) - // Create stripe_products table to track products associated with groups - .createTable('stripe_products', function (table) { - table.bigIncrements('id').primary() - table.bigInteger('group_id').unsigned().notNullable().references('id').inTable('groups').onDelete('CASCADE') - table.string('stripe_product_id', 255).notNullable() - table.string('stripe_price_id', 255).notNullable() - table.string('name', 255).notNullable() - table.text('description') - table.integer('price_in_cents').notNullable() - table.string('currency', 3).notNullable().defaultTo('usd') - table.boolean('active').defaultTo(true) - table.bigInteger('track_id').unsigned().references('id').inTable('tracks') - table.timestamps(true, true) - - table.index(['group_id']) - table.index(['stripe_product_id']) - }) - // Create content_access table to track all content access grants (paid and free) - // This supports both Stripe purchases and admin-granted free access - .createTable('content_access', function (table) { - table.bigIncrements('id').primary() - table.bigInteger('user_id').unsigned().notNullable().references('id').inTable('users') - table.bigInteger('group_id').unsigned().notNullable().references('id').inTable('groups') - table.bigInteger('product_id').unsigned().references('id').inTable('stripe_products') - table.bigInteger('track_id').unsigned().references('id').inTable('tracks') - table.integer('role_id').unsigned().references('id').inTable('groups_roles') - - // Access type: 'stripe_purchase', 'admin_grant', 'free' - table.string('access_type', 50).notNullable() - - // Stripe-specific fields (nullable for non-Stripe grants) - table.string('stripe_session_id', 255) - table.string('stripe_payment_intent_id', 255) - table.integer('amount_paid').defaultTo(0) // 0 for free grants - table.string('currency', 3).defaultTo('usd') - - // Status: 'active', 'expired', 'revoked' - table.string('status', 50).notNullable().defaultTo('active') - - // Who granted access (for admin grants) - table.bigInteger('granted_by_id').unsigned().references('id').inTable('users') - - // Optional expiration - table.timestamp('expires_at') - - // Flexible metadata for additional info - table.jsonb('metadata').defaultTo('{}') - - table.timestamps(true, true) - - table.index(['user_id', 'status']) - table.index(['group_id']) - table.index(['product_id']) - table.index(['track_id']) - table.index(['role_id']) - table.index(['stripe_session_id']) - table.index(['access_type']) - table.index(['status']) - }) +exports.up = async function (knex) { + // First, drop the existing foreign key from users to stripe_accounts + await knex.schema.table('users', function (table) { + table.dropForeign(['stripe_account_id']) + table.dropColumn('stripe_account_id') + }) + + // Add Stripe columns to groups table + await knex.schema.table('groups', function (table) { + table.bigInteger('stripe_account_id').unsigned().references('id').inTable('stripe_accounts') + table.boolean('stripe_charges_enabled').defaultTo(false) + table.boolean('stripe_payouts_enabled').defaultTo(false) + table.boolean('stripe_details_submitted').defaultTo(false) + }) + + // Create stripe_products table to track products associated with groups + await knex.schema.createTable('stripe_products', function (table) { + table.bigIncrements('id').primary() + table.bigInteger('group_id').unsigned().notNullable().references('id').inTable('groups').onDelete('CASCADE') + table.string('stripe_product_id', 255).notNullable() + table.string('stripe_price_id', 255).notNullable() + table.string('name', 255).notNullable() + table.text('description') + table.integer('price_in_cents').notNullable() + table.string('currency', 3).notNullable().defaultTo('usd') + table.boolean('active').defaultTo(true) + table.bigInteger('track_id').unsigned().references('id').inTable('tracks') + table.timestamps(true, true) + + table.index(['group_id']) + table.index(['stripe_product_id']) + }) + + // Create content_access table to track all content access grants (paid and free) + // This supports both Stripe purchases and admin-granted free access + await knex.schema.createTable('content_access', function (table) { + table.bigIncrements('id').primary() + table.bigInteger('user_id').unsigned().notNullable().references('id').inTable('users') + table.bigInteger('group_id').unsigned().notNullable().references('id').inTable('groups') + table.bigInteger('product_id').unsigned().references('id').inTable('stripe_products') + table.bigInteger('track_id').unsigned().references('id').inTable('tracks') + table.integer('role_id').unsigned().references('id').inTable('groups_roles') + + // Access type: 'stripe_purchase', 'admin_grant', 'free' + table.string('access_type', 50).notNullable() + + // Stripe-specific fields (nullable for non-Stripe grants) + table.string('stripe_session_id', 255) + table.string('stripe_payment_intent_id', 255) + table.integer('amount_paid').defaultTo(0) // 0 for free grants + table.string('currency', 3).defaultTo('usd') + + // Status: 'active', 'expired', 'revoked' + table.string('status', 50).notNullable().defaultTo('active') + + // Who granted access (for admin grants) + table.bigInteger('granted_by_id').unsigned().references('id').inTable('users') + + // Optional expiration + table.timestamp('expires_at') + + // Flexible metadata for additional info + table.jsonb('metadata').defaultTo('{}') + + table.timestamps(true, true) + + table.index(['user_id', 'status']) + table.index(['group_id']) + table.index(['product_id']) + table.index(['track_id']) + table.index(['role_id']) + table.index(['stripe_session_id']) + table.index(['access_type']) + table.index(['status']) + }) + + // Add expires_at columns to related tables to mirror content_access expiration + await knex.schema.table('group_memberships', function (table) { + table.timestamp('expires_at').comment('Mirrored from content_access table via trigger') + }) + + await knex.schema.table('tracks_users', function (table) { + table.timestamp('expires_at').comment('Mirrored from content_access table via trigger') + }) + + await knex.schema.table('group_memberships_group_roles', function (table) { + table.timestamp('expires_at').comment('Mirrored from content_access table via trigger') + }) + + // Add access_controlled flag to tracks table + // When true, this track requires purchased access (check content_access table) + // When false (default), track is freely accessible to all group members + await knex.schema.table('tracks', function (table) { + table.boolean('access_controlled').defaultTo(false).comment('Whether this track requires purchased access') + }) + + // Add paywall flag to groups table + // When true, this group requires purchased membership (check content_access table) + // When false (default), group is freely joinable per existing access control rules + await knex.schema.table('groups', function (table) { + table.boolean('paywall').defaultTo(false).comment('Whether this group requires purchased membership') + }) + + // Create trigger function to sync expires_at to related tables + // This function automatically updates the related tables whenever content_access is modified + // + // IMPORTANT: Multiple content_access records can reference the same stripe product + // (e.g., a bundle purchase, recurring subscriptions, or multiple purchases over time) + // + // IMPORTANT: Uses the MOST RECENT expires_at from ALL active content_access records + // This prevents old/expired records from overwriting newer access grants + // + // IMPORTANT: Only update group_memberships.expires_at when track_id is NULL + // This prevents track purchases from overwriting the group membership expiration + await knex.raw(` + CREATE OR REPLACE FUNCTION sync_content_access_expires_at() + RETURNS TRIGGER AS $$ + DECLARE + latest_expires_at TIMESTAMP; + BEGIN + -- Update group_memberships if track_id is NULL (includes group-level and role-based purchases) + -- Use the MOST RECENT expires_at from all active content_access records for this user+group + IF NEW.track_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND track_id IS NULL + AND status = 'active'; + + UPDATE group_memberships + SET expires_at = latest_expires_at, + updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id; + END IF; + + -- If track_id is set, update tracks_users with most recent expires_at for this track + IF NEW.track_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND track_id = NEW.track_id + AND status = 'active'; + + UPDATE tracks_users + SET expires_at = latest_expires_at, + updated_at = NOW() + WHERE user_id = NEW.user_id + AND track_id = NEW.track_id; + END IF; + + -- If role_id is set, update group_memberships_group_roles with most recent expires_at + IF NEW.role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND group_role_id = NEW.role_id + AND status = 'active'; + + UPDATE group_memberships_group_roles + SET expires_at = latest_expires_at + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND group_role_id = NEW.role_id; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + + // Create trigger on content_access for INSERT and UPDATE + await knex.raw(` + CREATE TRIGGER content_access_expires_at_sync + AFTER INSERT OR UPDATE OF expires_at ON content_access + FOR EACH ROW + WHEN (NEW.status = 'active') + EXECUTE FUNCTION sync_content_access_expires_at(); + `) + + // Create trigger function to clear expires_at when access is revoked + // Uses the most recent expires_at from remaining active records, or clears if none + await knex.raw(` + CREATE OR REPLACE FUNCTION clear_content_access_expires_at() + RETURNS TRIGGER AS $$ + DECLARE + latest_expires_at TIMESTAMP; + BEGIN + -- Update group_memberships with most recent expires_at from OTHER active records + -- If no other active records exist, this will set expires_at to NULL + IF NEW.track_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND track_id IS NULL + AND status = 'active' + AND id != NEW.id; -- Exclude the record being revoked + + UPDATE group_memberships + SET expires_at = latest_expires_at, + updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id; + END IF; + + -- If track_id is set, update tracks_users with most recent from other active records + IF NEW.track_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND track_id = NEW.track_id + AND status = 'active' + AND id != NEW.id; -- Exclude the record being revoked + + UPDATE tracks_users + SET expires_at = latest_expires_at, + updated_at = NOW() + WHERE user_id = NEW.user_id + AND track_id = NEW.track_id; + END IF; + + -- If role_id is set, update group_memberships_group_roles with most recent + IF NEW.role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND group_role_id = NEW.role_id + AND status = 'active' + AND id != NEW.id; -- Exclude the record being revoked + + UPDATE group_memberships_group_roles + SET expires_at = latest_expires_at + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND group_role_id = NEW.role_id; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + + // Create trigger to clear expires_at when status changes to revoked/expired + await knex.raw(` + CREATE TRIGGER content_access_expires_at_clear + AFTER UPDATE OF status ON content_access + FOR EACH ROW + WHEN (NEW.status IN ('revoked', 'expired')) + EXECUTE FUNCTION clear_content_access_expires_at(); + `) } -exports.down = function (knex) { - return knex.schema - // Drop the new tables - .dropTableIfExists('content_access') - .dropTableIfExists('stripe_products') - // Remove Stripe columns from groups table - .table('groups', function (table) { - table.dropForeign(['stripe_account_id']) - table.dropColumn('stripe_account_id') - table.dropColumn('stripe_charges_enabled') - table.dropColumn('stripe_payouts_enabled') - table.dropColumn('stripe_details_submitted') - }) - // Restore the stripe_account_id column to users table - .table('users', function (table) { - table.bigInteger('stripe_account_id').unsigned().references('id').inTable('stripe_accounts') - }) +exports.down = async function (knex) { + // Drop triggers first + await knex.raw('DROP TRIGGER IF EXISTS content_access_expires_at_sync ON content_access') + await knex.raw('DROP TRIGGER IF EXISTS content_access_expires_at_clear ON content_access') + await knex.raw('DROP FUNCTION IF EXISTS sync_content_access_expires_at()') + await knex.raw('DROP FUNCTION IF EXISTS clear_content_access_expires_at()') + + // Remove expires_at columns from related tables + await knex.schema.table('group_memberships', function (table) { + table.dropColumn('expires_at') + }) + + await knex.schema.table('tracks_users', function (table) { + table.dropColumn('expires_at') + }) + + await knex.schema.table('group_memberships_group_roles', function (table) { + table.dropColumn('expires_at') + }) + + // Remove access_controlled flag from tracks table + await knex.schema.table('tracks', function (table) { + table.dropColumn('access_controlled') + }) + + // Remove paywall flag from groups table + await knex.schema.table('groups', function (table) { + table.dropColumn('paywall') + }) + + // Drop the new tables + await knex.schema.dropTableIfExists('content_access') + await knex.schema.dropTableIfExists('stripe_products') + + // Remove Stripe columns from groups table + await knex.schema.table('groups', function (table) { + table.dropForeign(['stripe_account_id']) + table.dropColumn('stripe_account_id') + table.dropColumn('stripe_charges_enabled') + table.dropColumn('stripe_payouts_enabled') + table.dropColumn('stripe_details_submitted') + }) + + // Restore the stripe_account_id column to users table + await knex.schema.table('users', function (table) { + table.bigInteger('stripe_account_id').unsigned().references('id').inTable('stripe_accounts') + }) } From 25b8d53bf36f64be45e7b0fcfe11c2d47a9dbfc8 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 23 Oct 2025 15:49:04 +1100 Subject: [PATCH 03/76] Paid content backend, without actual access control --- apps/backend/.env.example | 5 +- .../api/controllers/StripeController.js | 367 ++++++++++++++ apps/backend/api/graphql/makeModels.js | 14 +- .../api/graphql/mutations/contentAccess.js | 267 +++++++++++ .../graphql/mutations/contentAccess.test.js | 322 +++++++++++++ apps/backend/api/graphql/mutations/index.js | 8 + apps/backend/api/graphql/mutations/stripe.js | 400 ++++++++++++++++ .../api/graphql/mutations/stripe.test.js | 331 +++++++++++++ apps/backend/api/graphql/schema.graphql | 162 +++++++ apps/backend/api/models/ContentAccess.js | 276 +++++++++++ apps/backend/api/models/StripeProduct.js | 230 +++++++++ apps/backend/api/models/Track.js | 2 +- apps/backend/api/services/StripeService.js | 449 ++++++++++++++++++ apps/backend/config/routes.js | 6 + .../20251020160838_paid-content-stripe.js | 5 +- apps/backend/migrations/schema.sql | 395 ++++++++++++++- apps/backend/package.json | 5 +- apps/mobile/package.json | 2 +- docs/CONTENT_ACCESS_SUMMARY.md | 280 +++++++++++ docs/PAID_CONTENT_WORKFLOW.md | 325 +++++++++++++ docs/STRIPE_CONNECT_INTEGRATION.md | 428 +++++++++++++++++ yarn.lock | 36 +- 22 files changed, 4291 insertions(+), 24 deletions(-) create mode 100644 apps/backend/api/controllers/StripeController.js create mode 100644 apps/backend/api/graphql/mutations/contentAccess.js create mode 100644 apps/backend/api/graphql/mutations/contentAccess.test.js create mode 100644 apps/backend/api/graphql/mutations/stripe.js create mode 100644 apps/backend/api/graphql/mutations/stripe.test.js create mode 100644 apps/backend/api/models/ContentAccess.js create mode 100644 apps/backend/api/models/StripeProduct.js create mode 100644 apps/backend/api/services/StripeService.js create mode 100644 docs/CONTENT_ACCESS_SUMMARY.md create mode 100644 docs/PAID_CONTENT_WORKFLOW.md create mode 100644 docs/STRIPE_CONNECT_INTEGRATION.md diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 9fcfb8c352..67e12cdab7 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -58,5 +58,8 @@ SEGMENT_KEY=xxxx SENDWITHUS_KEY= SLACK_APP_CLIENT_ID=XXX SLACK_APP_CLIENT_SECRET=XXX -STRIPE_API_KEY= +# Stripe API Keys - Get these from https://dashboard.stripe.com/apikeys +# Use test keys (starting with sk_test_ and pk_test_) for development +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= UPLOADER_PATH_PREFIX=evo-uploads diff --git a/apps/backend/api/controllers/StripeController.js b/apps/backend/api/controllers/StripeController.js new file mode 100644 index 0000000000..2f087f8cf4 --- /dev/null +++ b/apps/backend/api/controllers/StripeController.js @@ -0,0 +1,367 @@ +/** + * StripeController + * + * Controller for handling Stripe Connect REST endpoints. + * Provides endpoints for: + * - Handling OAuth redirects + * - Processing webhooks + * - Handling checkout success/cancel pages + */ + +const StripeService = require('../services/StripeService') +const Stripe = require('stripe') +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2025-09-30.clover' +}) + +module.exports = { + + /** + * Handles successful checkout and displays confirmation + * + * Called when a customer completes a purchase and is redirected + * from Stripe Checkout. Retrieves the session to verify payment. + * + * GET /noo/stripe/checkout/success?session_id=xxx&account_id=xxx + */ + checkoutSuccess: async function (req, res) { + try { + const sessionId = req.query.session_id + const accountId = req.query.account_id + + if (!sessionId || !accountId) { + return res.status(400).json({ + error: 'Missing session_id or account_id parameter' + }) + } + + // Retrieve the checkout session to verify payment + const session = await StripeService.getCheckoutSession(accountId, sessionId) + + // TODO STRIPE: + // In a real application, you would: + // 1. Grant access to the purchased content + // 2. Send confirmation email + // 3. Update your database + // 4. Redirect to appropriate page + + return res.json({ + success: true, + message: 'Payment successful!', + session: { + id: session.id, + payment_status: session.payment_status, + amount_total: session.amount_total, + currency: session.currency + } + }) + } catch (error) { + console.error('Error in checkoutSuccess:', error) + return res.status(500).json({ + error: 'Failed to process successful checkout', + message: error.message + }) + } + }, + + /** + * Handles checkout cancellation + * + * Called when a customer cancels the checkout process + * + * GET /noo/stripe/checkout/cancel + */ + checkoutCancel: function (req, res) { + // In a real application, you might want to: + // 1. Log the cancellation + // 2. Show a message to the user + // 3. Redirect back to the product page + + return res.json({ + success: false, + message: 'Checkout was cancelled' + }) + }, + + /** + * Webhook endpoint for Stripe events + * + * Stripe sends webhook events for important account and payment updates. + * This endpoint verifies the webhook signature and processes events. + * + * POST /noo/stripe/webhook + */ + webhook: async function (req, res) { + try { + // Get the webhook signature from headers + const signature = req.headers['stripe-signature'] + + if (!signature) { + console.error('Missing Stripe signature header') + return res.status(400).json({ error: 'Missing signature' }) + } + + // Verify webhook signature + let event + try { + event = stripe.webhooks.constructEvent( + req.body, + signature, + process.env.STRIPE_WEBHOOK_SECRET + ) + } catch (err) { + console.error('Webhook signature verification failed:', err.message) + return res.status(400).json({ error: 'Invalid signature' }) + } + + if (process.env.NODE_ENV === 'development') { + console.log(`Processing webhook event: ${event.type}`) + } + + // Handle different event types + switch (event.type) { + case 'account.updated': + await this.handleAccountUpdated(event) + break + + case 'payment_intent.succeeded': + await this.handlePaymentIntentSucceeded(event) + break + + case 'payment_intent.payment_failed': + await this.handlePaymentIntentFailed(event) + break + + case 'checkout.session.completed': + await this.handleCheckoutSessionCompleted(event) + break + + default: + if (process.env.NODE_ENV === 'development') { + console.log(`Unhandled event type: ${event.type}`) + } + } + + // Return a 200 response to acknowledge receipt of the event + return res.json({ received: true }) + } catch (error) { + console.error('Webhook error:', error) + return res.status(400).json({ + error: 'Webhook processing failed', + message: error.message + }) + } + }, + + /** + * Handle account.updated webhook events + * Updates group's Stripe account status when onboarding changes + */ + handleAccountUpdated: async function (event) { + try { + const account = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Account updated: ${account.id}`) + } + + // Find the group with this Stripe account ID + const group = await Group.where({ stripe_account_id: account.id }).fetch() + + if (!group) { + if (process.env.NODE_ENV === 'development') { + console.log(`No group found for Stripe account: ${account.id}`) + } + return + } + + // Update group's Stripe status + await group.save({ + stripe_charges_enabled: account.charges_enabled, + stripe_payouts_enabled: account.payouts_enabled, + stripe_details_submitted: account.details_submitted + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Updated Stripe status for group ${group.get('id')}`) + } + } catch (error) { + console.error('Error handling account.updated:', error) + throw error + } + }, + + /** + * Handle payment_intent.succeeded webhook events + * Grants access to content when payment is successful + */ + handlePaymentIntentSucceeded: async function (event) { + try { + const paymentIntent = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Payment succeeded: ${paymentIntent.id}`) + } + + // Get the checkout session to find the product and user info + const sessionId = paymentIntent.metadata?.session_id + if (!sessionId) { + if (process.env.NODE_ENV === 'development') { + console.log(`No session_id found in payment intent metadata: ${paymentIntent.id}`) + } + return + } + + // Retrieve the checkout session + const session = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['line_items'] + }) + + if (!session) { + if (process.env.NODE_ENV === 'development') { + console.log(`No checkout session found: ${sessionId}`) + } + return + } + + // Extract user and product info from session metadata + const userId = session.metadata?.userId + const groupId = session.metadata?.groupId + const accountId = session.metadata?.accountId + + if (!userId || !groupId || !accountId) { + if (process.env.NODE_ENV === 'development') { + console.log(`Missing required metadata in session ${sessionId}:`, { + userId: !!userId, + groupId: !!groupId, + accountId: !!accountId + }) + } + return + } + + // Find the Stripe product for this purchase + const lineItem = session.line_items?.data?.[0] + if (!lineItem) { + if (process.env.NODE_ENV === 'development') { + console.log(`No line items found in session: ${sessionId}`) + } + return + } + + const product = await StripeProduct.findByStripeId(lineItem.price.product) + if (!product) { + if (process.env.NODE_ENV === 'development') { + console.log(`No product found for Stripe product ID: ${lineItem.price.product}`) + } + return + } + + // Generate content access records + const accessRecords = await product.generateContentAccessRecords({ + userId: parseInt(userId, 10), + sessionId, + paymentIntentId: paymentIntent.id, + metadata: { + paymentAmount: paymentIntent.amount, + currency: paymentIntent.currency, + purchasedAt: new Date().toISOString() + } + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Created ${accessRecords.length} content access records for user ${userId}`) + } + + // TODO STRIPE: Send confirmation email to user + // TODO STRIPE: Send notification to group admins + } catch (error) { + console.error('Error handling payment_intent.succeeded:', error) + throw error + } + }, + + /** + * Handle payment_intent.payment_failed webhook events + * Notifies user and logs failed payments + * TODO STRIPE: This needs to actually log to somewhere specific, and otherwise be more useful. Need to be able to access the checkout session from stripe + */ + handlePaymentIntentFailed: async function (event) { + try { + const paymentIntent = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Payment failed: ${paymentIntent.id}`) + } + + // Get the checkout session to find user info + const sessionId = paymentIntent.metadata?.session_id + if (!sessionId) { + if (process.env.NODE_ENV === 'development') { + console.log(`No session_id found in failed payment intent metadata: ${paymentIntent.id}`) + } + return + } + + const session = await stripe.checkout.sessions.retrieve(sessionId) + const userId = session.metadata?.userId + + if (userId) { + if (process.env.NODE_ENV === 'development') { + console.log(`Payment failed for user ${userId}, session ${sessionId}`) + } + // TODO STRIPE: Send failure notification email to user + // TODO STRIPE: Log for analytics + } + } catch (error) { + console.error('Error handling payment_intent.payment_failed:', error) + throw error + } + }, + + /** + * Handle checkout.session.completed webhook events + * Final verification that checkout completed successfully + */ + handleCheckoutSessionCompleted: async function (event) { + try { + const session = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Checkout session completed: ${session.id}`) + } + + // Verify payment was successful + if (session.payment_status !== 'paid') { + if (process.env.NODE_ENV === 'development') { + console.log(`Checkout session ${session.id} completed but payment status is ${session.payment_status}`) + } + return + } + + // The actual access granting is handled by payment_intent.succeeded + // This is just for logging and verification + if (process.env.NODE_ENV === 'development') { + console.log(`Checkout session ${session.id} completed successfully`) + } + } catch (error) { + console.error('Error handling checkout.session.completed:', error) + throw error + } + }, + + /** + * Health check endpoint for Stripe integration + * + * Verifies that Stripe is properly configured + * + * GET /noo/stripe/health + */ + health: function (req, res) { + const hasStripeKey = !!process.env.STRIPE_SECRET_KEY + + return res.json({ + status: hasStripeKey ? 'configured' : 'not_configured', + message: hasStripeKey + ? 'Stripe is properly configured' + : 'STRIPE_SECRET_KEY environment variable is not set', + apiVersion: '2025-09-30.clover' + }) + } +} diff --git a/apps/backend/api/graphql/makeModels.js b/apps/backend/api/graphql/makeModels.js index 20d3ca740f..da0c100539 100644 --- a/apps/backend/api/graphql/makeModels.js +++ b/apps/backend/api/graphql/makeModels.js @@ -198,6 +198,7 @@ export default function makeModels (userId, isAdmin, apiClient) { model: GroupMembership, attributes: [ 'created_at', + 'expires_at', 'group_id', 'nav_order' ], @@ -254,6 +255,7 @@ export default function makeModels (userId, isAdmin, apiClient) { model: MemberGroupRole, attributes: [ 'id', + 'expires_at', 'group_id', 'group_role_id', 'user_id' @@ -308,6 +310,7 @@ export default function makeModels (userId, isAdmin, apiClient) { getters: { completedAt: p => p.pivot && p.pivot.get('completed_at'), // When loading through a track this is when they completed the track enrolledAt: p => p.pivot && p.pivot.get('enrolled_at'), // When loading through a track this is when they were enrolled in the track + expiresAt: p => p.pivot && p.pivot.get('expires_at'), // When loading through a track this is when their access expires messageThreadId: p => p.getMessageThreadWith(userId).then(post => post ? post.id : null) }, relations: [ @@ -600,9 +603,11 @@ export default function makeModels (userId, isAdmin, apiClient) { 'geo_shape', 'memberCount', 'name', + 'paywall', 'postCount', 'purpose', 'slug', + 'stripe_account_id', 'type', 'visibility', 'website_url', @@ -1253,6 +1258,7 @@ export default function makeModels (userId, isAdmin, apiClient) { Track: { model: Track, attributes: [ + 'access_controlled', 'action_descriptor', 'action_descriptor_plural', 'created_at', @@ -1279,7 +1285,12 @@ export default function makeModels (userId, isAdmin, apiClient) { getters: { isEnrolled: t => t && userId && t.isEnrolled(userId), didComplete: t => t && userId && t.didComplete(userId), - userSettings: t => t && userId ? t.userSettings(userId) : null + userSettings: t => t && userId ? t.userSettings(userId) : null, + expiresAt: async (t) => { + if (!t || !userId) return null + const trackUser = await t.trackUser(userId).fetch() + return trackUser ? trackUser.get('expires_at') : null + } }, fetchMany: ({ autocomplete, first = 20, offset = 0, order, published, sortBy }) => searchQuerySet('tracks', { @@ -1293,6 +1304,7 @@ export default function makeModels (userId, isAdmin, apiClient) { 'completed_at', 'created_at', 'enrolled_at', + 'expires_at', 'settings', 'updated_at' ], diff --git a/apps/backend/api/graphql/mutations/contentAccess.js b/apps/backend/api/graphql/mutations/contentAccess.js new file mode 100644 index 0000000000..e11ece7736 --- /dev/null +++ b/apps/backend/api/graphql/mutations/contentAccess.js @@ -0,0 +1,267 @@ +/** + * Content Access GraphQL Mutations + * + * Provides GraphQL API for managing content access grants: + * - Admin-granted free access to content + * - Recording Stripe purchases + * - Checking and revoking access + */ + +import { GraphQLError } from 'graphql' + +/* global ContentAccess */ + +module.exports = { + + /** + * Grant free access to content (admin only) + * + * Allows group administrators to grant users free access to paid content + * without requiring a Stripe purchase. Useful for comps, staff access, + * promotional access, etc. + * + * Usage: + * mutation { + * grantContentAccess( + * userId: "456" + * groupId: "123" + * productId: "789" // optional - for product-based access + * trackId: "101" // optional - for track-based access + * expiresAt: "2025-12-31T23:59:59Z" // optional + * reason: "Staff member" + * ) { + * id + * success + * message + * } + * } + */ + grantContentAccess: async (root, { + userId, + groupId, + productId, + trackId, + expiresAt, + reason + }, { session }) => { + try { + // Check if user is authenticated + if (!session || !session.userId) { + throw new GraphQLError('You must be logged in to grant content access') + } + + // Verify user has admin permission for this group + const membership = await GroupMembership.forPair(session.userId, groupId).fetch() + if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + throw new GraphQLError('You must be a group administrator to grant content access') + } + + // Verify the target user exists + const targetUser = await User.find(userId) + if (!targetUser) { + throw new GraphQLError('User not found') + } + + // Verify the group exists + const group = await Group.find(groupId) + if (!group) { + throw new GraphQLError('Group not found') + } + + // Must provide either productId or trackId + if (!productId && !trackId) { + throw new GraphQLError('Must specify either productId or trackId') + } + + // Grant access using the ContentAccess model + const access = await ContentAccess.grantAccess({ + userId, + groupId, + grantedById: session.userId, + productId, + trackId, + expiresAt, + reason + }) + + return { + id: access.id, + userId, + groupId, + productId, + trackId, + accessType: access.get('access_type'), + status: access.get('status'), + success: true, + message: 'Access granted successfully' + } + } catch (error) { + console.error('Error in grantContentAccess:', error) + throw new GraphQLError(`Failed to grant access: ${error.message}`) + } + }, + + /** + * Revoke content access (admin only) + * + * Allows group administrators to revoke previously granted access. + * Can be used for both purchased and admin-granted access. + * + * Usage: + * mutation { + * revokeContentAccess( + * accessId: "123" + * reason: "User violated terms" + * ) { + * success + * message + * } + * } + */ + revokeContentAccess: async (root, { accessId, reason }, { session }) => { + try { + // Check if user is authenticated + if (!session || !session.userId) { + throw new GraphQLError('You must be logged in to revoke content access') + } + + // Load the access record and verify permissions + const access = await ContentAccess.where({ id: accessId }).fetch() + if (!access) { + throw new GraphQLError('Access record not found') + } + + const membership = await GroupMembership.forPair(session.userId, access.get('group_id')).fetch() + if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + throw new GraphQLError('You must be a group administrator to revoke access') + } + + // Revoke the access using the model method + await ContentAccess.revoke(accessId, session.userId, reason) + + return { + success: true, + message: 'Access revoked successfully' + } + } catch (error) { + console.error('Error in revokeContentAccess:', error) + throw new GraphQLError(`Failed to revoke access: ${error.message}`) + } + }, + + /** + * Check if user has access to content + * + * Query to check if a user has active access to specific content. + * Can be used by the frontend to gate content display. + * + * Usage: + * query { + * checkContentAccess( + * userId: "456" + * groupId: "123" + * productId: "789" // or trackId: "101" + * ) { + * hasAccess + * accessType + * expiresAt + * } + * } + */ + checkContentAccess: async (root, { userId, groupId, productId, trackId }, { session }) => { + try { + // Check access using the model method + const access = await ContentAccess.checkAccess({ + userId, + groupId, + productId, + trackId + }) + + if (!access) { + return { + hasAccess: false, + accessType: null, + expiresAt: null, + grantedAt: null + } + } + + return { + hasAccess: true, + accessType: access.get('access_type'), + expiresAt: access.get('expires_at'), + grantedAt: access.get('created_at') + } + } catch (error) { + console.error('Error in checkContentAccess:', error) + throw new GraphQLError(`Failed to check access: ${error.message}`) + } + }, + + /** + * Record a Stripe purchase + * + * Internal mutation to record successful Stripe purchases. + * This should be called from the Stripe webhook handler after + * a successful checkout.session.completed event. + * + * Usage (internal): + * mutation { + * recordStripePurchase( + * userId: "456" + * groupId: "123" + * productId: "789" + * sessionId: "cs_xxx" + * paymentIntentId: "pi_xxx" + * amountPaid: 2000 + * currency: "usd" + * ) { + * id + * success + * } + * } + */ + recordStripePurchase: async (root, { + userId, + groupId, + productId, + trackId, + roleId, + sessionId, + paymentIntentId, + amountPaid, + currency, + expiresAt, + metadata + }, { session }) => { + try { + // This mutation should ideally only be called internally from webhook handler + // For security, you might want to add special authentication for this + + // Record the purchase using the model method + const access = await ContentAccess.recordPurchase({ + userId, + groupId, + productId, + trackId, + roleId, + sessionId, + paymentIntentId, + amountPaid, + currency, + expiresAt, + metadata: metadata || {} + }) + + return { + id: access.id, + success: true, + message: 'Purchase recorded successfully' + } + } catch (error) { + console.error('Error in recordStripePurchase:', error) + throw new GraphQLError(`Failed to record purchase: ${error.message}`) + } + } +} diff --git a/apps/backend/api/graphql/mutations/contentAccess.test.js b/apps/backend/api/graphql/mutations/contentAccess.test.js new file mode 100644 index 0000000000..4797c6687f --- /dev/null +++ b/apps/backend/api/graphql/mutations/contentAccess.test.js @@ -0,0 +1,322 @@ +/* eslint-disable no-unused-expressions */ +import '../../../test/setup' +import factories from '../../../test/setup/factories' +import { + grantContentAccess, + revokeContentAccess, + checkContentAccess, + recordStripePurchase +} from './contentAccess' +const { expect } = require('chai') + +/* global setup */ + +describe('Content Access Mutations', () => { + let user, adminUser, group, product, track + + before(async () => { + // Create test entities + user = await factories.user().save() + adminUser = await factories.user().save() + group = await factories.group().save() + product = await factories.stripeProduct({ group_id: group.id }).save() + track = await factories.track({ group_id: group.id }).save() + + // Add admin user as group administrator + await adminUser.joinGroup(group, { role: GroupMembership.Role.MODERATOR }) + // Add regular user as group member + await user.joinGroup(group) + }) + + after(() => setup.clearDb()) + + describe('grantContentAccess', () => { + it('grants access to a product for a user', async () => { + const result = await grantContentAccess({ + userId: user.id, + groupId: group.id, + productId: product.id, + reason: 'Staff member' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.userId).to.equal(user.id) + expect(result.groupId).to.equal(group.id) + expect(result.productId).to.equal(product.id) + expect(result.accessType).to.equal('admin_grant') + expect(result.status).to.equal('active') + + // Verify the access record was created + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access).to.exist + expect(access.get('user_id')).to.equal(user.id) + expect(access.get('group_id')).to.equal(group.id) + expect(access.get('product_id')).to.equal(product.id) + expect(access.get('access_type')).to.equal('admin_grant') + expect(access.get('status')).to.equal('active') + }) + + it('grants access to a track for a user', async () => { + const result = await grantContentAccess({ + userId: user.id, + groupId: group.id, + trackId: track.id, + reason: 'Promotional access' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.trackId).to.equal(track.id) + + // Verify the access record was created + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access.get('track_id')).to.equal(track.id) + }) + + it('grants access with expiration date', async () => { + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days from now + + const result = await grantContentAccess({ + userId: user.id, + groupId: group.id, + productId: product.id, + expiresAt, + reason: 'Temporary access' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access.get('expires_at')).to.be.closeToTime(expiresAt, 1000) + }) + + it('rejects access grant for non-authenticated users', async () => { + await expect( + grantContentAccess({ + userId: user.id, + groupId: group.id, + productId: product.id, + reason: 'Test' + }, { session: null }) + ).to.be.rejectedWith('You must be logged in to grant content access') + }) + + it('rejects access grant for non-admin users', async () => { + await expect( + grantContentAccess({ + userId: user.id, + groupId: group.id, + productId: product.id, + reason: 'Test' + }, { session: { userId: user.id } }) + ).to.be.rejectedWith('You must be a group administrator to grant content access') + }) + + it('rejects access grant for non-existent user', async () => { + await expect( + grantContentAccess({ + userId: 99999, + groupId: group.id, + productId: product.id, + reason: 'Test' + }, { session: { userId: adminUser.id } }) + ).to.be.rejectedWith('User not found') + }) + + it('rejects access grant for non-existent group', async () => { + await expect( + grantContentAccess({ + userId: user.id, + groupId: 99999, + productId: product.id, + reason: 'Test' + }, { session: { userId: adminUser.id } }) + ).to.be.rejectedWith('Group not found') + }) + + it('rejects access grant without productId or trackId', async () => { + await expect( + grantContentAccess({ + userId: user.id, + groupId: group.id, + reason: 'Test' + }, { session: { userId: adminUser.id } }) + ).to.be.rejectedWith('Must specify either productId or trackId') + }) + }) + + describe('revokeContentAccess', () => { + let accessRecord + + beforeEach(async () => { + // Create an access record to revoke + accessRecord = await ContentAccess.create({ + user_id: user.id, + group_id: group.id, + product_id: product.id, + access_type: 'admin_grant', + status: 'active', + metadata: { reason: 'Test access' } + }) + }) + + it('revokes access for admin users', async () => { + const result = await revokeContentAccess({ + accessId: accessRecord.id, + reason: 'Access no longer needed' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.message).to.equal('Access revoked successfully') + + // Verify the access record was revoked + await accessRecord.refresh() + expect(accessRecord.get('status')).to.equal('revoked') + }) + + it('rejects revocation for non-authenticated users', async () => { + await expect( + revokeContentAccess({ + accessId: accessRecord.id, + reason: 'Test' + }, { session: null }) + ).to.be.rejectedWith('You must be logged in to revoke content access') + }) + + it('rejects revocation for non-admin users', async () => { + await expect( + revokeContentAccess({ + accessId: accessRecord.id, + reason: 'Test' + }, { session: { userId: user.id } }) + ).to.be.rejectedWith('You must be a group administrator to revoke access') + }) + + it('rejects revocation for non-existent access record', async () => { + await expect( + revokeContentAccess({ + accessId: 99999, + reason: 'Test' + }, { session: { userId: adminUser.id } }) + ).to.be.rejectedWith('Access record not found') + }) + }) + + describe('checkContentAccess', () => { + beforeEach(async () => { + // Create an active access record + await ContentAccess.create({ + user_id: user.id, + group_id: group.id, + product_id: product.id, + access_type: 'admin_grant', + status: 'active', + metadata: { reason: 'Test access' } + }) + }) + + it('returns access information for user with access', async () => { + const result = await checkContentAccess({ + userId: user.id, + groupId: group.id, + productId: product.id + }, { session: { userId: user.id } }) + + expect(result.hasAccess).to.be.true + expect(result.accessType).to.equal('admin_grant') + expect(result.grantedAt).to.exist + }) + + it('returns no access for user without access', async () => { + const otherUser = await factories.user().save() + + const result = await checkContentAccess({ + userId: otherUser.id, + groupId: group.id, + productId: product.id + }, { session: { userId: otherUser.id } }) + + expect(result.hasAccess).to.be.false + expect(result.accessType).to.be.null + expect(result.expiresAt).to.be.null + expect(result.grantedAt).to.be.null + }) + + it('returns no access for non-existent product', async () => { + const result = await checkContentAccess({ + userId: user.id, + groupId: group.id, + productId: 99999 + }, { session: { userId: user.id } }) + + expect(result.hasAccess).to.be.false + }) + }) + + describe('recordStripePurchase', () => { + it('records a successful Stripe purchase', async () => { + const sessionId = 'cs_test_123' + const paymentIntentId = 'pi_test_123' + const expiresAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year from now + + const result = await recordStripePurchase({ + userId: user.id, + groupId: group.id, + productId: product.id, + sessionId, + paymentIntentId, + expiresAt, + metadata: { source: 'webhook' } + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.message).to.equal('Purchase recorded successfully') + + // Verify the access record was created + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access.get('user_id')).to.equal(user.id) + expect(access.get('group_id')).to.equal(group.id) + expect(access.get('product_id')).to.equal(product.id) + expect(access.get('access_type')).to.equal('stripe_purchase') + expect(access.get('stripe_session_id')).to.equal(sessionId) + expect(access.get('stripe_payment_intent_id')).to.equal(paymentIntentId) + expect(access.get('expires_at')).to.be.closeToTime(expiresAt, 1000) + expect(access.get('metadata')).to.deep.include({ source: 'webhook' }) + }) + + it('records a track-specific purchase', async () => { + const result = await recordStripePurchase({ + userId: user.id, + groupId: group.id, + trackId: track.id, + sessionId: 'cs_test_456', + paymentIntentId: 'pi_test_456', + metadata: { source: 'webhook' } + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access.get('track_id')).to.equal(track.id) + expect(access.get('access_type')).to.equal('stripe_purchase') + }) + + it('records a role-specific purchase', async () => { + const roleId = 1 + + const result = await recordStripePurchase({ + userId: user.id, + groupId: group.id, + roleId, + sessionId: 'cs_test_789', + paymentIntentId: 'pi_test_789', + metadata: { source: 'webhook' } + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access.get('role_id')).to.equal(roleId) + expect(access.get('access_type')).to.equal('stripe_purchase') + }) + }) +}) diff --git a/apps/backend/api/graphql/mutations/index.js b/apps/backend/api/graphql/mutations/index.js index 4de1bd508a..9ab150068f 100644 --- a/apps/backend/api/graphql/mutations/index.js +++ b/apps/backend/api/graphql/mutations/index.js @@ -148,6 +148,14 @@ export { createZapierTrigger, deleteZapierTrigger } from './zapier' +export { + createStripeConnectedAccount, + createStripeAccountLink, + stripeAccountStatus, + createStripeProduct, + stripeProducts, + createStripeCheckoutSession +} from './stripe' export { default as findOrCreateThread } from '../../models/post/findOrCreateThread' export async function updateMe (sessionId, userId, changes) { diff --git a/apps/backend/api/graphql/mutations/stripe.js b/apps/backend/api/graphql/mutations/stripe.js new file mode 100644 index 0000000000..440e46e9ab --- /dev/null +++ b/apps/backend/api/graphql/mutations/stripe.js @@ -0,0 +1,400 @@ +/** + * Stripe Connect GraphQL Mutations + * + * Provides GraphQL API for Stripe Connect functionality: + * - Creating connected accounts for groups + * - Generating onboarding links + * - Managing products and prices + * - Creating checkout sessions + */ + +import { GraphQLError } from 'graphql' + +/* global StripeProduct */ + +module.exports = { + + /** + * Creates a Stripe Connected Account for a group + * + * This mutation allows group administrators to create a connected account + * that enables them to receive payments directly. The platform takes + * an application fee on each transaction. + * + * Usage: + * mutation { + * createStripeConnectedAccount( + * groupId: "123" + * email: "group@example.com" + * businessName: "My Group" + * country: "US" + * ) { + * id + * accountId + * success + * } + * } + */ + createStripeConnectedAccount: async (root, { groupId, email, businessName, country }, { session }) => { + try { + // Check if user is authenticated + if (!session || !session.userId) { + throw new GraphQLError('You must be logged in to create a connected account') + } + + // Load the group to verify it exists and user has permission + const group = await Group.find(groupId) + if (!group) { + throw new GraphQLError('Group not found') + } + + // Check if user is a steward/admin of the group + const membership = await GroupMembership.forPair(session.userId, groupId).fetch() + if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + throw new GraphQLError('You must be a group administrator to create a connected account') + } + + // Check if group already has a Stripe account + // TODO STRIPE: You may want to store the accountId in your Group model + // For this demo, we'll just create a new account each time + + // Create the connected account + const account = await StripeService.createConnectedAccount({ + email: email || group.get('contact_email'), + country: country || 'US', + businessName: businessName || group.get('name') + }) + + // TODO STRIPE: Save the account ID to your database + // await group.save({ stripe_account_id: account.id }) + + return { + id: groupId, + accountId: account.id, + success: true, + message: 'Connected account created successfully' + } + } catch (error) { + console.error('Error in createStripeConnectedAccount:', error) + throw new GraphQLError(`Failed to create connected account: ${error.message}`) + } + }, + + /** + * Generates an Account Link for onboarding + * + * Account Links are temporary URLs that allow the connected account + * to complete onboarding and access the Stripe Dashboard. + * + * Usage: + * mutation { + * createStripeAccountLink( + * groupId: "123" + * accountId: "acct_xxx" + * returnUrl: "https://yourapp.com/groups/{groupSlug}/settings/paid-content" + * refreshUrl: "https://yourapp.com/groups/{groupSlug}/settings/paid-content" + * ) { + * url + * expiresAt + * } + * } + */ + createStripeAccountLink: async (root, { groupId, accountId, returnUrl, refreshUrl }, { session }) => { + try { + // Check if user is authenticated + if (!session || !session.userId) { + throw new GraphQLError('You must be logged in to create an account link') + } + + // Verify user has permission for this group + const membership = await GroupMembership.forPair(session.userId, groupId).fetch() + if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + throw new GraphQLError('You must be a group administrator to manage payments') + } + + // Create the account link + const accountLink = await StripeService.createAccountLink({ + accountId, + returnUrl, + refreshUrl + }) + + return { + url: accountLink.url, + expiresAt: accountLink.expires_at, + success: true + } + } catch (error) { + console.error('Error in createStripeAccountLink:', error) + throw new GraphQLError(`Failed to create account link: ${error.message}`) + } + }, + + /** + * Retrieves the status of a connected account + * + * This query fetches the current onboarding status and capabilities + * of a connected account directly from Stripe. + * + * Usage: + * query { + * stripeAccountStatus( + * groupId: "123" + * accountId: "acct_xxx" + * ) { + * chargesEnabled + * payoutsEnabled + * detailsSubmitted + * } + * } + */ + stripeAccountStatus: async (root, { groupId, accountId }, { session }) => { + try { + // Check if user is authenticated + if (!session || !session.userId) { + throw new GraphQLError('You must be logged in to view account status') + } + + // Verify user has permission for this group + const membership = await GroupMembership.forPair(session.userId, groupId).fetch() + if (!membership) { + throw new GraphQLError('You must be a member of this group to view payment status') + } + + // Get account status from Stripe + const status = await StripeService.getAccountStatus(accountId) + + return { + accountId: status.id, + chargesEnabled: status.charges_enabled, + payoutsEnabled: status.payouts_enabled, + detailsSubmitted: status.details_submitted, + email: status.email, + requirements: status.requirements + } + } catch (error) { + console.error('Error in stripeAccountStatus:', error) + throw new GraphQLError(`Failed to retrieve account status: ${error.message}`) + } + }, + + /** + * Creates a product on the connected account + * + * Products represent subscription tiers, content access, or other + * offerings that the group wants to sell. + * + * Usage: + * mutation { + * createStripeProduct( + * groupId: "123" + * accountId: "acct_xxx" + * name: "Premium Membership" + * description: "Access to all premium content" + * priceInCents: 2000 + * currency: "usd" + * contentAccess: { + * "123": { + * trackIds: [456, 789] + * roleIds: [1, 2] + * } + * } + * ) { + * productId + * priceId + * success + * } + * } + */ + createStripeProduct: async (root, { + groupId, + accountId, + name, + description, + priceInCents, + currency, + contentAccess, + renewalPolicy, + duration + }, { session }) => { + try { + // Check if user is authenticated + if (!session || !session.userId) { + throw new GraphQLError('You must be logged in to create a product') + } + + // Verify user has permission for this group + const membership = await GroupMembership.forPair(session.userId, groupId).fetch() + if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + throw new GraphQLError('You must be a group administrator to create products') + } + + // Create the product on the connected account + const product = await StripeService.createProduct({ + accountId, + name, + description, + priceInCents, + currency: currency || 'usd' + }) + + // Save product to database for tracking and association with content + const stripeProduct = await StripeProduct.create({ + group_id: groupId, + stripe_product_id: product.id, + stripe_price_id: product.default_price, + name: product.name, + description: product.description, + price_in_cents: priceInCents, + currency: currency || 'usd', + content_access: contentAccess || {}, + renewal_policy: renewalPolicy || 'manual', + duration: duration || null, + active: true + }) + + return { + productId: product.id, + priceId: product.default_price, + name: product.name, + databaseId: stripeProduct.id, + success: true, + message: 'Product created successfully' + } + } catch (error) { + console.error('Error in createStripeProduct:', error) + throw new GraphQLError(`Failed to create product: ${error.message}`) + } + }, + + /** + * Lists all products for a connected account + * + * Usage: + * query { + * stripeProducts( + * groupId: "123" + * accountId: "acct_xxx" + * ) { + * products { + * id + * name + * description + * defaultPrice { + * unitAmount + * currency + * } + * } + * } + * } + */ + stripeProducts: async (root, { groupId, accountId }, { session }) => { + try { + // Check if user is authenticated + if (!session || !session.userId) { + throw new GraphQLError('You must be logged in to view products') + } + + // Verify user has permission for this group + const membership = await GroupMembership.forPair(session.userId, groupId).fetch() + if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + throw new GraphQLError('You must be a group administrator to view products') + } + + // Get products from Stripe + const products = await StripeService.getProducts(accountId) + + // Format products for GraphQL response + const formattedProducts = products.map(product => ({ + id: product.id, + name: product.name, + description: product.description, + defaultPriceId: product.default_price, + images: product.images, + active: product.active + })) + + return { + products: formattedProducts, + success: true + } + } catch (error) { + console.error('Error in stripeProducts:', error) + throw new GraphQLError(`Failed to retrieve products: ${error.message}`) + } + }, + + /** + * Creates a checkout session for purchasing a product + * + * This mutation creates a Stripe Checkout session and returns a URL + * that the customer should be redirected to for payment. + * + * Usage: + * mutation { + * createStripeCheckoutSession( + * groupId: "123" + * accountId: "acct_xxx" + * priceId: "price_xxx" + * quantity: 1 + * successUrl: "https://yourapp.com/success" + * cancelUrl: "https://yourapp.com/cancel" + * ) { + * sessionId + * url + * } + * } + */ + createStripeCheckoutSession: async (root, { + groupId, + accountId, + priceId, + quantity, + successUrl, + cancelUrl, + metadata + }, { session }) => { + try { + // Authentication is optional for checkout - you may want to allow guests + // For this demo, we'll allow unauthenticated purchases + + // Fetch the actual price from Stripe to calculate the application fee accurately + const priceObject = await StripeService.getPrice(accountId, priceId) + + // Calculate the total amount (price * quantity) + const totalAmount = priceObject.unit_amount * (quantity || 1) + + // Calculate application fee (10% of total as example) + // TODO STRIPE: Adjust this percentage or calculation based on your business model + // You can make this configurable per group or product + const applicationFeePercentage = 0.10 // 10% + const applicationFeeAmount = Math.round(totalAmount * applicationFeePercentage) + + // Create the checkout session + const checkoutSession = await StripeService.createCheckoutSession({ + accountId, + priceId, + quantity: quantity || 1, + applicationFeeAmount, + successUrl: `${successUrl}?session_id={CHECKOUT_SESSION_ID}&account_id=${accountId}`, + cancelUrl, + metadata: { + groupId, + userId: session?.userId, + priceAmount: priceObject.unit_amount, + currency: priceObject.currency, + ...metadata + } + }) + + return { + sessionId: checkoutSession.id, + url: checkoutSession.url, + success: true + } + } catch (error) { + console.error('Error in createStripeCheckoutSession:', error) + throw new GraphQLError(`Failed to create checkout session: ${error.message}`) + } + } +} diff --git a/apps/backend/api/graphql/mutations/stripe.test.js b/apps/backend/api/graphql/mutations/stripe.test.js new file mode 100644 index 0000000000..6d1871e59a --- /dev/null +++ b/apps/backend/api/graphql/mutations/stripe.test.js @@ -0,0 +1,331 @@ +/* eslint-disable no-unused-expressions */ +import '../../../test/setup' +import factories from '../../../test/setup/factories' +import { + createStripeConnectedAccount, + createStripeAccountLink, + stripeAccountStatus, + createStripeProduct, + stripeProducts, + createStripeCheckoutSession +} from './stripe' +const { expect } = require('chai') + +/* global setup */ + +describe('Stripe Mutations', () => { + let user, adminUser, group + + before(async () => { + // Create test entities + user = await factories.user().save() + adminUser = await factories.user().save() + group = await factories.group().save() + + // Add admin user as group administrator + await adminUser.joinGroup(group, { role: GroupMembership.Role.MODERATOR }) + // Add regular user as group member + await user.joinGroup(group) + }) + + after(() => setup.clearDb()) + + describe('createStripeConnectedAccount', () => { + it('creates a connected account for group admins', async () => { + const result = await createStripeConnectedAccount({ + groupId: group.id, + email: 'group@example.com', + businessName: 'Test Group', + country: 'US' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.accountId).to.equal('acct_test_123') + expect(result.message).to.equal('Connected account created successfully') + }) + + it('uses group email and name as defaults', async () => { + await group.save({ contact_email: 'default@group.com', name: 'Default Group' }) + + const result = await createStripeConnectedAccount({ + groupId: group.id, + country: 'CA' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.accountId).to.equal('acct_test_123') + }) + + it('rejects creation for non-authenticated users', async () => { + await expect( + createStripeConnectedAccount({ + groupId: group.id, + email: 'test@example.com', + businessName: 'Test' + }, { session: null }) + ).to.be.rejectedWith('You must be logged in to create a connected account') + }) + + it('rejects creation for non-admin users', async () => { + await expect( + createStripeConnectedAccount({ + groupId: group.id, + email: 'test@example.com', + businessName: 'Test' + }, { session: { userId: user.id } }) + ).to.be.rejectedWith('You must be a group administrator to create a connected account') + }) + + it('rejects creation for non-existent group', async () => { + await expect( + createStripeConnectedAccount({ + groupId: 99999, + email: 'test@example.com', + businessName: 'Test' + }, { session: { userId: adminUser.id } }) + ).to.be.rejectedWith('Group not found') + }) + }) + + describe('createStripeAccountLink', () => { + it('creates an account link for group admins', async () => { + const result = await createStripeAccountLink({ + groupId: group.id, + accountId: 'acct_test_123', + returnUrl: 'https://example.com/return', + refreshUrl: 'https://example.com/refresh' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.url).to.equal('https://connect.stripe.com/setup/test') + expect(result.expiresAt).to.be.a('number') + }) + + it('rejects creation for non-authenticated users', async () => { + await expect( + createStripeAccountLink({ + groupId: group.id, + accountId: 'acct_test_123', + returnUrl: 'https://example.com/return', + refreshUrl: 'https://example.com/refresh' + }, { session: null }) + ).to.be.rejectedWith('You must be logged in to create an account link') + }) + + it('rejects creation for non-admin users', async () => { + await expect( + createStripeAccountLink({ + groupId: group.id, + accountId: 'acct_test_123', + returnUrl: 'https://example.com/return', + refreshUrl: 'https://example.com/refresh' + }, { session: { userId: user.id } }) + ).to.be.rejectedWith('You must be a group administrator to manage payments') + }) + }) + + describe('stripeAccountStatus', () => { + it('returns account status for group members', async () => { + const result = await stripeAccountStatus({ + groupId: group.id, + accountId: 'acct_test_123' + }, { session: { userId: user.id } }) + + expect(result.accountId).to.equal('acct_test_123') + expect(result.chargesEnabled).to.be.true + expect(result.payoutsEnabled).to.be.true + expect(result.detailsSubmitted).to.be.true + expect(result.email).to.equal('test@example.com') + }) + + it('rejects status check for non-authenticated users', async () => { + await expect( + stripeAccountStatus({ + groupId: group.id, + accountId: 'acct_test_123' + }, { session: null }) + ).to.be.rejectedWith('You must be logged in to view account status') + }) + + it('rejects status check for non-group members', async () => { + const nonMember = await factories.user().save() + + await expect( + stripeAccountStatus({ + groupId: group.id, + accountId: 'acct_test_123' + }, { session: { userId: nonMember.id } }) + ).to.be.rejectedWith('You must be a member of this group to view payment status') + }) + }) + + describe('createStripeProduct', () => { + it('creates a product for group admins', async () => { + const contentAccess = { + [group.id]: { + trackIds: [1, 2], + roleIds: [1] + } + } + + const result = await createStripeProduct({ + groupId: group.id, + accountId: 'acct_test_123', + name: 'Premium Membership', + description: 'Access to premium content', + priceInCents: 2000, + currency: 'usd', + contentAccess, + renewalPolicy: 'manual', + duration: 365 + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.productId).to.equal('prod_test_123') + expect(result.priceId).to.equal('price_test_123') + expect(result.name).to.equal('Premium Membership') + expect(result.databaseId).to.exist + + // Verify the product was saved to database + const savedProduct = await StripeProduct.where({ id: result.databaseId }).fetch() + expect(savedProduct.get('group_id')).to.equal(group.id) + expect(savedProduct.get('stripe_product_id')).to.equal('prod_test_123') + expect(savedProduct.get('name')).to.equal('Premium Membership') + expect(savedProduct.get('price_in_cents')).to.equal(2000) + expect(savedProduct.get('content_access')).to.deep.equal(contentAccess) + expect(savedProduct.get('renewal_policy')).to.equal('manual') + expect(savedProduct.get('duration')).to.equal(365) + }) + + it('creates a product with minimal required fields', async () => { + const result = await createStripeProduct({ + groupId: group.id, + accountId: 'acct_test_123', + name: 'Basic Product', + description: 'A basic product', + priceInCents: 1000 + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.name).to.equal('Basic Product') + + const savedProduct = await StripeProduct.where({ id: result.databaseId }).fetch() + expect(savedProduct.get('currency')).to.equal('usd') // default + expect(savedProduct.get('content_access')).to.deep.equal({}) // default + expect(savedProduct.get('renewal_policy')).to.equal('manual') // default + }) + + it('rejects creation for non-authenticated users', async () => { + await expect( + createStripeProduct({ + groupId: group.id, + accountId: 'acct_test_123', + name: 'Test Product', + priceInCents: 1000 + }, { session: null }) + ).to.be.rejectedWith('You must be logged in to create a product') + }) + + it('rejects creation for non-admin users', async () => { + await expect( + createStripeProduct({ + groupId: group.id, + accountId: 'acct_test_123', + name: 'Test Product', + priceInCents: 1000 + }, { session: { userId: user.id } }) + ).to.be.rejectedWith('You must be a group administrator to create products') + }) + }) + + describe('stripeProducts', () => { + it('lists products for group admins', async () => { + const result = await stripeProducts({ + groupId: group.id, + accountId: 'acct_test_123' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.products).to.have.length(1) + expect(result.products[0].id).to.equal('prod_test_123') + expect(result.products[0].name).to.equal('Test Product') + expect(result.products[0].description).to.equal('A test product') + }) + + it('rejects listing for non-authenticated users', async () => { + await expect( + stripeProducts({ + groupId: group.id, + accountId: 'acct_test_123' + }, { session: null }) + ).to.be.rejectedWith('You must be logged in to view products') + }) + + it('rejects listing for non-admin users', async () => { + await expect( + stripeProducts({ + groupId: group.id, + accountId: 'acct_test_123' + }, { session: { userId: user.id } }) + ).to.be.rejectedWith('You must be a group administrator to view products') + }) + }) + + describe('createStripeCheckoutSession', () => { + it('creates a checkout session', async () => { + const result = await createStripeCheckoutSession({ + groupId: group.id, + accountId: 'acct_test_123', + priceId: 'price_test_123', + quantity: 1, + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + metadata: { source: 'web' } + }, { session: { userId: user.id } }) + + expect(result.success).to.be.true + expect(result.sessionId).to.equal('cs_test_123') + expect(result.url).to.equal('https://checkout.stripe.com/test') + }) + + it('creates a checkout session with default quantity', async () => { + const result = await createStripeCheckoutSession({ + groupId: group.id, + accountId: 'acct_test_123', + priceId: 'price_test_123', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel' + }, { session: { userId: user.id } }) + + expect(result.success).to.be.true + expect(result.sessionId).to.equal('cs_test_123') + }) + + it('allows unauthenticated checkout sessions', async () => { + const result = await createStripeCheckoutSession({ + groupId: group.id, + accountId: 'acct_test_123', + priceId: 'price_test_123', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel' + }, { session: null }) + + expect(result.success).to.be.true + expect(result.sessionId).to.equal('cs_test_123') + }) + + it('includes user ID in metadata when authenticated', async () => { + const result = await createStripeCheckoutSession({ + groupId: group.id, + accountId: 'acct_test_123', + priceId: 'price_test_123', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + metadata: { custom: 'data' } + }, { session: { userId: user.id } }) + + expect(result.success).to.be.true + // Note: In a real test, you'd verify the metadata was passed correctly to StripeService + }) + }) +}) diff --git a/apps/backend/api/graphql/schema.graphql b/apps/backend/api/graphql/schema.graphql index 1a7a1c7b1f..046637032a 100644 --- a/apps/backend/api/graphql/schema.graphql +++ b/apps/backend/api/graphql/schema.graphql @@ -789,6 +789,8 @@ type Group { name: String # For the current logged in user how many prerequisite groups must they join before they can join this one numPrerequisitesLeft: Int + # Whether this group requires purchased membership (paywall enabled) + paywall: Boolean # Total number of posts in this group postCount: Int # The purpose statement for the group @@ -801,6 +803,8 @@ type Group { stewardDescriptor: String # Plural word used to describe stewards of this group. Defaults to "Stewards" stewardDescriptorPlural: String + # Stripe Connected Account ID for accepting payments + stripeAccountId: String # Right now can either by 'farm' or null type: String # Word used to describe this type of group. Defaults to Group, or if type is set then defaults to the type (e.g. Farm) @@ -1697,6 +1701,8 @@ type Membership { createdAt: Date # The common roles this member has in this group commonRoles: CommonRoleQuerySet + # When this membership expires (mirrored from content_access table) + expiresAt: Date groupId: ID group: Group # Is this group membership a moderator role. TODO: remove once mobile app has been updated to use new roles @@ -1768,6 +1774,8 @@ type MembershipCommonRoleQuerySet { type MembershipGroupRole { id: ID createdAt: Date + # When this role membership expires (mirrored from content_access table) + expiresAt: Date group: Group groupId: ID groupRoleId: ID @@ -1918,6 +1926,8 @@ type Person { contactPhone: String # When loading through a track this is when they were enrolled in the track enrolledAt: Date + # When loading through a track this is when their access to the track expires (from content_access via tracks_users) + expiresAt: Date # The URL to this person's Facebook profile facebookUrl: String # False for new users who have not completed the registration process @@ -2482,6 +2492,8 @@ type TopicFollowSettings { type Track { id: ID + # Whether this track requires purchased access (paid content) + accessControlled: Boolean # The word used in this track to describe an action actionDescriptor: String # The word used in this track to describe multiple actions @@ -2504,6 +2516,8 @@ type Track { didComplete: Boolean # The users enrolled in this track enrolledUsers: PersonQuerySet + # When the current user's access to this track expires (from content_access via tracks_users) + expiresAt: Date # The total number of groups this track is a part of groupsTotal: Int # Whether the current user is enrolled in this track @@ -2547,6 +2561,8 @@ type TrackUser { completedAt: Date createdAt: Date enrolledAt: Date + # When this user's access to the track expires (mirrored from content_access table) + expiresAt: Date settings: JSON updatedAt: Date group: Group @@ -2554,6 +2570,37 @@ type TrackUser { user: Person } +# Stripe product for paid content access +type StripeProduct { + id: ID + createdAt: Date + updatedAt: Date + # The group this product belongs to + group: Group + # Stripe product ID from Stripe API + stripeProductId: String + # Stripe price ID from Stripe API + stripePriceId: String + # Product name + name: String + # Product description + description: String + # Price in cents + priceInCents: Int + # Currency code (e.g. 'usd') + currency: String + # Whether this product is active + active: Boolean + # Optional track this product grants access to (legacy field) + track: Track + # JSONB object defining what access this product grants + contentAccess: JSON + # Renewal policy: automatic or manual + renewalPolicy: String + # Duration: month, season, annual, lifetime, or null for no expiration + duration: String +} + # Current user's personal settings type UserSettings { # Has this person seen the tour that displays the first time someone logs in @@ -2991,6 +3038,49 @@ type Mutation { ): SignupResult # Upvote a post, or remove upvote. DEPRECATED, use reactOn instead vote(postId: ID, isUpvote: Boolean): Post + + # Stripe Connect mutations + # Create a Stripe Connected Account for a group + createStripeConnectedAccount( + groupId: ID! + email: String! + businessName: String! + country: String + ): StripeConnectedAccountResult + + # Create an Account Link for onboarding + createStripeAccountLink( + groupId: ID! + accountId: String! + returnUrl: String! + refreshUrl: String! + ): StripeAccountLinkResult + + # Get the status of a connected account + stripeAccountStatus( + groupId: ID! + accountId: String! + ): StripeAccountStatusResult + + # Create a product on the connected account + createStripeProduct(input: StripeProductInput!): StripeProductResult + + # List all products for a connected account + stripeProducts( + groupId: ID! + accountId: String! + ): StripeProductsResult + + # Create a checkout session for purchasing a product + createStripeCheckoutSession( + groupId: ID! + accountId: String! + priceId: String! + quantity: Int + successUrl: String! + cancelUrl: String! + metadata: JSON + ): StripeCheckoutSessionResult } # Result of acceptGroupRelationshipInvite mutation @@ -3046,6 +3136,49 @@ type SignupResult { me: Me } +# Stripe Connect result types +type StripeConnectedAccountResult { + id: ID + accountId: String + success: Boolean + message: String +} + +type StripeAccountLinkResult { + url: String + expiresAt: Date + success: Boolean +} + +type StripeAccountStatusResult { + accountId: String + chargesEnabled: Boolean + payoutsEnabled: Boolean + detailsSubmitted: Boolean + email: String + requirements: JSON +} + +type StripeProductResult { + productId: String + priceId: String + name: String + databaseId: ID + success: Boolean + message: String +} + +type StripeProductsResult { + products: [StripeProduct] + success: Boolean +} + +type StripeCheckoutSessionResult { + sessionId: String + url: String + success: Boolean +} + # An Affilation with an organization added to a User profile input AffiliationInput { # The name of the organization the person is affiliated with @@ -3288,6 +3421,8 @@ input GroupInput { name: String # IDs of parent Groups to add this Group to parentIds: [ID] + # Whether this group requires purchased membership (paywall enabled) + paywall: Boolean # IDs of Groups that someone must join before they can join this one prerequisiteGroupIds: [ID] # Purpose statement @@ -3306,6 +3441,8 @@ input GroupInput { stewardDescriptor: String # Word used to describe stewards of this group (people with a Common Role). Defaults to "Stewards" stewardDescriptorPlural: String + # Stripe Connected Account ID for accepting payments + stripeAccountId: String # Right now can either by 'farm' or empty type: String # Word used to describe this type of group. Defaults to Group, or if type is set then defaults to the type (e.g. Farm) @@ -3388,6 +3525,29 @@ input GroupWidgetSettingsInput { title: String } +# Input for creating Stripe products +input StripeProductInput { + # The group this product belongs to + groupId: ID! + # Stripe connected account ID + accountId: String! + # Product name + name: String! + # Product description + description: String + # Price in cents + priceInCents: Int! + # Currency code (e.g. 'usd') + currency: String + # JSONB object defining what access this product grants + # Format: { "groupId": { "trackIds": [1, 2], "roleIds": [3, 4] } } + contentAccess: JSON + # Renewal policy: automatic or manual + renewalPolicy: String + # Duration: month, season, annual, lifetime, or null for no expiration + duration: String +} + # Details passed along when Flagging a Post input InappropriateContentInput { # 'inappropriate', 'offensive', 'abusive', 'illegal', 'safety', 'spam', or 'other' @@ -3645,6 +3805,8 @@ input SavedSearchInput { } input TrackInput { + # Whether this track requires purchased access (paid content) + accessControlled: Boolean # The word used in this track to describe an action actionDescriptor: String # The word used in this track to describe multiple actions diff --git a/apps/backend/api/models/ContentAccess.js b/apps/backend/api/models/ContentAccess.js new file mode 100644 index 0000000000..ec9e6b75f8 --- /dev/null +++ b/apps/backend/api/models/ContentAccess.js @@ -0,0 +1,276 @@ +/* eslint-disable camelcase */ + +module.exports = bookshelf.Model.extend({ + tableName: 'content_access', + requireFetch: false, + hasTimestamps: true, + + /** + * The user who has access + */ + user: function () { + return this.belongsTo(User, 'user_id') + }, + + /** + * The group this access grant is for + */ + group: function () { + return this.belongsTo(Group, 'group_id') + }, + + /** + * The Stripe product this access was granted for (optional - for paid access) + */ + product: function () { + return this.belongsTo(StripeProduct, 'product_id') + }, + + /** + * Optional track that this access grant is for + * If set, this grants access to a specific track within the group + */ + track: function () { + return this.belongsTo(Track, 'track_id') + }, + + /** + * Optional role that this access grant is for + * If set, this grants a specific role within the group + */ + role: function () { + return this.belongsTo(GroupRole, 'role_id') + }, + + /** + * The admin who granted this access (for admin-granted free access) + */ + grantedBy: function () { + return this.belongsTo(User, 'granted_by_id') + }, + + /** + * Check if this access has expired + * @returns {Boolean} + */ + isExpired: function () { + const expiresAt = this.get('expires_at') + return expiresAt && new Date(expiresAt) < new Date() + }, + + /** + * Check if this access is currently active + * @returns {Boolean} + */ + isActive: function () { + return this.get('status') === 'active' && !this.isExpired() + } + +}, { + // Status constants + Status: { + ACTIVE: 'active', + EXPIRED: 'expired', + REVOKED: 'revoked' + }, + + // Access type constants + Type: { + STRIPE_PURCHASE: 'stripe_purchase', + ADMIN_GRANT: 'admin_grant' + }, + + /** + * Create a new content access record + * Note: The database triggers will automatically update related tables + * (group_memberships, tracks_users, group_memberships_group_roles) + * + * @param {Object} attrs - Access attributes + * @param {String|Number} attrs.user_id - User receiving access + * @param {String|Number} attrs.group_id - Group the access is for + * @param {String} attrs.access_type - Type: 'stripe_purchase', 'admin_grant', or 'free' + * @param {String} [attrs.product_id] - Optional Stripe product ID + * @param {String} [attrs.track_id] - Optional track ID + * @param {String} [attrs.role_id] - Optional role ID + * @param {Date} [attrs.expires_at] - Optional expiration date + * @param {String} [attrs.stripe_session_id] - For Stripe purchases + * @param {Number} [attrs.amount_paid] - Amount paid in cents + * @param {String} [attrs.granted_by_id] - Admin who granted access + * @param {Object} [attrs.metadata] - Additional metadata + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + create: async function (attrs, { transacting } = {}) { + // Set defaults + const defaults = { + status: this.Status.ACTIVE, + metadata: {} + } + + return this.forge({ ...defaults, ...attrs }).save({}, { transacting }) + }, + + /** + * Grant free access to content (admin action) + * @param {Object} params + * @param {String|Number} params.userId - User to grant access to + * @param {String|Number} params.groupId - Group to grant access for + * @param {String|Number} params.grantedById - Admin granting the access + * @param {String|Number} [params.productId] - Optional product + * @param {String|Number} [params.trackId] - Optional track + * @param {String|Number} [params.roleId] - Optional role + * @param {Date} [params.expiresAt] - Optional expiration + * @param {String} [params.reason] - Reason for granting access (stored in metadata) + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + grantAccess: async function ({ + userId, + groupId, + grantedById, + productId, + trackId, + roleId, + expiresAt, + reason + }, { transacting } = {}) { + const metadata = {} + if (reason) metadata.reason = reason + + return this.create({ + user_id: userId, + group_id: groupId, + product_id: productId, + track_id: trackId, + role_id: roleId, + access_type: this.Type.ADMIN_GRANT, + granted_by_id: grantedById, + expires_at: expiresAt, + metadata + }, { transacting }) + }, + + /** + * Record a Stripe purchase (called from webhook handler) + * @param {Object} params + * @param {String|Number} params.userId - User who made the purchase + * @param {String|Number} params.groupId - Group the purchase is for + * @param {String|Number} params.productId - Stripe product ID (from stripe_products table) + * @param {String|Number} [params.trackId] - Optional track + * @param {String|Number} [params.roleId] - Optional role + * @param {String} params.sessionId - Stripe checkout session ID + * @param {String} [params.paymentIntentId] - Stripe payment intent ID + * @param {Date} [params.expiresAt] - When access expires + * @param {Object} [params.metadata] - Additional metadata + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + recordPurchase: async function ({ + userId, + groupId, + productId, + trackId, + roleId, + sessionId, + paymentIntentId, + expiresAt, + metadata = {} + }, { transacting } = {}) { + return this.create({ + user_id: userId, + group_id: groupId, + product_id: productId, + track_id: trackId, + role_id: roleId, + access_type: this.Type.STRIPE_PURCHASE, + stripe_session_id: sessionId, + stripe_payment_intent_id: paymentIntentId, + expires_at: expiresAt, + metadata + }, { transacting }) + }, + + /** + * Revoke access (changes status to revoked) + * Note: Database triggers will automatically clear expires_at in related tables + * + * @param {String|Number} accessId - The access record ID + * @param {String|Number} revokedById - Admin who revoked the access + * @param {String} [reason] - Reason for revocation + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + revoke: async function (accessId, revokedById, reason, { transacting } = {}) { + const access = await this.where({ id: accessId }).fetch({ transacting }) + if (!access) { + throw new Error('Access record not found') + } + + const metadata = access.get('metadata') || {} + metadata.revokedAt = new Date().toISOString() + metadata.revokedBy = revokedById + if (reason) metadata.revokeReason = reason + + return access.save({ + status: this.Status.REVOKED, + metadata + }, { transacting }) + }, + + /** + * Check if a user has active access to content + * @param {Object} params + * @param {String|Number} params.userId - User to check + * @param {String|Number} params.groupId - Group to check + * @param {String|Number} [params.productId] - Optional product + * @param {String|Number} [params.trackId] - Optional track + * @param {String|Number} [params.roleId] - Optional role + * @returns {Promise} + */ + checkAccess: async function ({ userId, groupId, productId, trackId, roleId }) { + const query = this.where({ + user_id: userId, + group_id: groupId, + status: this.Status.ACTIVE + }) + + if (productId) query.where({ product_id: productId }) + if (trackId) query.where({ track_id: trackId }) + if (roleId) query.where({ role_id: roleId }) + + const access = await query.fetch() + + // Check if expired + if (access && access.isExpired()) { + // Update status to expired + await access.save({ status: this.Status.EXPIRED }) + return null + } + + return access + }, + + /** + * Get all active access records for a user in a group + * @param {String|Number} userId + * @param {String|Number} groupId + * @returns {Promise>} + */ + forUser: function (userId, groupId) { + return this.where({ + user_id: userId, + group_id: groupId, + status: this.Status.ACTIVE + }).fetchAll() + }, + + /** + * Get all access records for a Stripe session + * (useful for finding all access grants from a bundle purchase) + * @param {String} sessionId - Stripe checkout session ID + * @returns {Promise>} + */ + forStripeSession: function (sessionId) { + return this.where({ stripe_session_id: sessionId }).fetchAll() + } +}) diff --git a/apps/backend/api/models/StripeProduct.js b/apps/backend/api/models/StripeProduct.js new file mode 100644 index 0000000000..e6b9706c9d --- /dev/null +++ b/apps/backend/api/models/StripeProduct.js @@ -0,0 +1,230 @@ +/* eslint-disable camelcase */ + +module.exports = bookshelf.Model.extend({ + tableName: 'stripe_products', + requireFetch: false, + hasTimestamps: true, + + /** + * The group that this Stripe product belongs to + */ + group: function () { + return this.belongsTo(Group, 'group_id') + }, + + /** + * Optional track that this product grants access to + * If set, purchasing this product grants access to the specific track + */ + track: function () { + return this.belongsTo(Track, 'track_id') + }, + + /** + * All content access records associated with this product + * (can be multiple users who purchased this product, or multiple access grants from a bundle) + */ + contentAccess: function () { + // Note: ContentAccess is globally available after models are loaded + return this.hasMany('ContentAccess', 'product_id') + } + +}, { + /** + * Create a new Stripe product record + * @param {Object} attrs - Product attributes (stripe_product_id, stripe_price_id, name, price_in_cents, etc.) + * @param {Object} attrs.content_access - JSONB object defining what access this product grants + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + create: async function (attrs, { transacting } = {}) { + // Set default values if not provided + const defaults = { + content_access: {}, + renewal_policy: 'manual', + duration: null + } + return this.forge({ ...defaults, ...attrs }).save({}, { transacting }) + }, + + /** + * Find a product by its Stripe product ID + * @param {String} stripeProductId - The Stripe product ID + * @returns {Promise} + */ + findByStripeId: function (stripeProductId) { + return this.where({ stripe_product_id: stripeProductId }).fetch() + }, + + /** + * Get all active products for a group + * @param {String|Number} groupId - The group ID + * @returns {Promise>} + */ + forGroup: function (groupId) { + return this.where({ group_id: groupId, active: true }).fetchAll() + }, + + /** + * Get all products for a specific track + * @param {String|Number} trackId - The track ID + * @returns {Promise>} + */ + forTrack: function (trackId) { + return this.where({ track_id: trackId, active: true }).fetchAll() + }, + + /** + * Generate content access records from product definition + * + * Takes the content_access JSONB field and creates individual content_access records + * for each group/track/role combination defined in the product. + * + * @param {Object} params + * @param {String|Number} params.userId - User who purchased the product + * @param {String} params.sessionId - Stripe checkout session ID + * @param {String} [params.paymentIntentId] - Stripe payment intent ID + * @param {Date} [params.expiresAt] - When access expires + * @param {Object} [params.metadata] - Additional metadata + * @param {Object} options - Options including transacting + * @returns {Promise>} Array of created content access records + */ + generateContentAccessRecords: async function ({ + userId, + sessionId, + paymentIntentId, + expiresAt, + metadata = {} + }, { transacting } = {}) { + const contentAccess = this.get('content_access') || {} + const groupId = this.get('group_id') + const productId = this.get('id') + const duration = this.get('duration') + const accessRecords = [] + + // Calculate expiration date if not provided + const calculatedExpiresAt = expiresAt || this.calculateExpirationDate(duration) + + // If content_access is empty, grant basic group access + if (Object.keys(contentAccess).length === 0) { + const record = await ContentAccess.recordPurchase({ + userId, + groupId, + productId, + sessionId, + paymentIntentId, + expiresAt: calculatedExpiresAt, + metadata + }, { transacting }) + accessRecords.push(record) + return accessRecords + } + + // Process each group in the content_access definition + for (const [groupIdStr, groupAccess] of Object.entries(contentAccess)) { + const groupIdNum = parseInt(groupIdStr, 10) + + // Create base group access record + const baseRecord = await ContentAccess.recordPurchase({ + userId, + groupId: groupIdNum, + productId, + sessionId, + paymentIntentId, + expiresAt: calculatedExpiresAt, + metadata: { + ...metadata, + accessType: 'group' + } + }, { transacting }) + accessRecords.push(baseRecord) + + // Add track-specific access records + if (groupAccess.trackIds && Array.isArray(groupAccess.trackIds)) { + for (const trackId of groupAccess.trackIds) { + const trackRecord = await ContentAccess.recordPurchase({ + userId, + groupId: groupIdNum, + productId, + trackId, + sessionId, + paymentIntentId, + expiresAt: calculatedExpiresAt, + metadata: { + ...metadata, + accessType: 'track' + } + }, { transacting }) + accessRecords.push(trackRecord) + } + } + + // Add role-specific access records + if (groupAccess.roleIds && Array.isArray(groupAccess.roleIds)) { + for (const roleId of groupAccess.roleIds) { + const roleRecord = await ContentAccess.recordPurchase({ + userId, + groupId: groupIdNum, + productId, + roleId, + sessionId, + paymentIntentId, + expiresAt: calculatedExpiresAt, + metadata: { + ...metadata, + accessType: 'role' + } + }, { transacting }) + accessRecords.push(roleRecord) + } + } + } + + return accessRecords + }, + + // Renewal policy constants + RenewalPolicy: { + AUTOMATIC: 'automatic', + MANUAL: 'manual' + }, + + // Duration constants + Duration: { + MONTH: 'month', + SEASON: 'season', + ANNUAL: 'annual', + LIFETIME: 'lifetime' + }, + + /** + * Calculate expiration date based on duration + * @param {String} duration - Duration string (month, season, annual, lifetime, or null) + * @param {Date} [startDate] - Start date for calculation (defaults to now) + * @returns {Date|null} Expiration date or null for lifetime/no expiration + */ + calculateExpirationDate: function (duration, startDate = new Date()) { + if (!duration) { + return null // No expiration (lifetime or tracks) + } + + const start = new Date(startDate) + + switch (duration) { + case this.Duration.MONTH: + return new Date(start.getTime() + (30 * 24 * 60 * 60 * 1000)) // 30 days + + case this.Duration.SEASON: + return new Date(start.getTime() + (90 * 24 * 60 * 60 * 1000)) // 90 days + + case this.Duration.ANNUAL: + return new Date(start.getTime() + (365 * 24 * 60 * 60 * 1000)) // 365 days + + case this.Duration.LIFETIME: + return null // No expiration + + default: + return null // Unknown duration, no expiration + } + } +}) diff --git a/apps/backend/api/models/Track.js b/apps/backend/api/models/Track.js index 98e3dd7197..ee263eac0a 100644 --- a/apps/backend/api/models/Track.js +++ b/apps/backend/api/models/Track.js @@ -20,7 +20,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ enrolledUsers: function () { return this.belongsToMany(User, 'tracks_users', 'track_id', 'user_id').query(q => { q.whereNotNull('tracks_users.enrolled_at') - }).orderBy('users.name', 'asc').withPivot(['enrolled_at', 'completed_at']) + }).orderBy('users.name', 'asc').withPivot(['enrolled_at', 'completed_at', 'expires_at']) }, groups: function () { diff --git a/apps/backend/api/services/StripeService.js b/apps/backend/api/services/StripeService.js new file mode 100644 index 0000000000..7a6136498e --- /dev/null +++ b/apps/backend/api/services/StripeService.js @@ -0,0 +1,449 @@ +/** + * StripeService + * + * Service for managing Stripe Connect integration. + * Handles connected account creation, onboarding, product management, + * and payment processing for groups. + */ + +const Stripe = require('stripe') + +// Initialize Stripe with API version +// TODO STRIPE: Replace with your actual Stripe secret key +// Set this in your environment variables as STRIPE_SECRET_KEY +const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY + +// Validate that Stripe secret key is configured +if (!STRIPE_SECRET_KEY) { + throw new Error( + '🔴 STRIPE_SECRET_KEY environment variable is not set. ' + + 'Please add STRIPE_SECRET_KEY to your .env file or environment variables. ' + + 'You can find this in your Stripe Dashboard: https://dashboard.stripe.com/apikeys' + ) +} + +// Initialize Stripe client with the latest API version +const stripe = new Stripe(STRIPE_SECRET_KEY, { + apiVersion: '2025-09-30.clover' // Using the latest Stripe API version +}) + +module.exports = { + + /** + * Creates a new Stripe Connected Account for a group + * + * This allows groups to receive payments directly while the platform + * takes an application fee. The account is created with: + * - Connected account pays Stripe fees + * - Stripe handles payment disputes and losses + * - Connected account gets full dashboard access + * + * @param {Object} params - Account creation parameters + * @param {String} params.email - Email address for the connected account + * @param {String} params.country - Two-letter country code (e.g. 'US', 'GB') + * @param {String} params.businessName - Business/group name + * @returns {Promise} The created Stripe account object + */ + async createConnectedAccount ({ email, country = 'US', businessName }) { + try { + // Validate required parameters + if (!email) { + throw new Error('Email is required to create a connected account') + } + + if (!businessName) { + throw new Error('Business name is required to create a connected account') + } + + // Create the connected account with controller settings + // Note: We use controller properties, NOT top-level type property + const account = await stripe.accounts.create({ + email, + country, + business_profile: { + name: businessName + }, + controller: { + // Platform controls fee collection - connected account pays fees + fees: { + payer: 'account' + }, + // Stripe handles payment disputes and losses + losses: { + payments: 'stripe' + }, + // Connected account gets full access to Stripe dashboard + stripe_dashboard: { + type: 'full' + } + } + }) + + return account + } catch (error) { + console.error('Error creating connected account:', error) + throw new Error(`Failed to create Stripe connected account: ${error.message}`) + } + }, + + /** + * Creates an Account Link for onboarding a connected account + * + * Account Links are temporary URLs that allow connected accounts + * to complete their onboarding and gain access to the Stripe Dashboard. + * Links expire after the specified time. + * + * @param {Object} params - Account link parameters + * @param {String} params.accountId - The Stripe connected account ID + * @param {String} params.refreshUrl - URL to redirect if link expires + * @param {String} params.returnUrl - URL to redirect after onboarding complete + * @returns {Promise} Account link object with url property + */ + async createAccountLink ({ accountId, refreshUrl, returnUrl }) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to create an account link') + } + + if (!refreshUrl || !returnUrl) { + throw new Error('Both refreshUrl and returnUrl are required') + } + + // Create the account link for onboarding + const accountLink = await stripe.accountLinks.create({ + account: accountId, + refresh_url: refreshUrl, + return_url: returnUrl, + type: 'account_onboarding' + }) + + return accountLink + } catch (error) { + console.error('Error creating account link:', error) + throw new Error(`Failed to create account link: ${error.message}`) + } + }, + + /** + * Retrieves the current status of a connected account + * + * This fetches the account directly from Stripe to get the most + * up-to-date onboarding status and capabilities. + * + * @param {String} accountId - The Stripe connected account ID + * @returns {Promise} Account object with status information + */ + async getAccountStatus (accountId) { + try { + // Validate required parameter + if (!accountId) { + throw new Error('Account ID is required to get account status') + } + + // Retrieve the account from Stripe + const account = await stripe.accounts.retrieve(accountId) + + // Return relevant status information + return { + id: account.id, + charges_enabled: account.charges_enabled, + payouts_enabled: account.payouts_enabled, + details_submitted: account.details_submitted, + requirements: account.requirements, + email: account.email, + business_profile: account.business_profile + } + } catch (error) { + console.error('Error retrieving account status:', error) + throw new Error(`Failed to retrieve account status: ${error.message}`) + } + }, + + /** + * Creates a product on a connected account + * + * Products represent the items/memberships/content that the + * connected account is selling. Uses the Stripe-Account header + * to create the product on the connected account, not the platform. + * + * @param {Object} params - Product creation parameters + * @param {String} params.accountId - The Stripe connected account ID + * @param {String} params.name - Product name + * @param {String} params.description - Product description + * @param {Number} params.priceInCents - Price in cents (e.g. 2000 = $20.00) + * @param {String} params.currency - Three-letter currency code (e.g. 'usd') + * @returns {Promise} The created product with default price + */ + async createProduct ({ accountId, name, description, priceInCents, currency = 'usd' }) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to create a product') + } + + if (!name) { + throw new Error('Product name is required') + } + + if (priceInCents === undefined || priceInCents === null) { + throw new Error('Price is required') + } + + if (priceInCents < 0) { + throw new Error('Price must be a positive number') + } + + // Create product on the connected account using stripeAccount parameter + const product = await stripe.products.create({ + name, + description: description || '', + default_price_data: { + unit_amount: priceInCents, + currency: currency.toLowerCase() + } + }, { + stripeAccount: accountId // This header creates the product on the connected account + }) + + return product + } catch (error) { + console.error('Error creating product:', error) + throw new Error(`Failed to create product: ${error.message}`) + } + }, + + /** + * Retrieves all products for a connected account + * + * Lists products using the Stripe-Account header to get products + * from the connected account, not the platform account. + * + * @param {String} accountId - The Stripe connected account ID + * @param {Object} options - Optional pagination parameters + * @param {Number} options.limit - Number of products to retrieve (default: 100) + * @returns {Promise} Array of product objects + */ + async getProducts (accountId, { limit = 100 } = {}) { + try { + // Validate required parameter + if (!accountId) { + throw new Error('Account ID is required to retrieve products') + } + + // List products from the connected account + const products = await stripe.products.list({ + limit, + active: true // Only return active products + }, { + stripeAccount: accountId // This header retrieves from the connected account + }) + + return products.data + } catch (error) { + console.error('Error retrieving products:', error) + throw new Error(`Failed to retrieve products: ${error.message}`) + } + }, + + /** + * Retrieves a single product with its price information + * + * Fetches a product and expands the default price to get + * pricing information in a single call. + * + * @param {String} accountId - The Stripe connected account ID + * @param {String} productId - The product ID to retrieve + * @returns {Promise} Product object with expanded price + */ + async getProduct (accountId, productId) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to retrieve a product') + } + + if (!productId) { + throw new Error('Product ID is required') + } + + // Retrieve the product with expanded price information + const product = await stripe.products.retrieve(productId, { + expand: ['default_price'] + }, { + stripeAccount: accountId + }) + + return product + } catch (error) { + console.error('Error retrieving product:', error) + throw new Error(`Failed to retrieve product: ${error.message}`) + } + }, + + /** + * Retrieves a price object from Stripe + * + * Fetches detailed price information including unit amount and currency. + * + * @param {String} accountId - The Stripe connected account ID + * @param {String} priceId - The price ID to retrieve + * @returns {Promise} Price object with unit_amount, currency, etc. + */ + async getPrice (accountId, priceId) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to retrieve a price') + } + + if (!priceId) { + throw new Error('Price ID is required') + } + + // Retrieve the price from the connected account + const price = await stripe.prices.retrieve(priceId, { + stripeAccount: accountId + }) + + return price + } catch (error) { + console.error('Error retrieving price:', error) + throw new Error(`Failed to retrieve price: ${error.message}`) + } + }, + + /** + * Creates a Checkout Session for purchasing a product + * + * Uses Stripe Hosted Checkout for a secure payment experience. + * Implements Direct Charges with an application fee to monetize + * the transaction. The platform takes a fee, and the rest goes + * to the connected account. + * + * @param {Object} params - Checkout session parameters + * @param {String} params.accountId - The Stripe connected account ID + * @param {String} params.priceId - The price ID to charge + * @param {Number} params.quantity - Quantity to purchase + * @param {Number} params.applicationFeeAmount - Platform fee in cents + * @param {String} params.successUrl - URL to redirect on success + * @param {String} params.cancelUrl - URL to redirect on cancel + * @param {Object} params.metadata - Optional metadata to attach + * @returns {Promise} Checkout session with url to redirect customer + */ + async createCheckoutSession ({ + accountId, + priceId, + quantity = 1, + applicationFeeAmount, + successUrl, + cancelUrl, + metadata = {} + }) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to create a checkout session') + } + + if (!priceId) { + throw new Error('Price ID is required') + } + + if (!applicationFeeAmount || applicationFeeAmount < 0) { + throw new Error('Valid application fee amount is required') + } + + if (!successUrl || !cancelUrl) { + throw new Error('Both success and cancel URLs are required') + } + + // Create checkout session on the connected account + const session = await stripe.checkout.sessions.create({ + line_items: [{ + price: priceId, + quantity + }], + mode: 'payment', + // Direct charge with application fee + payment_intent_data: { + application_fee_amount: applicationFeeAmount, + metadata: { + session_id: 'placeholder' // Will be updated after session creation + } + }, + success_url: successUrl, + cancel_url: cancelUrl, + metadata + }, { + stripeAccount: accountId // Process payment on connected account + }) + + // Update the payment intent metadata with the actual session ID + if (session.payment_intent) { + await stripe.paymentIntents.update(session.payment_intent, { + metadata: { + session_id: session.id + } + }, { + stripeAccount: accountId + }) + } + + return session + } catch (error) { + console.error('Error creating checkout session:', error) + throw new Error(`Failed to create checkout session: ${error.message}`) + } + }, + + /** + * Retrieves a checkout session + * + * Used to verify payment status after customer returns from + * Stripe Hosted Checkout. + * + * @param {String} accountId - The Stripe connected account ID + * @param {String} sessionId - The checkout session ID + * @returns {Promise} Checkout session object + */ + async getCheckoutSession (accountId, sessionId) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required') + } + + if (!sessionId) { + throw new Error('Session ID is required') + } + + // Retrieve the checkout session + const session = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['payment_intent', 'line_items'] + }, { + stripeAccount: accountId + }) + + return session + } catch (error) { + console.error('Error retrieving checkout session:', error) + throw new Error(`Failed to retrieve checkout session: ${error.message}`) + } + }, + + /** + * Utility function to format price from cents to display string + * + * @param {Number} cents - Price in cents + * @param {String} currency - Currency code + * @returns {String} Formatted price string (e.g. "$20.00") + */ + formatPrice (cents, currency = 'usd') { + const dollars = cents / 100 + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency.toUpperCase() + }).format(dollars) + } +} diff --git a/apps/backend/config/routes.js b/apps/backend/config/routes.js index f36f44120f..bc8124f86b 100644 --- a/apps/backend/config/routes.js +++ b/apps/backend/config/routes.js @@ -62,6 +62,12 @@ module.exports.routes = { 'GET /noo/payment/registerStripe': 'PaymentController.registerStripe', 'POST /noo/payment/registerStripe': 'PaymentController.registerStripe', + // Stripe Connect routes + 'GET /noo/stripe/health': 'StripeController.health', + 'GET /noo/stripe/checkout/success': 'StripeController.checkoutSuccess', + 'GET /noo/stripe/checkout/cancel': 'StripeController.checkoutCancel', + 'POST /noo/stripe/webhook': 'StripeController.webhook', + // websockets routes 'POST /noo/user/subscribe': 'UserController.subscribeToUpdates', 'POST /noo/user/unsubscribe': 'UserController.unsubscribeFromUpdates', diff --git a/apps/backend/migrations/20251020160838_paid-content-stripe.js b/apps/backend/migrations/20251020160838_paid-content-stripe.js index 96f3e894ff..88eeed48e2 100644 --- a/apps/backend/migrations/20251020160838_paid-content-stripe.js +++ b/apps/backend/migrations/20251020160838_paid-content-stripe.js @@ -25,6 +25,9 @@ exports.up = async function (knex) { table.string('currency', 3).notNullable().defaultTo('usd') table.boolean('active').defaultTo(true) table.bigInteger('track_id').unsigned().references('id').inTable('tracks') + table.jsonb('content_access').defaultTo('{}').comment('Defines what access this product grants - groups, tracks, roles') + table.string('renewal_policy', 20).defaultTo('manual').comment('Renewal policy: automatic or manual') + table.string('duration', 20).comment('Duration: month, season, annual, lifetime, or null for no expiration') table.timestamps(true, true) table.index(['group_id']) @@ -47,8 +50,6 @@ exports.up = async function (knex) { // Stripe-specific fields (nullable for non-Stripe grants) table.string('stripe_session_id', 255) table.string('stripe_payment_intent_id', 255) - table.integer('amount_paid').defaultTo(0) // 0 for free grants - table.string('currency', 3).defaultTo('usd') // Status: 'active', 'expired', 'revoked' table.string('status', 50).notNullable().defaultTo('active') diff --git a/apps/backend/migrations/schema.sql b/apps/backend/migrations/schema.sql index 8ad9c1d949..b61bdbf542 100644 --- a/apps/backend/migrations/schema.sql +++ b/apps/backend/migrations/schema.sql @@ -1117,7 +1117,8 @@ CREATE TABLE public.group_memberships ( new_post_count integer DEFAULT 0, group_data_type integer, project_role_id bigint, - nav_order integer + nav_order integer, + expires_at timestamp with time zone ); @@ -1164,7 +1165,8 @@ CREATE TABLE public.group_memberships_group_roles ( group_role_id bigint NOT NULL, active boolean, created_at timestamp with time zone, - updated_at timestamp with time zone + updated_at timestamp with time zone, + expires_at timestamp with time zone ); @@ -1398,7 +1400,12 @@ CREATE TABLE public.groups ( allow_in_public boolean DEFAULT false, purpose text, welcome_page text, - website_url text + website_url text, + stripe_account_id bigint, + stripe_charges_enabled boolean DEFAULT false, + stripe_payouts_enabled boolean DEFAULT false, + stripe_details_submitted boolean DEFAULT false, + paywall boolean DEFAULT false ); @@ -2500,7 +2507,6 @@ CREATE TABLE public.users ( location character varying(255), url character varying(255), tagline character varying(255), - stripe_account_id bigint, location_id bigint, contact_email character varying(255), contact_phone character varying(255), @@ -2850,6 +2856,90 @@ CREATE SEQUENCE public.stripe_accounts_id_seq ALTER SEQUENCE public.stripe_accounts_id_seq OWNED BY public.stripe_accounts.id; +-- +-- Name: stripe_products; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.stripe_products ( + id bigint DEFAULT nextval('public.stripe_products_id_seq'::regclass) NOT NULL, + group_id bigint NOT NULL, + stripe_product_id character varying(255) NOT NULL, + stripe_price_id character varying(255) NOT NULL, + name character varying(255) NOT NULL, + description text, + price_in_cents integer NOT NULL, + currency character varying(3) NOT NULL DEFAULT 'usd'::character varying, + active boolean DEFAULT true, + track_id bigint, + content_access jsonb DEFAULT '{}'::jsonb, + renewal_policy character varying(20) DEFAULT 'manual'::character varying, + duration character varying(20), + created_at timestamp with time zone, + updated_at timestamp with time zone +); + + +-- +-- Name: stripe_products_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.stripe_products_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: stripe_products_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.stripe_products_id_seq OWNED BY public.stripe_products.id; + + +-- +-- Name: content_access; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.content_access ( + id bigint DEFAULT nextval('public.content_access_id_seq'::regclass) NOT NULL, + user_id bigint NOT NULL, + group_id bigint NOT NULL, + product_id bigint, + track_id bigint, + role_id integer, + access_type character varying(50) NOT NULL, + stripe_session_id character varying(255), + stripe_payment_intent_id character varying(255), + status character varying(50) NOT NULL DEFAULT 'active'::character varying, + granted_by_id bigint, + expires_at timestamp with time zone, + metadata jsonb DEFAULT '{}'::jsonb, + created_at timestamp with time zone, + updated_at timestamp with time zone +); + + +-- +-- Name: content_access_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.content_access_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: content_access_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.content_access_id_seq OWNED BY public.content_access.id; + + -- -- Name: tags; Type: TABLE; Schema: public; Owner: - -- @@ -2940,7 +3030,8 @@ CREATE TABLE public.tracks ( num_people_completed integer DEFAULT 0, completion_role_id bigint, completion_role_type character varying(255), - action_descriptor character varying(255) + action_descriptor character varying(255), + access_controlled boolean DEFAULT false ); @@ -3010,7 +3101,8 @@ CREATE TABLE public.tracks_users ( enrolled_at timestamp with time zone, completed_at timestamp with time zone, created_at timestamp with time zone, - updated_at timestamp with time zone + updated_at timestamp with time zone, + expires_at timestamp with time zone ); @@ -5361,6 +5453,76 @@ CREATE INDEX user_verification_codes_email_index ON public.user_verification_cod CREATE INDEX zapier_triggers_groups_zapier_trigger_id_index ON public.zapier_triggers_groups USING btree (zapier_trigger_id); +-- +-- Name: stripe_products_group_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stripe_products_group_id_index ON public.stripe_products USING btree (group_id); + + +-- +-- Name: stripe_products_stripe_product_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stripe_products_stripe_product_id_index ON public.stripe_products USING btree (stripe_product_id); + + +-- +-- Name: content_access_user_id_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_user_id_status_index ON public.content_access USING btree (user_id, status); + + +-- +-- Name: content_access_group_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_group_id_index ON public.content_access USING btree (group_id); + + +-- +-- Name: content_access_product_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_product_id_index ON public.content_access USING btree (product_id); + + +-- +-- Name: content_access_track_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_track_id_index ON public.content_access USING btree (track_id); + + +-- +-- Name: content_access_role_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_role_id_index ON public.content_access USING btree (role_id); + + +-- +-- Name: content_access_stripe_session_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_stripe_session_id_index ON public.content_access USING btree (stripe_session_id); + + +-- +-- Name: content_access_access_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_access_type_index ON public.content_access USING btree (access_type); + + +-- +-- Name: content_access_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_status_index ON public.content_access USING btree (status); + + -- -- Name: activities activities_contribution_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6257,6 +6419,14 @@ ALTER TABLE ONLY public.groups ADD CONSTRAINT groups_created_by_id_foreign FOREIGN KEY (created_by_id) REFERENCES public.users(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: groups groups_stripe_account_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.groups + ADD CONSTRAINT groups_stripe_account_id_foreign FOREIGN KEY (stripe_account_id) REFERENCES public.stripe_accounts(id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: groups groups_location_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6321,6 +6491,70 @@ ALTER TABLE ONLY public.groups_tracks ADD CONSTRAINT groups_tracks_track_id_foreign FOREIGN KEY (track_id) REFERENCES public.tracks(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: stripe_products stripe_products_group_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stripe_products + ADD CONSTRAINT stripe_products_group_id_foreign FOREIGN KEY (group_id) REFERENCES public.groups(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: stripe_products stripe_products_track_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stripe_products + ADD CONSTRAINT stripe_products_track_id_foreign FOREIGN KEY (track_id) REFERENCES public.tracks(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_group_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_group_id_foreign FOREIGN KEY (group_id) REFERENCES public.groups(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_product_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_product_id_foreign FOREIGN KEY (product_id) REFERENCES public.stripe_products(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_track_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_track_id_foreign FOREIGN KEY (track_id) REFERENCES public.tracks(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_role_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_role_id_foreign FOREIGN KEY (role_id) REFERENCES public.groups_roles(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_granted_by_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_granted_by_id_foreign FOREIGN KEY (granted_by_id) REFERENCES public.users(id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: group_join_questions_answers join_request_question_answers_join_request_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6865,6 +7099,155 @@ ALTER TABLE ONLY public.zapier_triggers ADD CONSTRAINT zapier_triggers_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: sync_content_access_expires_at(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE OR REPLACE FUNCTION sync_content_access_expires_at() +RETURNS TRIGGER AS $$ +DECLARE + latest_expires_at TIMESTAMP; +BEGIN + -- Update group_memberships if track_id is NULL (includes group-level and role-based purchases) + -- Use the MOST RECENT expires_at from all active content_access records for this user+group + IF NEW.track_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND track_id IS NULL + AND status = 'active'; + + UPDATE group_memberships + SET expires_at = latest_expires_at, + updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id; + END IF; + + -- If track_id is set, update tracks_users with most recent expires_at for this track + IF NEW.track_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND track_id = NEW.track_id + AND status = 'active'; + + UPDATE tracks_users + SET expires_at = latest_expires_at, + updated_at = NOW() + WHERE user_id = NEW.user_id + AND track_id = NEW.track_id; + END IF; + + -- If role_id is set, update group_memberships_group_roles with most recent expires_at + IF NEW.role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND group_role_id = NEW.role_id + AND status = 'active'; + + UPDATE group_memberships_group_roles + SET expires_at = latest_expires_at + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND group_role_id = NEW.role_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +-- +-- Name: clear_content_access_expires_at(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE OR REPLACE FUNCTION clear_content_access_expires_at() +RETURNS TRIGGER AS $$ +DECLARE + latest_expires_at TIMESTAMP; +BEGIN + -- Update group_memberships with most recent expires_at from OTHER active records + -- If no other active records exist, this will set expires_at to NULL + IF NEW.track_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND track_id IS NULL + AND status = 'active' + AND id != NEW.id; -- Exclude the record being revoked + + UPDATE group_memberships + SET expires_at = latest_expires_at, + updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id; + END IF; + + -- If track_id is set, update tracks_users with most recent from other active records + IF NEW.track_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND track_id = NEW.track_id + AND status = 'active' + AND id != NEW.id; -- Exclude the record being revoked + + UPDATE tracks_users + SET expires_at = latest_expires_at, + updated_at = NOW() + WHERE user_id = NEW.user_id + AND track_id = NEW.track_id; + END IF; + + -- If role_id is set, update group_memberships_group_roles with most recent + IF NEW.role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND group_role_id = NEW.role_id + AND status = 'active' + AND id != NEW.id; -- Exclude the record being revoked + + UPDATE group_memberships_group_roles + SET expires_at = latest_expires_at + WHERE user_id = NEW.user_id + AND group_id = NEW.group_id + AND group_role_id = NEW.role_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +-- +-- Name: content_access_expires_at_sync; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER content_access_expires_at_sync + AFTER INSERT OR UPDATE OF expires_at ON public.content_access + FOR EACH ROW + WHEN (NEW.status = 'active') + EXECUTE FUNCTION sync_content_access_expires_at(); + + +-- +-- Name: content_access_expires_at_clear; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER content_access_expires_at_clear + AFTER UPDATE OF status ON public.content_access + FOR EACH ROW + WHEN (NEW.status IN ('revoked', 'expired')) + EXECUTE FUNCTION clear_content_access_expires_at(); + + -- -- PostgreSQL database dump complete -- diff --git a/apps/backend/package.json b/apps/backend/package.json index 406ca5fa33..a6708901dd 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -148,7 +148,7 @@ "sharp": "^0.30.4", "socket.io-emitter": "^3.1.1", "stream-buffers": "^3.0.2", - "stripe": "^6.15.0", + "stripe": "^17.5.0", "uuid": "^9.0.0", "validator": "^3.22.1", "wkx": "^0.5.0" @@ -184,6 +184,7 @@ "Comment", "CommentTag", "CommonRole", + "ContentAccess", "ContextWidget", "CookieConsent", "CustomView", @@ -242,6 +243,8 @@ "SavedSearch", "Search", "Skill", + "StripeService", + "StripeProduct", "Tag", "TagFollow", "Thank", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e5fe0d90ca..80f362547b 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -203,7 +203,7 @@ "kitType": "app", "alignDeps": { "requirements": [ - "react-native@0.70" + "react-native@0.77.3" ], "capabilities": [ "animation", diff --git a/docs/CONTENT_ACCESS_SUMMARY.md b/docs/CONTENT_ACCESS_SUMMARY.md new file mode 100644 index 0000000000..bdb1855f65 --- /dev/null +++ b/docs/CONTENT_ACCESS_SUMMARY.md @@ -0,0 +1,280 @@ +# Content Access System Summary + +## Overview + +The database schema has been updated to support **content access, for both paid (via Stripe) and freely granted (by admin)**. + +## Database Changes + +### Migration: `20251020160838_paid-content-stripe.js` + +#### 1. Moved Stripe Association from Users to Groups +- Removed `stripe_account_id` from `users` table +- Added `stripe_account_id` to `groups` table +- Added Stripe status columns to groups: + - `stripe_charges_enabled` + - `stripe_payouts_enabled` + - `stripe_details_submitted` + +#### 2. Created `stripe_products` Table +Tracks products/offerings created by groups: +- Links to groups and optionally to tracks +- Stores Stripe product/price IDs +- Tracks product details (name, description, price, currency) + +#### 3. Created `content_access` Table (Key Feature!) +**This supplements the original `stripe_purchases` concept** to support multiple access types: + +**Access Types:** +- `stripe_purchase` - User paid via Stripe +- `admin_grant` - Admin gave free access + +**Key Columns:** +- `user_id` - Who has access +- `group_id` - Which group +- `product_id` - Product id denotes the entity in Stripe that tracks an offering/product +- `track_id` - Optional: Grants access to a specific track within a group +- `role_id` - Optional: References `groups_roles` table, represents role-based access grants by admins +- `access_type` - How they got access (stripe_purchase, admin_grant) +- `stripe_session_id` - **Nullable** (only for Stripe purchases) +- `amount_paid` - Amount paid (0 for free grants) +- `status` - active/expired/revoked +- `granted_by_id` - Admin who granted access (for admin grants) +- `expires_at` - Optional expiration date +- `metadata` - Flexible JSONB for additional info + +**Access Granularity:** +Access can be granted at multiple levels: +- **Group-level**: Just `group_id` set - access to entire group content +- **Track-level**: `track_id` set - access to specific track content +- **Role-level**: `role_id` set - access tied to a specific group role + +**Automatic Expiration Mirroring:** +The `expires_at` value from `content_access` is automatically mirrored to related tables using PostgreSQL triggers: +- **When track_id is NULL** (group-level or role-based): `group_memberships.expires_at` (based on `user_id` + `group_id`) +- **When track_id is set**: `tracks_users.expires_at` (based on `user_id` + `track_id`) +- **When role_id is set**: `group_memberships_group_roles.expires_at` (based on `user_id` + `group_id` + `group_role_id`) + +**Important:** Track purchases do NOT update `group_memberships.expires_at`. This prevents a one-off track purchase from overwriting a long-term group membership expiration. However, role bumps DO update group membership (having a role implies group access). + +This avoids the need for JOINs when checking expiration - you can query the respective tables directly. + +## Database Triggers for Automatic Expiration Sync + +### Overview +To avoid constantly joining `content_access` with membership tables, PostgreSQL triggers automatically keep `expires_at` values in sync. + +### How It Works + +**When content access is granted or updated:** +1. User inserts/updates a record in `content_access` with `expires_at` set +2. Trigger `content_access_expires_at_sync` fires automatically +3. Function `sync_content_access_expires_at()` executes: + - **If track_id is NULL**: Updates `group_memberships.expires_at` (group-level or role-based purchase) + - **If track_id is set**: Updates `tracks_users.expires_at` (track-specific purchase, does NOT update group_memberships) + - **If role_id is set**: Updates `group_memberships_group_roles.expires_at` (role-specific purchase, also updates group_memberships if no track_id) + +**When access is revoked or expires:** +1. Status changes to 'revoked' or 'expired' in `content_access` +2. Trigger `content_access_expires_at_clear` fires +3. Function `clear_content_access_expires_at()` executes: + - Clears `expires_at` (sets to NULL) in the appropriate table based on what IDs are set + +**Bundle Purchases (One Product, Multiple Access Grants):** +- A single Stripe product can create multiple `content_access` records +- Example: User buys "Premium Bundle" which grants: + - Group membership access (content_access record with no track_id) + - Track A access (content_access record with track_id = A) + - Track B access (content_access record with track_id = B) +- All three records reference the same `product_id` +- Each record's trigger independently updates its respective table + +### Benefits + +✅ **No Application Code**: Triggers run at database level +✅ **Always Consistent**: Can't be bypassed or forgotten +✅ **Fast Queries**: No JOINs needed to check expiration +✅ **Atomic**: Happens in same transaction +✅ **Automatic**: Works for all code paths (GraphQL, direct SQL, etc.) + +### Example Usage + +```javascript +// Check if a membership is expired (no JOIN needed!) +const membership = await GroupMembership + .where({ user_id: userId, group_id: groupId }) + .fetch() + +if (membership.get('expires_at') && membership.get('expires_at') < new Date()) { + // Membership has expired +} + +// Check if track access is expired (no JOIN needed!) +const trackAccess = await knex('tracks_users') + .where({ user_id: userId, track_id: trackId }) + .first() + +if (trackAccess.expires_at && trackAccess.expires_at < new Date()) { + // Track access has expired +} +``` + +### Protection Against Expiration Overwriting + +**Scenario:** User has long-term group membership, buys short-term track access + +```javascript +// Step 1: User buys 1-year group membership +// Creates: content_access { user_id, group_id, expires_at: '2026-01-01', track_id: NULL } +// Trigger updates: group_memberships.expires_at = '2026-01-01' ✓ + +// Step 2: Same user buys 1-month access to Track A (3 months later) +// Creates: content_access { user_id, group_id, track_id: 123, expires_at: '2025-05-01' } +// Trigger updates: tracks_users.expires_at = '2025-05-01' ✓ +// Trigger DOES NOT update: group_memberships.expires_at (still '2026-01-01') ✓ + +// Result: User retains 1-year group membership while having separate track expiration +``` + +**Scenario:** User buys role-based access + +```javascript +// User purchases "Moderator Role" - 6 months +// Creates: content_access { user_id, group_id, role_id: 5, track_id: NULL, expires_at: '2025-07-22' } +// Trigger updates: group_memberships.expires_at = '2025-07-22' ✅ +// Trigger updates: group_memberships_group_roles.expires_at = '2025-07-22' ✅ + +// Result: User has both group membership AND role access until July 2025 +// This makes sense because having a role requires group membership +``` + +**Why this matters:** +- Without this logic, buying a 1-month track would reset the group membership to expire in 1 month +- Now each access level maintains its own independent expiration +- Group membership expiration changes with group-level purchases OR role purchases (not track purchases) +- Having a role implies having group membership, so role expiration = group membership expiration + +## GraphQL Mutations + +### New File: `api/graphql/mutations/contentAccess.js` + +#### Admin Operations: + +**`grantContentAccess`** - Grant free access to content +```graphql +mutation { + grantContentAccess( + userId: "456" + groupId: "123" + productId: "789" # or trackId: "101" or roleId: "202" + expiresAt: "2025-12-31T23:59:59Z" + reason: "Staff member" + ) { + id + success + message + } +} +``` + +**`revokeContentAccess`** - Revoke any access (paid or free) +```graphql +mutation { + revokeContentAccess( + accessId: "123" + reason: "User violated terms" + ) { + success + message + } +} +``` + +**`checkContentAccess`** - Check if user has access +```graphql +query { + checkContentAccess( + userId: "456" + groupId: "123" + productId: "789" + ) { + hasAccess + accessType + expiresAt + } +} +``` + +**`recordStripePurchase`** - Internal mutation for webhook handler +```graphql +mutation { + recordStripePurchase( + userId: "456" + groupId: "123" + productId: "789" + sessionId: "cs_xxx" + paymentIntentId: "pi_xxx" + amountPaid: 2000 + currency: "usd" + ) { + id + success + } +} +``` + +## Use Cases + +### 1. Paid Content via Stripe +1. Admin creates product with price +2. User purchases via Stripe Checkout +3. Webhook handler calls `recordStripePurchase` +4. Access record created with `access_type: 'stripe_purchase'` + +### 2. Admin-Granted Free Access +1. Admin uses `grantContentAccess` mutation +2. Access record created with `access_type: 'admin_grant'` +3. No Stripe involvement, no payment required +4. Can set expiration date if desired +5. Reason stored in metadata for audit trail + +### 3. Revoking Access +1. Admin can revoke any access (paid or free) +2. Status changed to 'revoked' +3. Metadata records who revoked and why + +## Implementation Status + +✅ **Completed:** +- Database migration schema +- GraphQL mutation signatures and documentation +- Support for multiple access types + +⚠️ **TODO STRIPE (marked in code):** +- Create frontend UI for admin grants + +## Benefits of This Design + +1. **Flexible**: Supports both paid and free access in same table +2. **Auditable**: Tracks who granted access and why +3. **Reversible**: Admins can revoke any access +4. **Temporal**: Supports optional expiration dates +5. **Comprehensive**: Works for both products and tracks +6. **Extensible**: JSONB metadata field for future needs + +## Running the Migration + +```bash +cd apps/backend +yarn knex migrate:latest +``` + +## Next Steps + +1. Run the migration +2. Create `ContentAccess` Bookshelf model +3. Implement the TODO comments in `contentAccess.js` +4. Update Stripe webhook to record purchases +5. Add access checks before serving protected content +6. Build admin UI for granting/revoking access + diff --git a/docs/PAID_CONTENT_WORKFLOW.md b/docs/PAID_CONTENT_WORKFLOW.md new file mode 100644 index 0000000000..a607783152 --- /dev/null +++ b/docs/PAID_CONTENT_WORKFLOW.md @@ -0,0 +1,325 @@ +# Paid Content Workflow Guide + +This document provides a comprehensive walkthrough of the entire paid content workflow, from admin setup to user purchase completion. + +## Table of Contents + +1. [Overview](#overview) +2. [Admin Setup: Connecting Group to Stripe](#admin-setup-connecting-group-to-stripe) +3. [Admin Setup: Defining Offerings](#admin-setup-defining-offerings) +4. [User Experience: Exploring and Purchasing](#user-experience-exploring-and-purchasing) +5. [Stripe Checkout Flow](#stripe-checkout-flow) +6. [Post-Purchase Verification](#post-purchase-verification) +7. [Content Access Creation](#content-access-creation) +8. [Technical Implementation Details](#technical-implementation-details) + +## Overview + +The paid content system enables groups to: +- Connect their group to Stripe for payment processing +- Define and manage content offerings +- Automatically grant access upon successful payment +- Manage both paid and admin-granted access + +## Admin Setup: Connecting Group to Stripe + +### User Steps +1. **Navigate to Group Settings** + - Admin goes to `/groups/{groupSlug}/settings/paid-content` + - Only group administrators can access this section + +2. **Create Stripe Connected Account** + - Click "Connect to Stripe" button + - Fill out business information: + - Email address + - Business name + - Country + - System creates **unverified** Stripe Connected Account + - Account requires completion of Stripe's onboarding process + +3. **Complete Stripe Onboarding** + - System generates Account Link URL + - Admin is redirected to Stripe onboarding flow + - **Required verification steps:** + - Business verification and tax information + - Bank account details for payouts + - Identity verification (KYC - Know Your Customer) + - Business type and industry classification + - Additional documentation as required by Stripe + - Returns to group settings page after completion + +4. **Verify Account Status** + - System checks Stripe account status + - Displays current capabilities: + - `charges_enabled`: Can accept payments (false until verification complete) + - `payouts_enabled`: Can receive payouts (false until bank details added) + - `details_submitted`: Onboarding information submitted (true when complete) + - Shows any pending requirements or verification issues + - Account may be restricted until all requirements are met + +### Functions Invoked +```javascript +// GraphQL Mutation +createStripeConnectedAccount(groupId, email, businessName, country) + +// Backend Service +StripeService.createConnectedAccount({ email, country, businessName }) + +// Account Link Creation +createStripeAccountLink(groupId, accountId, returnUrl, refreshUrl) +``` + +### UI Flow +``` +Group Settings → Paid Content Tab → Connect to Stripe → +Stripe Onboarding → Return to Settings → Account Status Display +``` + +## Admin Setup: Defining Offerings + +### User Steps +1. **Access Product Management** + - Admin navigates to product management section in paid content (group settings) + - Views existing products (if any) + +2. **Create New Product** + - Click "Create Product" button + - Fill out product details: + - **Name**: e.g., "Premium Membership" + - **Description**: Detailed description of what's included + - **Price**: Amount in cents (e.g., 2000 = $20.00) + - **Currency**: Defaults to USD + - **Content Access Definition**: selects what tracks or roles are associated with the product, if any + +3. **Define Content Access** + The `contentAccess` field uses a flexible JSON structure: + ```json + { + "123": { // Group ID + "trackIds": [456, 789], // Optional: specific tracks + "roleIds": [1, 2] // Optional: specific roles + } + } + ``` + +4. **Handle Multiple Durations** + - For the same offering with different durations, the admin must create separate products + - Example: "Premium Monthly" vs "Premium Annual" + - Use product cloning feature to easily create variations + - Each duration = separate Stripe product with different pricing + +5. **Product Cloning Workflow** + - Select existing product + - Click "Clone Product" + - Modify duration, pricing, and content access as needed + - System creates new Stripe product automatically + +### Functions Invoked +```javascript +// GraphQL Mutation +createStripeProduct(input: StripeProductInput!) + +// Backend Service +StripeService.createProduct({ + accountId, + name, + description, + priceInCents, + currency +}) + +// Database Operations +StripeProduct.create({ + group_id, + stripe_product_id, + stripe_price_id, + name, + description, + price_in_cents, + currency, + content_access, + renewal_policy, + duration, + active: true +}) +``` + +### UI Flow +``` +Product Management → Create Product → Fill Details → +Define Content Access → Clone for Variations → Product List +``` + +## User Experience: Exploring and Purchasing + +### User Steps +1. **Discover Offerings** + - User visits group page or dedicated group storefront + - Views available products/offerings + - Reads descriptions and pricing + +2. **Select Product** + - Clicks on desired product + - Reviews what's included in the offering + - Sees pricing and duration information + +3. **Initiate Purchase** + - Clicks "Purchase" or "Buy Now" button + - System creates Stripe Checkout Session + - User is redirected to Stripe-hosted checkout page + +### Functions Invoked +```javascript +// GraphQL Mutation +createStripeCheckoutSession( + groupId, + accountId, + priceId, + quantity, + successUrl, + cancelUrl, + metadata +) + +// Backend Service +StripeService.createCheckoutSession({ + accountId, + priceId, + quantity, + applicationFeeAmount, + successUrl, + cancelUrl, + metadata +}) +``` + +### UI Flow +``` +Group Storefront → Product Details → Purchase Button → +Stripe Checkout Session Creation → Redirect to Stripe +``` + +## Stripe Checkout Flow + +### User Experience on Stripe +1. **Stripe Checkout Page** + - User sees Stripe-hosted checkout form + - Enters payment information (card details, billing info) + - Reviews order summary and pricing + - Completes payment + +2. **Payment Processing** + - Stripe processes the payment + - Handles fraud detection and security + - Applies application fees automatically + +3. **Redirect Back to Platform** + - Upon successful payment, user is redirected to `successUrl` + - Upon cancellation, user is redirected to `cancelUrl` + - URLs include session ID for tracking + +### Technical Details +- **Checkout Session**: Created with application fee percentage +- **Metadata**: Includes groupId, userId, priceAmount, currency +- **Session ID**: Stored in payment intent metadata for webhook correlation +- **Application Fee**: Automatically calculated and applied + +## Post-Purchase Verification + +### User Experience +1. **Purchase Verification Page** + - User lands on `/purchase-verification?session_id={CHECKOUT_SESSION_ID}` + - Page displays: + - "Purchase verification in progress..." + - "Please wait while we confirm your payment" + - "You will receive an email confirmation shortly" + - "Check your email for access details" + +2. **Email Confirmation** + - User receives confirmation email + - Email includes: + - Purchase confirmation details + - Links to access the content they purchased + - Instructions for accessing their new content + - Support contact information + +### Functions Invoked +```javascript +// Webhook Handler +StripeController.webhook(event) + +// Payment Intent Success Handler +handlePaymentIntentSucceeded(event) + +// Content Access Creation +StripeProduct.generateContentAccessRecords({ + userId, + sessionId, + paymentIntentId, + expiresAt, + metadata +}) +``` + +### UI Flow +``` +Stripe Checkout → Payment Success → Redirect to Verification Page → +Email Confirmation → Access Content +``` + +## Content Access Creation + +### Automatic Process +1. **Webhook Trigger** + - Stripe sends `payment_intent.succeeded` webhook + - System verifies webhook signature + - Extracts session and payment intent data + +2. **Product Lookup** + - System finds the StripeProduct by session metadata + - Retrieves product's `content_access` definition + - Calculates expiration date based on product duration + +3. **Access Record Creation** + - System calls `StripeProduct.generateContentAccessRecords()` + - Creates multiple `ContentAccess` records based on product definition: + - Group-level access (if no specific tracks/roles) + - Track-specific access (if trackIds specified) + - Role-based access (if roleIds specified) + +4. **Database Triggers** + - Triggers automatically update related tables: + - `group_memberships.expires_at` + - `tracks_users.expires_at` + - `group_memberships_group_roles.expires_at` + +### Functions Invoked +```javascript +// Webhook Processing +StripeController.handlePaymentIntentSucceeded(event) + +// Product Lookup +StripeProduct.findByStripeId(stripeProductId) + +// Access Record Generation +product.generateContentAccessRecords({ + userId, + sessionId, + paymentIntentId, + expiresAt, + metadata: { source: 'webhook' } +}) + +// Individual Record Creation +ContentAccess.recordPurchase({ + userId, + groupId, + productId, + trackId, + roleId, + sessionId, + paymentIntentId, + expiresAt, + metadata +}) +``` diff --git a/docs/STRIPE_CONNECT_INTEGRATION.md b/docs/STRIPE_CONNECT_INTEGRATION.md new file mode 100644 index 0000000000..cfa4225807 --- /dev/null +++ b/docs/STRIPE_CONNECT_INTEGRATION.md @@ -0,0 +1,428 @@ +# Stripe Connect Integration Guide + +This document provides a comprehensive guide to the Stripe Connect integration that has been added to your Hylo application. + +## Overview + +This integration allows groups to: +- Create Stripe Connected Accounts to receive payments +- Onboard to Stripe using Account Links +- Create products for group memberships and track content +- Display products in a public storefront +- Process payments with application fees using Hosted Checkout + +## Architecture + +The integration uses: +- **Stripe API Version**: `2025-09-30.clover` +- **Connection Type**: Connected Accounts with controller settings (NOT type: 'express' or type: 'standard') +- **Payment Flow**: Direct Charges with application fees +- **Checkout**: Hosted Checkout for simplicity and security +- **Account Access**: Full dashboard access for connected accounts + +## Setup Instructions + +### 1. Environment Variables + +Add the following environment variable to your backend `.env` file: + +```bash +# Stripe Secret Key +# Get this from: https://dashboard.stripe.com/apikeys +STRIPE_SECRET_KEY=sk_test_your_secret_key_here + +# Optional: Webhook Secret (for production) +# Get this from: https://dashboard.stripe.com/webhooks +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here +``` + +**IMPORTANT**: If `STRIPE_SECRET_KEY` is not set, the application will throw a helpful error message explaining where to find it. + +### 2. Install Dependencies + +The Stripe package has been updated to version `^17.5.0`. Run: + +```bash +cd apps/backend +yarn install +``` + +## Backend Components + +### Services + +#### `apps/backend/api/services/StripeService.js` + +Core service that handles all Stripe API calls. Key methods: + +- **`createConnectedAccount({ email, country, businessName })`** + - Creates a new connected account using controller settings + - Platform controls fee collection (connected account pays fees) + - Stripe handles disputes and losses + - Connected account gets full dashboard access + +- **`createAccountLink({ accountId, refreshUrl, returnUrl })`** + - Generates temporary onboarding URL for connected accounts + - Links expire and must be regenerated if needed + +- **`getAccountStatus(accountId)`** + - Fetches current onboarding status from Stripe + - Returns: charges_enabled, payouts_enabled, details_submitted, requirements + +- **`createProduct({ accountId, name, description, priceInCents, currency })`** + - Creates a product on the connected account (not platform account) + - Uses `stripeAccount` header to create on connected account + +- **`getProducts(accountId, { limit })`** + - Lists all products for a connected account + - Uses `stripeAccount` header to fetch from connected account + +- **`createCheckoutSession({ accountId, priceId, quantity, applicationFeeAmount, successUrl, cancelUrl, metadata })`** + - Creates a Stripe Checkout session for purchasing + - Implements Direct Charge with application fee + - Returns URL to redirect customer to + +- **`getCheckoutSession(accountId, sessionId)`** + - Retrieves checkout session after payment + - Used to verify payment status + +### Controllers + +#### `apps/backend/api/controllers/StripeController.js` + +REST endpoints for Stripe operations: + +- `GET /noo/stripe/health` - Verify Stripe configuration +- `GET /noo/stripe/checkout/success?session_id=xxx&account_id=xxx` - Handle successful checkout +- `GET /noo/stripe/checkout/cancel` - Handle cancelled checkout +- `POST /noo/stripe/webhook` - Receive Stripe webhook events (TODO: implement signature verification) + +### GraphQL Mutations + +#### `apps/backend/api/graphql/mutations/stripe.js` + +All operations are available via GraphQL: + +**Mutations:** +- `createStripeConnectedAccount(groupId, email, businessName, country)` +- `createStripeAccountLink(groupId, accountId, returnUrl, refreshUrl)` +- `createStripeProduct(groupId, accountId, name, description, priceInCents, currency)` +- `createStripeCheckoutSession(groupId, accountId, priceId, quantity, successUrl, cancelUrl, metadata)` + +**Queries:** +- `stripeAccountStatus(groupId, accountId)` +- `stripeProducts(groupId, accountId)` + +### Routes + +Routes are defined in `apps/backend/config/routes.js`: + +```javascript +'GET /noo/stripe/health' -> StripeController.health +'GET /noo/stripe/checkout/success' -> StripeController.checkoutSuccess +'GET /noo/stripe/checkout/cancel' -> StripeController.checkoutCancel +'POST /noo/stripe/webhook' -> StripeController.webhook +``` + +## Frontend Components + +### Group Settings - Paid Content Tab + +**Location**: `apps/web/src/routes/GroupSettings/PaidContentTab/` + +**Features**: +- Create Stripe Connected Account +- Generate onboarding links +- View onboarding status with visual indicators +- Create products with name, description, and price +- View all products +- Display storefront link + +**Access**: Group administrators only (requires `RESP_ADMINISTRATION` responsibility) + +**URL**: `/groups/{groupSlug}/settings/paid-content` + +### Storefront + +**Location**: `apps/web/src/routes/GroupStore/` + +**Features**: +- Public-facing product listing +- Product cards with images and descriptions +- Purchase buttons that redirect to Stripe Checkout +- Success page after payment +- Responsive design for mobile and desktop + +**Access**: Public (no authentication required for viewing) + +**URL**: `/groups/{groupSlug}/store` + +**Important Note**: Currently uses account ID in implementation. In production, you should: +1. Store `stripe_account_id` in your Group model +2. Look up account ID from group slug +3. Never expose account IDs in URLs + +### Store Files + +- `GroupStore.js` - Main storefront component +- `GroupStore.js` exports `GroupStoreSuccess` - Success page component +- `index.js` - Exports for routing + +## User Flows + +### 1. Group Admin Onboards to Stripe + +1. Admin navigates to Group Settings > Paid Content +2. Clicks "Create Stripe Account" +3. System creates connected account via `createStripeConnectedAccount` mutation +4. System automatically generates Account Link +5. Admin is redirected to Stripe to complete onboarding +6. Admin returns to app, status updates automatically +7. Once `chargesEnabled` and `payoutsEnabled` are true, account is ready + +### 2. Group Admin Creates Products + +1. In Paid Content tab, click "Add Product" +2. Fill in product name, description, price, and currency +3. Click "Create Product" +4. Product is created on the connected account (not platform) +5. Product appears in product list +6. Storefront link is displayed + +### 3. Customer Purchases Product + +1. Customer visits `/groups/{groupSlug}/store` +2. Browses available products +3. Clicks "Purchase" on a product +4. System creates Checkout Session with application fee +5. Customer is redirected to Stripe Hosted Checkout +6. Customer completes payment on Stripe +7. Customer returns to success page +8. (TODO: Grant access to content/membership) + +## Application Fee Calculation + +The application fee (platform's revenue) is currently calculated in `createStripeCheckoutSession`: + +```javascript +// Example: 10% platform fee +const applicationFeePercentage = 0.10 +const applicationFeeAmount = Math.round(price * applicationFeePercentage) +``` + +**To customize**: +- Modify percentage in `apps/backend/api/graphql/mutations/stripe.js` +- Consider making this configurable per group or product +- Store fee structure in database for flexibility + + +## TODO STRIPE: Production Checklist + +### Critical for Production + +1. **Database Integration** + - [ ] Add `stripe_account_id` column to `groups` table + - [ ] Update `createStripeConnectedAccount` to save account ID + - [ ] Update `GroupStore` to load account ID from database + - [ ] Store onboarding status in database + +2. **Webhook Signature Verification** + - [ ] Implement webhook signature verification in `StripeController.webhook` + - [ ] Add `STRIPE_WEBHOOK_SECRET` to environment variables + - [ ] Register webhook endpoint with Stripe: `https://yourdomain.com/noo/stripe/webhook` + +3. **Content Access Control** + - [ ] Implement logic to grant access after successful payment + - [ ] Link products to tracks or content + - [ ] Check purchase status before allowing content access + - [ ] Send confirmation emails after purchase + +4. **Error Handling** + - [ ] Add comprehensive error handling and logging + - [ ] Set up monitoring for failed payments + - [ ] Implement retry logic for transient failures + - [ ] Add user-friendly error messages + +5. **Security** + - [ ] Verify user permissions on all mutations + - [ ] Implement rate limiting on checkout creation + - [ ] Validate all input data + - [ ] Use HTTPS in production (required by Stripe) + +### Recommended for Production + +6. **Testing** + - [ ] Test with Stripe test mode thoroughly + - [ ] Test onboarding flow end-to-end + - [ ] Test different payment methods + - [ ] Test webhook events + - [ ] Test refund scenarios + +7. **User Experience** + - [ ] Add loading states throughout + - [ ] Implement proper error boundaries + - [ ] Add success notifications + - [ ] Create email templates for confirmations + - [ ] Add transaction history for users + +8. **Business Logic** + - [ ] Configure application fee percentage + - [ ] Implement refund policies + - [ ] Add subscription support (if needed) + - [ ] Implement discount codes (if needed) + - [ ] Add tax calculation (Stripe Tax) + +9. **Compliance** + - [ ] Add Terms of Service for payments + - [ ] Add Privacy Policy updates for payment data + - [ ] Ensure PCI compliance (Stripe handles this) + - [ ] Add required legal disclaimers + +10. **Monitoring & Analytics** + - [ ] Track conversion rates + - [ ] Monitor failed payments + - [ ] Set up alerts for issues + - [ ] Dashboard for financial metrics + +## Testing + +### Test Mode Setup + +1. Use Stripe test API keys (start with `sk_test_`) +2. Use test card numbers: https://stripe.com/docs/testing#cards + - Success: `4242 4242 4242 4242` + - Decline: `4000 0000 0000 0002` + - 3D Secure: `4000 0025 0000 3155` + +### Testing Flow + +```bash +# 1. Start backend +cd apps/backend +yarn dev + +# 2. Start frontend +cd apps/web +yarn start + +# 3. Test in browser +# - Create a test group +# - Navigate to group settings > payments +# - Create Stripe account +# - Complete onboarding (test mode) +# - Create test products +# - Visit storefront +# - Test checkout with test card +``` + +## Troubleshooting + +### "STRIPE_SECRET_KEY is not set" Error + +**Solution**: Add your Stripe secret key to `apps/backend/.env`: +```bash +STRIPE_SECRET_KEY=sk_test_your_key_here +``` + +### Onboarding Link Expired + +**Solution**: Account Links expire after a short time. Click "Complete Onboarding" again to generate a new link. + +### Product Not Showing in Storefront + +**Causes**: +1. Product marked as inactive +2. Account ID not loaded correctly +3. Product fetch failed + +**Solution**: Check browser console for errors, verify account ID is set + +### Checkout Session Creation Fails + +**Causes**: +1. Invalid price ID +2. Account not fully onboarded +3. Application fee too high (must be less than total) + +**Solution**: Verify account status shows charges enabled + +## API Examples + +### Create Connected Account + +```graphql +mutation { + createStripeConnectedAccount( + groupId: "123" + email: "group@example.com" + businessName: "My Community Group" + country: "US" + ) { + id + accountId + success + message + } +} +``` + +### Create Product + +```graphql +mutation { + createStripeProduct( + groupId: "123" + accountId: "acct_xxxxx" + name: "Premium Membership" + description: "Access to all premium content" + priceInCents: 2000 # $20.00 + currency: "usd" + ) { + productId + priceId + success + } +} +``` + +### Check Account Status + +```graphql +query { + stripeAccountStatus( + groupId: "123" + accountId: "acct_xxxxx" + ) { + chargesEnabled + payoutsEnabled + detailsSubmitted + requirements { + currently_due + past_due + } + } +} +``` + +## Additional Resources + +- [Stripe Connect Documentation](https://stripe.com/docs/connect) +- [Stripe API Reference](https://stripe.com/docs/api) +- [Stripe Testing](https://stripe.com/docs/testing) +- [Stripe Checkout](https://stripe.com/docs/payments/checkout) +- [Stripe Webhooks](https://stripe.com/docs/webhooks) + +## Support + +For questions or issues with this integration: +1. Check Stripe Dashboard for detailed error messages +2. Review server logs for backend errors +3. Check browser console for frontend errors +4. Consult Stripe documentation for API details + +--- + +**Integration completed on**: October 20, 2025 +**Stripe API Version**: 2025-09-30.clover +**Package Version**: stripe@^17.5.0 + diff --git a/yarn.lock b/yarn.lock index 8ea6fbeb64..92eef4ac7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10625,6 +10625,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=8.1.0": + version: 24.8.1 + resolution: "@types/node@npm:24.8.1" + dependencies: + undici-types: "npm:~7.14.0" + checksum: 10/4f944466766ca8cc0d635386e2b9b42b1948723ca0ec7d24624e80513e5816d6e96197db7c05f8f67b544a228a06a1c0598d66a5526aa4c1f5919db0d3c5bf8c + languageName: node + linkType: hard + "@types/node@npm:^22.15.30": version: 22.15.32 resolution: "@types/node@npm:22.15.32" @@ -13097,7 +13106,7 @@ __metadata: socket.io-emitter: "npm:^3.1.1" standard: "npm:^17.1.2" stream-buffers: "npm:^3.0.2" - stripe: "npm:^6.15.0" + stripe: "npm:^17.5.0" uuid: "npm:^9.0.0" validator: "npm:^3.22.1" wkx: "npm:^0.5.0" @@ -31899,7 +31908,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.6.0": +"qs@npm:^6.11.0": version: 6.14.0 resolution: "qs@npm:6.14.0" dependencies: @@ -34815,7 +34824,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10/32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451 @@ -36847,15 +36856,13 @@ __metadata: languageName: node linkType: hard -"stripe@npm:^6.15.0": - version: 6.36.0 - resolution: "stripe@npm:6.36.0" +"stripe@npm:^17.5.0": + version: 17.7.0 + resolution: "stripe@npm:17.7.0" dependencies: - lodash.isplainobject: "npm:^4.0.6" - qs: "npm:^6.6.0" - safe-buffer: "npm:^5.1.1" - uuid: "npm:^3.3.2" - checksum: 10/60eb81320369e722e640ea97bca585b2c39f836bdc2ae5b3c77a86a1f7fd87af9e73fc7def7bb0cf0c40618874c4758927907b141c88d7c932a233caf65a2af8 + "@types/node": "npm:>=8.1.0" + qs: "npm:^6.11.0" + checksum: 10/376f945f9c194c8ea2d47d1fda50d141ed985cbb6f94041e11880084f830e502962d434967f1c90a3cb27a9b6e26c606f2830d9448bde636ebd6c067d5cbfecc languageName: node linkType: hard @@ -38441,6 +38448,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.14.0": + version: 7.14.0 + resolution: "undici-types@npm:7.14.0" + checksum: 10/0f8709b21437697af35801e33bddbe9992e0cf1771959c41850b1946f63822b825e03ce99f44bf19e4f5c3ccc5166e0be59f541565c36ce86163dc2c5870bc62 + languageName: node + linkType: hard + "undici@npm:^6.19.5": version: 6.21.1 resolution: "undici@npm:6.21.1" From 57aa2bb2078e31df64bec587262c268065beaa33 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 24 Oct 2025 10:28:15 +1100 Subject: [PATCH 04/76] Ensure we pass group id as metadata on stripe connect account creation --- apps/backend/api/controllers/StripeController.js | 14 +++++++++++--- apps/backend/api/graphql/mutations/stripe.js | 3 ++- apps/backend/api/services/StripeService.js | 12 +++++++++++- docs/PAID_CONTENT_WORKFLOW.md | 9 +++++++-- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/backend/api/controllers/StripeController.js b/apps/backend/api/controllers/StripeController.js index 2f087f8cf4..8b2bca60bf 100644 --- a/apps/backend/api/controllers/StripeController.js +++ b/apps/backend/api/controllers/StripeController.js @@ -164,18 +164,26 @@ module.exports = { console.log(`Account updated: ${account.id}`) } - // Find the group with this Stripe account ID - const group = await Group.where({ stripe_account_id: account.id }).fetch() + // Extract group ID from account metadata + const groupId = account.metadata?.group_id + if (!groupId) { + if (process.env.NODE_ENV === 'development') { + console.log(`No group_id metadata found for Stripe account: ${account.id}`) + } + return + } + const group = await Group.find(groupId) if (!group) { if (process.env.NODE_ENV === 'development') { - console.log(`No group found for Stripe account: ${account.id}`) + console.log(`No group found with ID: ${groupId}`) } return } // Update group's Stripe status await group.save({ + stripe_account_id: account.id, // Store account ID if not already stored stripe_charges_enabled: account.charges_enabled, stripe_payouts_enabled: account.payouts_enabled, stripe_details_submitted: account.details_submitted diff --git a/apps/backend/api/graphql/mutations/stripe.js b/apps/backend/api/graphql/mutations/stripe.js index 440e46e9ab..30fc963852 100644 --- a/apps/backend/api/graphql/mutations/stripe.js +++ b/apps/backend/api/graphql/mutations/stripe.js @@ -62,7 +62,8 @@ module.exports = { const account = await StripeService.createConnectedAccount({ email: email || group.get('contact_email'), country: country || 'US', - businessName: businessName || group.get('name') + businessName: businessName || group.get('name'), + groupId }) // TODO STRIPE: Save the account ID to your database diff --git a/apps/backend/api/services/StripeService.js b/apps/backend/api/services/StripeService.js index 7a6136498e..a85de108e7 100644 --- a/apps/backend/api/services/StripeService.js +++ b/apps/backend/api/services/StripeService.js @@ -37,14 +37,16 @@ module.exports = { * - Connected account pays Stripe fees * - Stripe handles payment disputes and losses * - Connected account gets full dashboard access + * - Group ID stored in metadata for webhook correlation * * @param {Object} params - Account creation parameters * @param {String} params.email - Email address for the connected account * @param {String} params.country - Two-letter country code (e.g. 'US', 'GB') * @param {String} params.businessName - Business/group name + * @param {String} params.groupId - Group ID for metadata correlation * @returns {Promise} The created Stripe account object */ - async createConnectedAccount ({ email, country = 'US', businessName }) { + async createConnectedAccount ({ email, country = 'US', businessName, groupId }) { try { // Validate required parameters if (!email) { @@ -55,6 +57,10 @@ module.exports = { throw new Error('Business name is required to create a connected account') } + if (!groupId) { + throw new Error('Group ID is required for account metadata') + } + // Create the connected account with controller settings // Note: We use controller properties, NOT top-level type property const account = await stripe.accounts.create({ @@ -63,6 +69,10 @@ module.exports = { business_profile: { name: businessName }, + metadata: { + group_id: groupId.toString(), + platform: 'hylo' + }, controller: { // Platform controls fee collection - connected account pays fees fees: { diff --git a/docs/PAID_CONTENT_WORKFLOW.md b/docs/PAID_CONTENT_WORKFLOW.md index a607783152..0b52d95ae6 100644 --- a/docs/PAID_CONTENT_WORKFLOW.md +++ b/docs/PAID_CONTENT_WORKFLOW.md @@ -62,8 +62,13 @@ The paid content system enables groups to: // GraphQL Mutation createStripeConnectedAccount(groupId, email, businessName, country) -// Backend Service -StripeService.createConnectedAccount({ email, country, businessName }) +// Backend Service (with metadata) +StripeService.createConnectedAccount({ + email, + country, + businessName, + groupId // Added for metadata correlation +}) // Account Link Creation createStripeAccountLink(groupId, accountId, returnUrl, refreshUrl) From 8e35035e389a141e8f21f9b6fc62021e51f87538 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 27 Oct 2025 09:28:02 +1100 Subject: [PATCH 05/76] Refined backend --- .../api/controllers/StripeController.js | 69 +++ apps/backend/api/graphql/makeModels.js | 35 ++ apps/backend/api/graphql/mutations/stripe.js | 168 +++++++- .../api/graphql/mutations/stripe.test.js | 395 +++++++++++++++++- apps/backend/api/graphql/schema.graphql | 27 +- apps/backend/api/models/StripeProduct.js | 36 +- apps/backend/api/services/StripeService.js | 142 +++++++ .../20251020160838_paid-content-stripe.js | 2 +- apps/backend/migrations/schema.sql | 2 +- 9 files changed, 851 insertions(+), 25 deletions(-) diff --git a/apps/backend/api/controllers/StripeController.js b/apps/backend/api/controllers/StripeController.js index 8b2bca60bf..107c65d11e 100644 --- a/apps/backend/api/controllers/StripeController.js +++ b/apps/backend/api/controllers/StripeController.js @@ -136,6 +136,10 @@ module.exports = { await this.handleCheckoutSessionCompleted(event) break + case 'product.updated': + await this.handleProductUpdated(event) + break + default: if (process.env.NODE_ENV === 'development') { console.log(`Unhandled event type: ${event.type}`) @@ -354,6 +358,71 @@ module.exports = { } }, + /** + * Handle product.updated webhook events + * Syncs product changes from Stripe back to our database + */ + handleProductUpdated: async function (event) { + try { + const product = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Product updated in Stripe: ${product.id}`) + } + + // Find our database record for this product + const dbProduct = await StripeProduct.findByStripeId(product.id) + if (!dbProduct) { + if (process.env.NODE_ENV === 'development') { + console.log(`No database record found for Stripe product: ${product.id}`) + } + return + } + + // Get the current price information + const expandedProduct = await stripe.products.retrieve(product.id, { + expand: ['default_price'] + }) + + // Check if our database record needs updating + const needsUpdate = {} + + if (dbProduct.get('name') !== expandedProduct.name) { + needsUpdate.name = expandedProduct.name + } + + if (dbProduct.get('description') !== expandedProduct.description) { + needsUpdate.description = expandedProduct.description + } + + if (dbProduct.get('price_in_cents') !== expandedProduct.default_price.unit_amount) { + needsUpdate.price_in_cents = expandedProduct.default_price.unit_amount + } + + if (dbProduct.get('currency') !== expandedProduct.default_price.currency) { + needsUpdate.currency = expandedProduct.default_price.currency + } + + if (dbProduct.get('stripe_price_id') !== expandedProduct.default_price) { + needsUpdate.stripe_price_id = expandedProduct.default_price + } + + // Update our database if there are changes + if (Object.keys(needsUpdate).length > 0) { + await dbProduct.save(needsUpdate) + if (process.env.NODE_ENV === 'development') { + console.log(`Updated database record for product ${product.id}:`, needsUpdate) + } + } else { + if (process.env.NODE_ENV === 'development') { + console.log(`Database record for product ${product.id} is already in sync`) + } + } + } catch (error) { + console.error('Error handling product.updated:', error) + throw error + } + }, + /** * Health check endpoint for Stripe integration * diff --git a/apps/backend/api/graphql/makeModels.js b/apps/backend/api/graphql/makeModels.js index da0c100539..93e56f569e 100644 --- a/apps/backend/api/graphql/makeModels.js +++ b/apps/backend/api/graphql/makeModels.js @@ -1462,6 +1462,41 @@ export default function makeModels (userId, isAdmin, apiClient) { 'id', 'name' ] + }, + + StripeProduct: { + model: StripeProduct, + attributes: [ + 'id', + 'created_at', + 'updated_at', + 'group_id', + 'stripe_product_id', + 'stripe_price_id', + 'name', + 'description', + 'price_in_cents', + 'currency', + 'track_id', + 'content_access', + 'renewal_policy', + 'duration', + 'publish_status' + ], + relations: [ + 'group', + 'track', + { contentAccess: { querySet: true } } + ], + getters: { + stripeProductId: sp => sp.get('stripe_product_id'), + stripePriceId: sp => sp.get('stripe_price_id'), + priceInCents: sp => sp.get('price_in_cents'), + trackId: sp => sp.get('track_id'), + contentAccess: sp => sp.get('content_access'), + renewalPolicy: sp => sp.get('renewal_policy'), + publishStatus: sp => sp.get('publish_status') + } } } } diff --git a/apps/backend/api/graphql/mutations/stripe.js b/apps/backend/api/graphql/mutations/stripe.js index 30fc963852..d4e84d43a6 100644 --- a/apps/backend/api/graphql/mutations/stripe.js +++ b/apps/backend/api/graphql/mutations/stripe.js @@ -28,6 +28,7 @@ module.exports = { * email: "group@example.com" * businessName: "My Group" * country: "US" + * existingAccountId: "acct_xxx" # Optional: existing Stripe account * ) { * id * accountId @@ -35,7 +36,7 @@ module.exports = { * } * } */ - createStripeConnectedAccount: async (root, { groupId, email, businessName, country }, { session }) => { + createStripeConnectedAccount: async (root, { groupId, email, businessName, country, existingAccountId }, { session }) => { try { // Check if user is authenticated if (!session || !session.userId) { @@ -55,19 +56,30 @@ module.exports = { } // Check if group already has a Stripe account - // TODO STRIPE: You may want to store the accountId in your Group model - // For this demo, we'll just create a new account each time - - // Create the connected account - const account = await StripeService.createConnectedAccount({ - email: email || group.get('contact_email'), - country: country || 'US', - businessName: businessName || group.get('name'), - groupId - }) + if (group.get('stripe_account_id')) { + throw new GraphQLError('This group already has a Stripe account connected') + } + + let account + + if (existingAccountId) { + // Connect existing Stripe account + account = await StripeService.connectExistingAccount({ + accountId: existingAccountId, + groupId + }) + } else { + // Create new Stripe account + account = await StripeService.createConnectedAccount({ + email: email || group.get('contact_email'), + country: country || 'US', + businessName: businessName || group.get('name'), + groupId + }) + } - // TODO STRIPE: Save the account ID to your database - // await group.save({ stripe_account_id: account.id }) + // Save the account ID to the database + await group.save({ stripe_account_id: account.id }) return { id: groupId, @@ -200,6 +212,7 @@ module.exports = { * roleIds: [1, 2] * } * } + * publishStatus: "published" * ) { * productId * priceId @@ -216,7 +229,8 @@ module.exports = { currency, contentAccess, renewalPolicy, - duration + duration, + publishStatus }, { session }) => { try { // Check if user is authenticated @@ -251,7 +265,7 @@ module.exports = { content_access: contentAccess || {}, renewal_policy: renewalPolicy || 'manual', duration: duration || null, - active: true + publish_status: publishStatus || 'unpublished' }) return { @@ -268,6 +282,130 @@ module.exports = { } }, + /** + * Updates an existing Stripe product + * + * Allows group administrators to update product details including name, description, + * price, content access, renewal policy, duration, and publish status. + * + * Usage: + * mutation { + * updateStripeProduct( + * productId: "123" + * name: "Updated Premium Membership" + * description: "Updated description" + * priceInCents: 2500 + * contentAccess: { + * "123": { + * trackIds: [456, 789] + * roleIds: [1, 2] + * } + * } + * publishStatus: "published" + * ) { + * success + * message + * } + * } + */ + updateStripeProduct: async (root, { + productId, + name, + description, + priceInCents, + currency, + contentAccess, + renewalPolicy, + duration, + publishStatus + }, { session }) => { + try { + // Check if user is authenticated + if (!session || !session.userId) { + throw new GraphQLError('You must be logged in to update a product') + } + + // Load the product and verify permissions + const product = await StripeProduct.find(productId) + if (!product) { + throw new GraphQLError('Product not found') + } + + const membership = await GroupMembership.forPair(session.userId, product.get('group_id')).fetch() + if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + throw new GraphQLError('You must be a group administrator to update products') + } + + // Prepare update attributes (only include provided fields) + const updateAttrs = {} + + // Fields that need to be synced with Stripe + const stripeSyncFields = {} + if (name !== undefined) { + updateAttrs.name = name + stripeSyncFields.name = name + } + if (description !== undefined) { + updateAttrs.description = description + stripeSyncFields.description = description + } + if (priceInCents !== undefined) { + updateAttrs.price_in_cents = priceInCents + stripeSyncFields.priceInCents = priceInCents + } + if (currency !== undefined) { + updateAttrs.currency = currency + stripeSyncFields.currency = currency + } + + // Fields that are platform-only (don't sync with Stripe) + if (contentAccess !== undefined) updateAttrs.content_access = contentAccess + if (renewalPolicy !== undefined) updateAttrs.renewal_policy = renewalPolicy + if (duration !== undefined) updateAttrs.duration = duration + if (publishStatus !== undefined) { + // Validate publish status + const validStatuses = ['unpublished', 'unlisted', 'published', 'archived'] + if (!validStatuses.includes(publishStatus)) { + throw new GraphQLError('Invalid publish status. Must be unpublished, unlisted, published, or archived') + } + updateAttrs.publish_status = publishStatus + } + + // Sync with Stripe first if there are fields that need syncing + if (Object.keys(stripeSyncFields).length > 0) { + const group = await Group.find(product.get('group_id')) + if (!group || !group.get('stripe_account_id')) { + throw new GraphQLError('Group does not have a connected Stripe account') + } + + // Update product in Stripe first + const updatedStripeProduct = await StripeService.updateProduct({ + accountId: group.get('stripe_account_id'), + productId: product.get('stripe_product_id'), + ...stripeSyncFields + }) + + // Update our database with the actual values from Stripe + updateAttrs.name = updatedStripeProduct.name + updateAttrs.description = updatedStripeProduct.description + updateAttrs.price_in_cents = updatedStripeProduct.default_price.unit_amount + updateAttrs.currency = updatedStripeProduct.default_price.currency + updateAttrs.stripe_price_id = updatedStripeProduct.default_price + } + + // Update the product in our database + await StripeProduct.update(productId, updateAttrs) + + return { + success: true, + message: 'Product updated successfully' + } + } catch (error) { + console.error('Error in updateStripeProduct:', error) + throw new GraphQLError(`Failed to update product: ${error.message}`) + } + }, + /** * Lists all products for a connected account * diff --git a/apps/backend/api/graphql/mutations/stripe.test.js b/apps/backend/api/graphql/mutations/stripe.test.js index 6d1871e59a..aa158b59cf 100644 --- a/apps/backend/api/graphql/mutations/stripe.test.js +++ b/apps/backend/api/graphql/mutations/stripe.test.js @@ -1,11 +1,13 @@ /* eslint-disable no-unused-expressions */ import '../../../test/setup' import factories from '../../../test/setup/factories' +import mock from 'mock-require' import { createStripeConnectedAccount, createStripeAccountLink, stripeAccountStatus, createStripeProduct, + updateStripeProduct, stripeProducts, createStripeCheckoutSession } from './stripe' @@ -13,6 +15,88 @@ const { expect } = require('chai') /* global setup */ +// Mock StripeService to avoid real API calls +const mockStripeService = { + createConnectedAccount: async ({ email, country, businessName, groupId }) => ({ + id: 'acct_test_123', + email: email || 'test@example.com', + country: country || 'US', + business_name: businessName || 'Test Business', + charges_enabled: false, + payouts_enabled: false, + details_submitted: false + }), + + connectExistingAccount: async ({ accountId, groupId }) => ({ + id: accountId, + email: 'existing@example.com', + country: 'US', + business_name: 'Existing Business', + charges_enabled: true, + payouts_enabled: true, + details_submitted: true + }), + + createAccountLink: async ({ accountId, returnUrl, refreshUrl }) => ({ + url: `https://connect.stripe.com/setup/c/${accountId}`, + expires_at: Math.floor(Date.now() / 1000) + 3600 + }), + + getAccountStatus: async (accountId) => ({ + id: accountId, + email: 'test@example.com', + charges_enabled: true, + payouts_enabled: true, + details_submitted: true, + requirements: [] + }), + + createProduct: async ({ accountId, name, description, priceInCents, currency }) => ({ + id: 'prod_test_123', + name, + description: description || '', + default_price: 'price_test_123', + active: true + }), + + updateProduct: async ({ accountId, productId, name, description, priceInCents, currency }) => ({ + id: productId, + name: name || 'Updated Product', + description: description || 'Updated description', + default_price: { + id: 'price_test_updated', + unit_amount: priceInCents || 2000, + currency: currency || 'usd' + }, + active: true + }), + + getProducts: async (accountId) => ({ + data: [{ + id: 'prod_test_123', + name: 'Test Product', + description: 'A test product', + default_price: 'price_test_123', + active: true + }] + }), + + getPrice: async (accountId, priceId) => ({ + id: priceId, + unit_amount: 2000, + currency: 'usd' + }), + + createCheckoutSession: async ({ accountId, priceId, quantity, applicationFeeAmount, successUrl, cancelUrl, metadata }) => ({ + id: 'cs_test_123', + url: 'https://checkout.stripe.com/pay/cs_test_123', + payment_intent: 'pi_test_123' + }) +} + +// Mock the StripeService before importing the mutations +mock('../../services/StripeService', mockStripeService) + describe('Stripe Mutations', () => { let user, adminUser, group @@ -22,6 +106,9 @@ describe('Stripe Mutations', () => { adminUser = await factories.user().save() group = await factories.group().save() + // Add Stripe account ID to the test group + await group.save({ stripe_account_id: 'acct_test_123' }) + // Add admin user as group administrator await adminUser.joinGroup(group, { role: GroupMembership.Role.MODERATOR }) // Add regular user as group member @@ -85,6 +172,67 @@ describe('Stripe Mutations', () => { }, { session: { userId: adminUser.id } }) ).to.be.rejectedWith('Group not found') }) + + it('connects existing Stripe account when existingAccountId provided', async () => { + const result = await createStripeConnectedAccount({ + groupId: group.id, + email: 'group@example.com', + businessName: 'Test Group', + country: 'US', + existingAccountId: 'acct_existing_123' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.accountId).to.equal('acct_existing_123') + expect(result.message).to.equal('Connected account created successfully') + }) + + it('rejects connection if group already has Stripe account', async () => { + // First, create an account + await createStripeConnectedAccount({ + groupId: group.id, + email: 'group@example.com', + businessName: 'Test Group', + country: 'US' + }, { session: { userId: adminUser.id } }) + + // Try to create another account + await expect( + createStripeConnectedAccount({ + groupId: group.id, + email: 'group2@example.com', + businessName: 'Test Group 2', + country: 'US' + }, { session: { userId: adminUser.id } }) + ).to.be.rejectedWith('This group already has a Stripe account connected') + }) + + it('rejects connection with invalid existing account ID', async () => { + await expect( + createStripeConnectedAccount({ + groupId: group.id, + email: 'group@example.com', + businessName: 'Test Group', + country: 'US', + existingAccountId: 'invalid_account_id' + }, { session: { userId: adminUser.id } }) + ).to.be.rejectedWith('Invalid Stripe account ID provided') + }) + + it('allows connection of unverified accounts', async () => { + // This test verifies that we can connect accounts even if they're not fully verified + // The verification status will be handled in the UI + const result = await createStripeConnectedAccount({ + groupId: group.id, + email: 'group@example.com', + businessName: 'Test Group', + country: 'US', + existingAccountId: 'acct_unverified_123' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.accountId).to.equal('acct_unverified_123') + }) }) describe('createStripeAccountLink', () => { @@ -177,7 +325,8 @@ describe('Stripe Mutations', () => { currency: 'usd', contentAccess, renewalPolicy: 'manual', - duration: 365 + duration: 365, + publishStatus: 'published' }, { session: { userId: adminUser.id } }) expect(result.success).to.be.true @@ -195,6 +344,7 @@ describe('Stripe Mutations', () => { expect(savedProduct.get('content_access')).to.deep.equal(contentAccess) expect(savedProduct.get('renewal_policy')).to.equal('manual') expect(savedProduct.get('duration')).to.equal(365) + expect(savedProduct.get('publish_status')).to.equal('published') }) it('creates a product with minimal required fields', async () => { @@ -213,6 +363,7 @@ describe('Stripe Mutations', () => { expect(savedProduct.get('currency')).to.equal('usd') // default expect(savedProduct.get('content_access')).to.deep.equal({}) // default expect(savedProduct.get('renewal_policy')).to.equal('manual') // default + expect(savedProduct.get('publish_status')).to.equal('unpublished') // default }) it('rejects creation for non-authenticated users', async () => { @@ -238,6 +389,248 @@ describe('Stripe Mutations', () => { }) }) + describe('updateStripeProduct', () => { + let testProduct + + before(async () => { + // Create a test product + const result = await createStripeProduct({ + groupId: group.id, + accountId: 'acct_test_123', + name: 'Original Product', + description: 'Original description', + priceInCents: 1000, + currency: 'usd', + contentAccess: { [group.id]: { trackIds: [1] } }, + renewalPolicy: 'manual', + duration: 'month', + publishStatus: 'unpublished' + }, { session: { userId: adminUser.id } }) + testProduct = await StripeProduct.where({ id: result.databaseId }).fetch() + }) + + it('updates product name and description', async () => { + const result = await updateStripeProduct({ + productId: testProduct.id, + name: 'Updated Product Name', + description: 'Updated description' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + expect(result.message).to.equal('Product updated successfully') + + // Verify the changes were saved (values come from mocked StripeService) + await testProduct.refresh() + expect(testProduct.get('name')).to.equal('Updated Product Name') + expect(testProduct.get('description')).to.equal('Updated description') + }) + + it('updates product price and currency', async () => { + const result = await updateStripeProduct({ + productId: testProduct.id, + priceInCents: 2500, + currency: 'eur' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + + // Verify the changes were saved (values come from mocked StripeService) + await testProduct.refresh() + expect(testProduct.get('price_in_cents')).to.equal(2500) + expect(testProduct.get('currency')).to.equal('eur') + expect(testProduct.get('stripe_price_id')).to.equal('price_test_updated') + }) + + it('updates content access configuration', async () => { + const newContentAccess = { + [group.id]: { + trackIds: [1, 2, 3], + roleIds: [1, 2] + } + } + + const result = await updateStripeProduct({ + productId: testProduct.id, + contentAccess: newContentAccess + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + + // Verify the changes were saved + await testProduct.refresh() + expect(testProduct.get('content_access')).to.deep.equal(newContentAccess) + }) + + it('updates renewal policy and duration', async () => { + const result = await updateStripeProduct({ + productId: testProduct.id, + renewalPolicy: 'automatic', + duration: 'annual' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + + // Verify the changes were saved + await testProduct.refresh() + expect(testProduct.get('renewal_policy')).to.equal('automatic') + expect(testProduct.get('duration')).to.equal('annual') + }) + + it('updates publish status to all valid values', async () => { + // Test unpublished + let result = await updateStripeProduct({ + productId: testProduct.id, + publishStatus: 'unpublished' + }, { session: { userId: adminUser.id } }) + expect(result.success).to.be.true + await testProduct.refresh() + expect(testProduct.get('publish_status')).to.equal('unpublished') + + // Test unlisted + result = await updateStripeProduct({ + productId: testProduct.id, + publishStatus: 'unlisted' + }, { session: { userId: adminUser.id } }) + expect(result.success).to.be.true + await testProduct.refresh() + expect(testProduct.get('publish_status')).to.equal('unlisted') + + // Test published + result = await updateStripeProduct({ + productId: testProduct.id, + publishStatus: 'published' + }, { session: { userId: adminUser.id } }) + expect(result.success).to.be.true + await testProduct.refresh() + expect(testProduct.get('publish_status')).to.equal('published') + + // Test archived + result = await updateStripeProduct({ + productId: testProduct.id, + publishStatus: 'archived' + }, { session: { userId: adminUser.id } }) + expect(result.success).to.be.true + await testProduct.refresh() + expect(testProduct.get('publish_status')).to.equal('archived') + }) + + it('rejects invalid publish status', async () => { + await expect( + updateStripeProduct({ + productId: testProduct.id, + publishStatus: 'invalid_status' + }, { session: { userId: adminUser.id } }) + ).to.be.rejectedWith('Invalid publish status. Must be unpublished, unlisted, published, or archived') + }) + + it('updates multiple fields at once', async () => { + const result = await updateStripeProduct({ + productId: testProduct.id, + name: 'Multi-Update Product', + description: 'Updated with multiple fields', + priceInCents: 3000, + publishStatus: 'unlisted' + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + + // Verify all changes were saved (Stripe-synced fields come from mock, platform fields from input) + await testProduct.refresh() + expect(testProduct.get('name')).to.equal('Multi-Update Product') + expect(testProduct.get('description')).to.equal('Updated with multiple fields') + expect(testProduct.get('price_in_cents')).to.equal(3000) + expect(testProduct.get('currency')).to.equal('usd') // From mock + expect(testProduct.get('stripe_price_id')).to.equal('price_test_updated') // From mock + expect(testProduct.get('publish_status')).to.equal('unlisted') + }) + + it('handles partial updates without affecting other fields', async () => { + // First set some values + await updateStripeProduct({ + productId: testProduct.id, + name: 'Test Name', + description: 'Test Description', + priceInCents: 1500 + }, { session: { userId: adminUser.id } }) + + // Then update only the name + await updateStripeProduct({ + productId: testProduct.id, + name: 'Only Name Updated' + }, { session: { userId: adminUser.id } }) + + // Verify only name changed, other fields remained + await testProduct.refresh() + expect(testProduct.get('name')).to.equal('Only Name Updated') + expect(testProduct.get('description')).to.equal('Test Description') + expect(testProduct.get('price_in_cents')).to.equal(1500) + }) + + it('rejects update for non-authenticated users', async () => { + await expect( + updateStripeProduct({ + productId: testProduct.id, + name: 'Unauthorized Update' + }, { session: null }) + ).to.be.rejectedWith('You must be logged in to update a product') + }) + + it('rejects update for non-admin users', async () => { + await expect( + updateStripeProduct({ + productId: testProduct.id, + name: 'Unauthorized Update' + }, { session: { userId: user.id } }) + ).to.be.rejectedWith('You must be a group administrator to update products') + }) + + it('rejects update for non-existent product', async () => { + await expect( + updateStripeProduct({ + productId: 99999, + name: 'Non-existent Product' + }, { session: { userId: adminUser.id } }) + ).to.be.rejectedWith('Product not found') + }) + + it('rejects update when group has no Stripe account', async () => { + // Create a group without a Stripe account + const groupWithoutStripe = await factories.group().save() + await adminUser.joinGroup(groupWithoutStripe, { role: GroupMembership.Role.MODERATOR }) + + const productWithoutStripe = await StripeProduct.create({ + group_id: groupWithoutStripe.id, + stripe_product_id: 'prod_no_stripe', + stripe_price_id: 'price_no_stripe', + name: 'Product Without Stripe', + description: 'A product for a group without Stripe', + price_in_cents: 1000, + currency: 'usd' + }) + + await expect( + updateStripeProduct({ + productId: productWithoutStripe.id, + name: 'Updated Name' + }, { session: { userId: adminUser.id } }) + ).to.be.rejectedWith('Group does not have a connected Stripe account') + }) + + it('handles empty update gracefully', async () => { + const originalName = testProduct.get('name') + + const result = await updateStripeProduct({ + productId: testProduct.id + }, { session: { userId: adminUser.id } }) + + expect(result.success).to.be.true + + // Verify nothing changed + await testProduct.refresh() + expect(testProduct.get('name')).to.equal(originalName) + }) + }) + describe('stripeProducts', () => { it('lists products for group admins', async () => { const result = await stripeProducts({ diff --git a/apps/backend/api/graphql/schema.graphql b/apps/backend/api/graphql/schema.graphql index 046637032a..31042a5873 100644 --- a/apps/backend/api/graphql/schema.graphql +++ b/apps/backend/api/graphql/schema.graphql @@ -7,6 +7,13 @@ enum LocationDisplayPrecision { region } +enum PublishStatus { + unpublished + unlisted + published + archived +} + type Query { # Find an Activity by ID activity(id: ID): Activity @@ -2589,8 +2596,6 @@ type StripeProduct { priceInCents: Int # Currency code (e.g. 'usd') currency: String - # Whether this product is active - active: Boolean # Optional track this product grants access to (legacy field) track: Track # JSONB object defining what access this product grants @@ -2599,6 +2604,8 @@ type StripeProduct { renewalPolicy: String # Duration: month, season, annual, lifetime, or null for no expiration duration: String + # Publish status: unpublished, unlisted, published + publishStatus: PublishStatus } # Current user's personal settings @@ -3046,6 +3053,7 @@ type Mutation { email: String! businessName: String! country: String + existingAccountId: String ): StripeConnectedAccountResult # Create an Account Link for onboarding @@ -3065,6 +3073,19 @@ type Mutation { # Create a product on the connected account createStripeProduct(input: StripeProductInput!): StripeProductResult + # Update an existing Stripe product + updateStripeProduct( + productId: ID! + name: String + description: String + priceInCents: Int + currency: String + contentAccess: JSON + renewalPolicy: String + duration: String + publishStatus: PublishStatus + ): StripeProductUpdateResult + # List all products for a connected account stripeProducts( groupId: ID! @@ -3546,6 +3567,8 @@ input StripeProductInput { renewalPolicy: String # Duration: month, season, annual, lifetime, or null for no expiration duration: String + # Publish status: unpublished, unlisted, published, archived + publishStatus: PublishStatus } # Details passed along when Flagging a Post diff --git a/apps/backend/api/models/StripeProduct.js b/apps/backend/api/models/StripeProduct.js index e6b9706c9d..1886cebbb3 100644 --- a/apps/backend/api/models/StripeProduct.js +++ b/apps/backend/api/models/StripeProduct.js @@ -42,7 +42,8 @@ module.exports = bookshelf.Model.extend({ const defaults = { content_access: {}, renewal_policy: 'manual', - duration: null + duration: null, + publish_status: 'unpublished' } return this.forge({ ...defaults, ...attrs }).save({}, { transacting }) }, @@ -57,21 +58,38 @@ module.exports = bookshelf.Model.extend({ }, /** - * Get all active products for a group + * Get all published products for a group (excludes unpublished, unlisted, and archived) * @param {String|Number} groupId - The group ID * @returns {Promise>} */ forGroup: function (groupId) { - return this.where({ group_id: groupId, active: true }).fetchAll() + return this.where({ group_id: groupId, publish_status: 'published' }).fetchAll() }, /** - * Get all products for a specific track + * Get all published products for a specific track (excludes unpublished, unlisted, and archived) * @param {String|Number} trackId - The track ID * @returns {Promise>} */ forTrack: function (trackId) { - return this.where({ track_id: trackId, active: true }).fetchAll() + return this.where({ track_id: trackId, publish_status: 'published' }).fetchAll() + }, + + /** + * Update a Stripe product + * @param {String|Number} productId - The product ID + * @param {Object} attrs - Attributes to update + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + update: async function (productId, attrs, { transacting } = {}) { + const product = await this.find(productId) + if (!product) { + throw new Error('Product not found') + } + + // Update the product with new attributes + return product.save(attrs, { transacting }) }, /** @@ -197,6 +215,14 @@ module.exports = bookshelf.Model.extend({ LIFETIME: 'lifetime' }, + // Publish status constants + PublishStatus: { + UNPUBLISHED: 'unpublished', + UNLISTED: 'unlisted', + PUBLISHED: 'published', + ARCHIVED: 'archived' + }, + /** * Calculate expiration date based on duration * @param {String} duration - Duration string (month, season, annual, lifetime, or null) diff --git a/apps/backend/api/services/StripeService.js b/apps/backend/api/services/StripeService.js index a85de108e7..a15b049d7a 100644 --- a/apps/backend/api/services/StripeService.js +++ b/apps/backend/api/services/StripeService.js @@ -96,6 +96,56 @@ module.exports = { } }, + /** + * Connects an existing Stripe account to a group + * + * This allows groups to use their existing Stripe account instead of + * creating a new one. The account can be connected even if not fully + * verified - verification status will be displayed in the UI and + * prevent product publishing until the account is ready. + * + * @param {Object} params - Connection parameters + * @param {String} params.accountId - Existing Stripe account ID + * @param {String} params.groupId - Group ID for metadata correlation + * @returns {Promise} The Stripe account object + */ + async connectExistingAccount ({ accountId, groupId }) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to connect existing account') + } + + if (!groupId) { + throw new Error('Group ID is required for account metadata') + } + + // Validate that the account exists by retrieving it + // This will throw an error if the account ID is invalid + await stripe.accounts.retrieve(accountId) + + // Account exists and is valid - we can proceed with connection + // No need to check verification status here as UI will handle that + + // Update the account metadata to include our group ID + const updatedAccount = await stripe.accounts.update(accountId, { + metadata: { + group_id: groupId.toString(), + platform: 'hylo', + connected_at: new Date().toISOString() + } + }) + + return updatedAccount + } catch (error) { + console.error('Error connecting existing account:', error) + if (error.type === 'StripeInvalidRequestError') { + throw new Error('Invalid Stripe account ID provided') + } + throw new Error(`Failed to connect existing Stripe account: ${error.message}`) + } + }, + /** * Creates an Account Link for onboarding a connected account * @@ -223,6 +273,98 @@ module.exports = { } }, + /** + * Updates a product on a connected account + * + * Updates product details in Stripe and returns the updated product. + * Only updates fields that are provided and different from current values. + * + * @param {Object} params - Product update parameters + * @param {String} params.accountId - The Stripe connected account ID + * @param {String} params.productId - The Stripe product ID to update + * @param {String} [params.name] - New product name + * @param {String} [params.description] - New product description + * @param {Number} [params.priceInCents] - New price in cents + * @param {String} [params.currency] - New currency code + * @returns {Promise} The updated product object + */ + async updateProduct ({ accountId, productId, name, description, priceInCents, currency }) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to update a product') + } + + if (!productId) { + throw new Error('Product ID is required') + } + + // First, get the current product to compare values + const currentProduct = await stripe.products.retrieve(productId, { + expand: ['default_price'] + }, { + stripeAccount: accountId + }) + + const updateData = {} + + // Only update fields that are provided and different + if (name !== undefined && name !== currentProduct.name) { + updateData.name = name + } + + if (description !== undefined && description !== currentProduct.description) { + updateData.description = description || '' + } + + // Handle price updates - this requires updating the price, not the product + if (priceInCents !== undefined || currency !== undefined) { + const currentPrice = currentProduct.default_price + const newPriceInCents = priceInCents !== undefined ? priceInCents : currentPrice.unit_amount + const newCurrency = currency !== undefined ? currency.toLowerCase() : currentPrice.currency + + // Only update price if it's different + if (newPriceInCents !== currentPrice.unit_amount || newCurrency !== currentPrice.currency) { + // Create a new price for the product + const newPrice = await stripe.prices.create({ + product: productId, + unit_amount: newPriceInCents, + currency: newCurrency + }, { + stripeAccount: accountId + }) + + // Update the product to use the new default price + updateData.default_price = newPrice.id + } + } + + // Only proceed with update if there are changes + if (Object.keys(updateData).length === 0) { + return currentProduct // No changes needed + } + + // Update the product in Stripe + const updatedProduct = await stripe.products.update(productId, updateData, { + stripeAccount: accountId + }) + + // If we created a new price, retrieve the product with expanded price info + if (updateData.default_price) { + return await stripe.products.retrieve(productId, { + expand: ['default_price'] + }, { + stripeAccount: accountId + }) + } + + return updatedProduct + } catch (error) { + console.error('Error updating product:', error) + throw new Error(`Failed to update product: ${error.message}`) + } + }, + /** * Retrieves all products for a connected account * diff --git a/apps/backend/migrations/20251020160838_paid-content-stripe.js b/apps/backend/migrations/20251020160838_paid-content-stripe.js index 88eeed48e2..46624187e2 100644 --- a/apps/backend/migrations/20251020160838_paid-content-stripe.js +++ b/apps/backend/migrations/20251020160838_paid-content-stripe.js @@ -23,11 +23,11 @@ exports.up = async function (knex) { table.text('description') table.integer('price_in_cents').notNullable() table.string('currency', 3).notNullable().defaultTo('usd') - table.boolean('active').defaultTo(true) table.bigInteger('track_id').unsigned().references('id').inTable('tracks') table.jsonb('content_access').defaultTo('{}').comment('Defines what access this product grants - groups, tracks, roles') table.string('renewal_policy', 20).defaultTo('manual').comment('Renewal policy: automatic or manual') table.string('duration', 20).comment('Duration: month, season, annual, lifetime, or null for no expiration') + table.string('publish_status', 20).defaultTo('unpublished').comment('Publish status: unpublished, unlisted, published, archived') table.timestamps(true, true) table.index(['group_id']) diff --git a/apps/backend/migrations/schema.sql b/apps/backend/migrations/schema.sql index b61bdbf542..8ada57fba7 100644 --- a/apps/backend/migrations/schema.sql +++ b/apps/backend/migrations/schema.sql @@ -2869,11 +2869,11 @@ CREATE TABLE public.stripe_products ( description text, price_in_cents integer NOT NULL, currency character varying(3) NOT NULL DEFAULT 'usd'::character varying, - active boolean DEFAULT true, track_id bigint, content_access jsonb DEFAULT '{}'::jsonb, renewal_policy character varying(20) DEFAULT 'manual'::character varying, duration character varying(20), + publish_status character varying(20) DEFAULT 'unpublished'::character varying, created_at timestamp with time zone, updated_at timestamp with time zone ); From 002a2c8c82dc7e95d5c2f3fae6d2fd49a29ae7ef Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 27 Oct 2025 10:06:58 +1100 Subject: [PATCH 06/76] Get tests *runnnig* but not yet passing --- apps/backend/migrations/schema.sql | 66 +++++++++++++----------- apps/backend/test/setup/index.js | 81 ++++++++++++++++++++++++++++-- docs/PAID_CONTENT_WORKFLOW.md | 31 ++++++++---- 3 files changed, 133 insertions(+), 45 deletions(-) diff --git a/apps/backend/migrations/schema.sql b/apps/backend/migrations/schema.sql index 8ada57fba7..95c6309000 100644 --- a/apps/backend/migrations/schema.sql +++ b/apps/backend/migrations/schema.sql @@ -2856,6 +2856,18 @@ CREATE SEQUENCE public.stripe_accounts_id_seq ALTER SEQUENCE public.stripe_accounts_id_seq OWNED BY public.stripe_accounts.id; +-- +-- Name: stripe_products_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.stripe_products_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + -- -- Name: stripe_products; Type: TABLE; Schema: public; Owner: - -- @@ -2880,22 +2892,22 @@ CREATE TABLE public.stripe_products ( -- --- Name: stripe_products_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- Name: stripe_products_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - -- -CREATE SEQUENCE public.stripe_products_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; +ALTER SEQUENCE public.stripe_products_id_seq OWNED BY public.stripe_products.id; -- --- Name: stripe_products_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- Name: content_access_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- -ALTER SEQUENCE public.stripe_products_id_seq OWNED BY public.stripe_products.id; +CREATE SEQUENCE public.content_access_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; -- @@ -2921,18 +2933,6 @@ CREATE TABLE public.content_access ( ); --- --- Name: content_access_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.content_access_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -- -- Name: content_access_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - -- @@ -5523,6 +5523,22 @@ CREATE INDEX content_access_access_type_index ON public.content_access USING btr CREATE INDEX content_access_status_index ON public.content_access USING btree (status); +-- +-- Name: stripe_products stripe_products_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stripe_products + ADD CONSTRAINT stripe_products_pkey PRIMARY KEY (id); + + +-- +-- Name: content_access content_access_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_pkey PRIMARY KEY (id); + + -- -- Name: activities activities_contribution_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -7067,14 +7083,6 @@ ALTER TABLE ONLY public.users ADD CONSTRAINT users_location_id_foreign FOREIGN KEY (location_id) REFERENCES public.locations(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED; --- --- Name: users users_stripe_account_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.users - ADD CONSTRAINT users_stripe_account_id_foreign FOREIGN KEY (stripe_account_id) REFERENCES public.stripe_accounts(id); - - -- -- Name: zapier_triggers_groups zapier_triggers_groups_group_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- diff --git a/apps/backend/test/setup/index.js b/apps/backend/test/setup/index.js index 5cb3002379..6a8b241e35 100644 --- a/apps/backend/test/setup/index.js +++ b/apps/backend/test/setup/index.js @@ -1,13 +1,52 @@ +/* eslint-disable import/first */ process.env.NODE_ENV = 'test' +process.env.STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || 'sk_test_fake_key_for_testing_purposes' +process.env.STRIPE_API_KEY = process.env.STRIPE_API_KEY || 'sk_test_fake_api_key_for_testing_purposes' import nock from 'nock' import './core' +const mock = require('mock-require') const skiff = require('../../lib/skiff') const fs = require('fs') const path = require('path') const Promise = require('bluebird') const root = require('root-path') +// Mock Stripe before any modules are loaded +const mockStripe = function (apiKey) { + return { + accounts: { + create: async () => ({}), + retrieve: async () => ({}), + update: async () => ({}) + }, + accountLinks: { + create: async () => ({}) + }, + products: { + create: async () => ({}), + update: async () => ({}), + list: async () => ({}) + }, + prices: { + create: async () => ({}), + retrieve: async () => ({}) + }, + checkout: { + sessions: { + create: async () => ({}), + retrieve: async () => ({}) + } + }, + paymentIntents: { + update: async () => ({}) + } + } +} + +// Set up mock-require to intercept all Stripe imports +mock('stripe', mockStripe) + const TestSetup = function () { this.tables = [] this.initialized = false @@ -23,7 +62,7 @@ before(function (done) { global.sails = skiff.sails skiff.lift({ - log: {level: process.env.LOG_LEVEL || 'warn'}, + log: { level: process.env.LOG_LEVEL || 'warn' }, silent: true, start: function () { const { database } = bookshelf.knex.client.connectionSettings @@ -72,13 +111,45 @@ TestSetup.prototype.createSchema = function () { .then(() => bookshelf.knex.raw('create schema public').transacting(trx)) .then(() => { const script = fs.readFileSync(root('migrations/schema.sql')).toString() - return script.split(/\n/) + + // Remove comment lines + const cleaned = script.split(/\n/) .filter(line => !line.startsWith('--')) .join(' ') .replace(/\s+/g, ' ') - .split(/;\s?/) - .map(line => line.trim()) - .filter(line => line !== '') + + // Split by semicolon but preserve dollar-quoted strings + const commands = [] + let inDollarQuote = false + let currentCommand = '' + + for (let i = 0; i < cleaned.length; i++) { + const char = cleaned[i] + const nextChar = cleaned[i + 1] || '' + + // Check if we're entering or exiting a dollar-quoted string + if (char === '$' && nextChar === '$') { + inDollarQuote = !inDollarQuote + currentCommand += char + if (nextChar) currentCommand += cleaned[++i] + } else if (char === ';' && !inDollarQuote) { + currentCommand += char + const trimmed = currentCommand.trim() + if (trimmed) { + commands.push(trimmed) + } + currentCommand = '' + } else { + currentCommand += char + } + } + + // Add the last command if any + if (currentCommand.trim()) { + commands.push(currentCommand.trim()) + } + + return commands.filter(line => line !== '') }) .then(commands => { return Promise.map(commands, command => { diff --git a/docs/PAID_CONTENT_WORKFLOW.md b/docs/PAID_CONTENT_WORKFLOW.md index 0b52d95ae6..0c0ba0ad47 100644 --- a/docs/PAID_CONTENT_WORKFLOW.md +++ b/docs/PAID_CONTENT_WORKFLOW.md @@ -28,13 +28,12 @@ The paid content system enables groups to: - Admin goes to `/groups/{groupSlug}/settings/paid-content` - Only group administrators can access this section -2. **Create Stripe Connected Account** +2. **Connect Stripe Account** - Click "Connect to Stripe" button - - Fill out business information: - - Email address - - Business name - - Country - - System creates **unverified** Stripe Connected Account + - Choose connection method: + - **Create New Account**: Fill out business information (email, business name, country) + - **Use Existing Account**: Provide existing Stripe account ID + - System either creates new account or connects existing one - Account requires completion of Stripe's onboarding process 3. **Complete Stripe Onboarding** @@ -55,19 +54,26 @@ The paid content system enables groups to: - `payouts_enabled`: Can receive payouts (false until bank details added) - `details_submitted`: Onboarding information submitted (true when complete) - Shows any pending requirements or verification issues - - Account may be restricted until all requirements are met + - **Product publishing is disabled** until account is fully verified + - Account can be connected even if not fully verified ### Functions Invoked ```javascript -// GraphQL Mutation -createStripeConnectedAccount(groupId, email, businessName, country) +// GraphQL Mutation (supports both new and existing accounts) +createStripeConnectedAccount(groupId, email, businessName, country, existingAccountId) -// Backend Service (with metadata) +// Backend Service - New Account Creation StripeService.createConnectedAccount({ email, country, businessName, - groupId // Added for metadata correlation + groupId +}) + +// Backend Service - Existing Account Connection +StripeService.connectExistingAccount({ + accountId: existingAccountId, + groupId }) // Account Link Creation @@ -89,6 +95,9 @@ Stripe Onboarding → Return to Settings → Account Status Display 2. **Create New Product** - Click "Create Product" button + - **Verification Check**: System verifies Stripe account is ready + - If account not verified: Shows warning and disables product creation + - If account verified: Allows product creation - Fill out product details: - **Name**: e.g., "Premium Membership" - **Description**: Detailed description of what's included From f83c0d3f18a12c13b40ef820cbf3f89c5249efad Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 27 Oct 2025 10:23:43 +1100 Subject: [PATCH 07/76] wip web changes --- .../PaidContentTab/PaidContentTab.js | 647 ++++++++++++++++++ .../PaidContentTab/PaidContentTab.store.js | 229 +++++++ .../GroupSettings/PaidContentTab/index.js | 1 + apps/web/src/routes/GroupStore/GroupStore.js | 423 ++++++++++++ apps/web/src/routes/GroupStore/index.js | 2 + 5 files changed, 1302 insertions(+) create mode 100644 apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js create mode 100644 apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js create mode 100644 apps/web/src/routes/GroupSettings/PaidContentTab/index.js create mode 100644 apps/web/src/routes/GroupStore/GroupStore.js create mode 100644 apps/web/src/routes/GroupStore/index.js diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js new file mode 100644 index 0000000000..0911558776 --- /dev/null +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js @@ -0,0 +1,647 @@ +/** + * PaidContentTab Component + * + * Manages Stripe Connect integration for groups. + * Allows group administrators to: + * - Onboard their group to Stripe Connect + * - Create products for group membership subscriptions + * - Define charges for track content + * - View onboarding status + */ + +import React, { useCallback, useEffect, useState } from 'react' +import { useDispatch } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { CreditCard, CheckCircle, AlertCircle, ExternalLink, PlusCircle } from 'lucide-react' + +import Button from 'components/ui/button' +import Loading from 'components/Loading' +import SettingsControl from 'components/SettingsControl' +import SettingsSection from '../SettingsSection' +import { useViewHeader } from 'contexts/ViewHeaderContext' + +import { + createConnectedAccount, + createAccountLink, + fetchAccountStatus, + createProduct, + fetchProducts +} from './PaidContentTab.store' +import { updateGroupSettings } from '../GroupSettings.store' + +/** + * Main PaidContentTab component + * Orchestrates the Stripe Connect integration UI + */ +function PaidContentTab ({ group }) { + const { t } = useTranslation() + const dispatch = useDispatch() + + // Local state for managing Stripe account and products + const [state, setState] = useState({ + accountId: group?.stripeAccountId || '', + accountStatus: null, + products: [], + loading: false, + error: null + }) + + const { setHeaderDetails } = useViewHeader() + + useEffect(() => { + setHeaderDetails({ + title: { + desktop: `${t('Group Settings')} > ${t('Paid Content')}`, + mobile: `${t('Paid Content')}` + }, + icon: 'CreditCard' + }) + }, []) + + // Update accountId when group changes + useEffect(() => { + if (group?.stripeAccountId && group.stripeAccountId !== state.accountId) { + setState(prev => ({ ...prev, accountId: group.stripeAccountId })) + } + }, [group?.stripeAccountId]) + + // Load account status if we have an account ID + useEffect(() => { + if (state.accountId && group?.id) { + loadAccountStatus() + loadProducts() + } + }, [state.accountId, group?.id]) + + /** + * Creates a new Stripe Connected Account for this group + * + * This is the first step in enabling payments. Once the account is created, + * the group admin needs to complete onboarding via an Account Link. + */ + const handleCreateAccount = useCallback(async () => { + if (!group) return + + setState(prev => ({ ...prev, loading: true, error: null })) + + try { + const result = await dispatch(createConnectedAccount( + group.id, + group.contactEmail || '', // TODO STRIPE: Use appropriate email field + group.name, + 'US' // TODO STRIPE: Make country selectable or detect from user + )) + + if (result.error) { + throw new Error(result.error.message || 'Failed to create connected account') + } + + const accountId = result.payload?.data?.createStripeConnectedAccount?.accountId + + if (!accountId) { + throw new Error('No account ID returned from server') + } + + // Save accountId to your group model in the database + await dispatch(updateGroupSettings(group.id, { stripeAccountId: accountId })) + + setState(prev => ({ + ...prev, + accountId, + loading: false + })) + + // Automatically trigger onboarding after account creation + handleStartOnboarding(accountId) + } catch (error) { + console.error('Error creating connected account:', error) + setState(prev => ({ + ...prev, + loading: false, + error: error.message + })) + } + }, [dispatch, group]) + + /** + * Generates an Account Link and redirects the user to Stripe + * + * The user will complete onboarding on Stripe's hosted pages, + * then return to the returnUrl when complete. + */ + const handleStartOnboarding = useCallback(async (accountIdToUse) => { + if (!group) return + + const accountId = accountIdToUse || state.accountId + if (!accountId) { + setState(prev => ({ ...prev, error: 'No account ID available' })) + return + } + + setState(prev => ({ ...prev, loading: true, error: null })) + + try { + // Build return URLs + // TODO STRIPE: Replace with your actual domain + const baseUrl = window.location.origin + const returnUrl = `${baseUrl}/groups/${group.slug}/settings/paid-content?onboarding=complete` + const refreshUrl = `${baseUrl}/groups/${group.slug}/settings/paid-content?onboarding=refresh` + + const result = await dispatch(createAccountLink( + group.id, + accountId, + returnUrl, + refreshUrl + )) + + if (result.error) { + throw new Error(result.error.message || 'Failed to create account link') + } + + const url = result.payload?.data?.createStripeAccountLink?.url + + if (!url) { + throw new Error('No onboarding URL returned from server') + } + + // Redirect to Stripe for onboarding + window.location.href = url + } catch (error) { + console.error('Error creating account link:', error) + setState(prev => ({ + ...prev, + loading: false, + error: error.message + })) + } + }, [dispatch, group, state.accountId]) + + /** + * Loads the current account status from Stripe + * + * This fetches the live status to show onboarding progress + * and payment capabilities. + */ + const loadAccountStatus = useCallback(async () => { + if (!group?.id || !state.accountId) return + + try { + const result = await dispatch(fetchAccountStatus(group.id, state.accountId)) + + if (result.error) { + throw new Error(result.error.message) + } + + const status = result.payload?.data?.stripeAccountStatus + + setState(prev => ({ + ...prev, + accountStatus: status + })) + } catch (error) { + console.error('Error loading account status:', error) + setState(prev => ({ + ...prev, + error: error.message + })) + } + }, [dispatch, group, state.accountId]) + + /** + * Loads all products for this connected account + */ + const loadProducts = useCallback(async () => { + if (!group?.id || !state.accountId) return + + try { + const result = await dispatch(fetchProducts(group.id, state.accountId)) + + if (result.error) { + throw new Error(result.error.message) + } + + const products = result.payload?.data?.stripeProducts?.products || [] + + setState(prev => ({ + ...prev, + products + })) + } catch (error) { + console.error('Error loading products:', error) + } + }, [dispatch, group, state.accountId]) + + /** + * Refreshes account status - useful after returning from onboarding + */ + const handleRefreshStatus = useCallback(() => { + loadAccountStatus() + loadProducts() + }, [loadAccountStatus, loadProducts]) + + if (!group) return + + const { accountId, accountStatus, products, loading, error } = state + + return ( +
+

{t('Accept payments for your group')}

+

+ {t('Set up Stripe Connect to accept payments for group memberships, track content, and other offerings. Stripe handles all payment processing securely.')} +

+ + {error && ( +
+ +
+

{t('Error')}

+

{error}

+
+
+ )} + + + {!accountId ? ( + + ) : ( + <> + + + + + )} + +
+ ) +} + +/** + * Section for initial account setup + * + * Displayed when the group doesn't have a Stripe account yet. + */ +function AccountSetupSection ({ loading, onCreateAccount, t }) { + return ( +
+
+ +
+

{t('Get started with payments')}

+

+ {t('Create a Stripe Connect account to start accepting payments. This allows your group to receive payments directly while maintaining security and compliance.')} +

+
    +
  • {t('Accept credit card and other payment methods')}
  • +
  • {t('Automatic payouts to your bank account')}
  • +
  • {t('Full dashboard access to view transactions')}
  • +
  • {t('Stripe handles all payment disputes and fraud')}
  • +
+ +
+
+
+ ) +} + +/** + * Section showing account onboarding status + * + * Displays the current state of the connected account and provides + * actions to complete onboarding or view the Stripe dashboard. + */ +function AccountStatusSection ({ accountStatus, loading, onStartOnboarding, onRefreshStatus, t }) { + if (loading && !accountStatus) { + return ( +
+ +
+ ) + } + + const isFullyOnboarded = accountStatus?.chargesEnabled && accountStatus?.payoutsEnabled + const needsOnboarding = !accountStatus?.detailsSubmitted + + return ( +
+
+
+ {isFullyOnboarded ? ( + + ) : ( + + )} +
+

+ {isFullyOnboarded ? t('Account Active') : t('Account Setup Required')} +

+

+ {isFullyOnboarded + ? t('Your Stripe account is fully set up and ready to accept payments.') + : t('Complete your account setup to start accepting payments.') + } +

+
+
+ +
+ + {accountStatus && ( +
+ + + +
+ )} + + {needsOnboarding && ( + + )} + + {accountStatus?.requirements && accountStatus.requirements.currently_due?.length > 0 && ( +
+

{t('Action Required')}

+

+ {t('There are {{count}} items that need your attention.', { count: accountStatus.requirements.currently_due.length })} +

+
+ )} +
+ ) +} + +/** + * Badge showing a single status indicator + */ +function StatusBadge ({ label, value, t }) { + return ( +
+
+
+

{label}

+

{value ? t('Yes') : t('No')}

+
+
+ ) +} + +/** + * Section for managing products + * + * Allows creating and viewing products that customers can purchase. + */ +function ProductManagementSection ({ group, accountId, products, onRefreshProducts, t }) { + const dispatch = useDispatch() + const [showCreateForm, setShowCreateForm] = useState(false) + const [formData, setFormData] = useState({ + name: '', + description: '', + price: '', + currency: 'usd' + }) + const [creating, setCreating] = useState(false) + + /** + * Handles product creation + */ + const handleCreateProduct = useCallback(async (e) => { + e.preventDefault() + + if (!formData.name || !formData.price) { + alert(t('Please fill in all required fields')) + return + } + + setCreating(true) + + try { + const priceInCents = Math.round(parseFloat(formData.price) * 100) + + if (isNaN(priceInCents) || priceInCents < 0) { + throw new Error(t('Invalid price')) + } + + const result = await dispatch(createProduct( + group.id, + accountId, + formData.name, + formData.description, + priceInCents, + formData.currency + )) + + if (result.error) { + throw new Error(result.error.message) + } + + // Reset form and refresh products + setFormData({ name: '', description: '', price: '', currency: 'usd' }) + setShowCreateForm(false) + onRefreshProducts() + } catch (error) { + console.error('Error creating product:', error) + alert(t('Failed to create product: {{error}}', { error: error.message })) + } finally { + setCreating(false) + } + }, [dispatch, group, accountId, formData, onRefreshProducts, t]) + + return ( +
+
+
+

{t('Products & Pricing')}

+

+ {t('Create products for memberships, tracks, or content access')} +

+
+ +
+ + {showCreateForm && ( +
+

{t('Create New Product')}

+ +
+ setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder={t('e.g., Premium Membership')} + required + /> + + setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder={t('What does this product include?')} + /> + +
+
+ setFormData(prev => ({ ...prev, price: e.target.value }))} + placeholder='20.00' + required + /> +
+
+ setFormData(prev => ({ ...prev, currency: e.target.value }))} + renderControl={(props) => ( + + )} + /> +
+
+ +
+ + +
+
+
+ )} + + {products.length === 0 ? ( +
+ +

{t('No products yet')}

+

{t('Create your first product to start accepting payments')}

+
+ ) : ( +
+ {products.map(product => ( + + ))} +
+ )} + + {products.length > 0 && ( +
+

+ {t('Storefront Link:')}{' '} + + {window.location.origin}/groups/{group.slug}/store + +

+

+ {t('Share this link with your members so they can view and purchase products')} +

+
+ )} +
+ ) +} + +/** + * Card displaying a single product + */ +function ProductCard ({ product, groupSlug, t }) { + return ( +
+
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} +
+
+

{t('ID')}: {product.id}

+ {product.active ? ( + {t('Active')} + ) : ( + {t('Inactive')} + )} +
+
+ ) +} + +export default PaidContentTab diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js new file mode 100644 index 0000000000..88a47bd39b --- /dev/null +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js @@ -0,0 +1,229 @@ +/** + * PaidContentTab Store + * + * Redux store module for managing Stripe Connect paid content functionality. + * Handles connected account creation, onboarding, and product management. + */ + +import { get } from 'lodash/fp' + +export const MODULE_NAME = 'PaidContentTab' + +export const CREATE_CONNECTED_ACCOUNT = `${MODULE_NAME}/CREATE_CONNECTED_ACCOUNT` +export const CREATE_ACCOUNT_LINK = `${MODULE_NAME}/CREATE_ACCOUNT_LINK` +export const FETCH_ACCOUNT_STATUS = `${MODULE_NAME}/FETCH_ACCOUNT_STATUS` +export const CREATE_PRODUCT = `${MODULE_NAME}/CREATE_PRODUCT` +export const FETCH_PRODUCTS = `${MODULE_NAME}/FETCH_PRODUCTS` + +/** + * Creates a Stripe Connected Account for the group + * + * This allows the group to receive payments directly while the platform + * takes an application fee. + */ +export function createConnectedAccount (groupId, email, businessName, country = 'US') { + return { + type: CREATE_CONNECTED_ACCOUNT, + graphql: { + query: `mutation ($groupId: ID!, $email: String!, $businessName: String!, $country: String) { + createStripeConnectedAccount( + groupId: $groupId + email: $email + businessName: $businessName + country: $country + ) { + id + accountId + success + message + } + }`, + variables: { + groupId, + email, + businessName, + country + } + } + } +} + +/** + * Creates an Account Link for onboarding + * + * Generates a temporary URL that allows the connected account to complete + * their onboarding process and gain access to the Stripe Dashboard. + */ +export function createAccountLink (groupId, accountId, returnUrl, refreshUrl) { + return { + type: CREATE_ACCOUNT_LINK, + graphql: { + query: `mutation ($groupId: ID!, $accountId: String!, $returnUrl: String!, $refreshUrl: String!) { + createStripeAccountLink( + groupId: $groupId + accountId: $accountId + returnUrl: $returnUrl + refreshUrl: $refreshUrl + ) { + url + expiresAt + success + } + }`, + variables: { + groupId, + accountId, + returnUrl, + refreshUrl + } + } + } +} + +/** + * Fetches the current status of a connected account + * + * Retrieves onboarding status, payment capabilities, and requirements + * directly from Stripe. + */ +export function fetchAccountStatus (groupId, accountId) { + return { + type: FETCH_ACCOUNT_STATUS, + graphql: { + query: `query ($groupId: ID!, $accountId: String!) { + stripeAccountStatus( + groupId: $groupId + accountId: $accountId + ) { + accountId + chargesEnabled + payoutsEnabled + detailsSubmitted + email + requirements { + currently_due + eventually_due + past_due + pending_verification + } + } + }`, + variables: { + groupId, + accountId + } + } + } +} + +/** + * Creates a product on the connected account + * + * Products represent subscription tiers, content access, or other offerings + * that the group wants to sell. + */ +export function createProduct (groupId, accountId, name, description, priceInCents, currency = 'usd') { + return { + type: CREATE_PRODUCT, + graphql: { + query: `mutation ($groupId: ID!, $accountId: String!, $name: String!, $description: String, $priceInCents: Int!, $currency: String) { + createStripeProduct( + groupId: $groupId + accountId: $accountId + name: $name + description: $description + priceInCents: $priceInCents + currency: $currency + ) { + productId + priceId + name + success + message + } + }`, + variables: { + groupId, + accountId, + name, + description, + priceInCents, + currency + } + } + } +} + +/** + * Fetches all products for a connected account + * + * Lists all active products that the group has created for sale. + */ +export function fetchProducts (groupId, accountId) { + return { + type: FETCH_PRODUCTS, + graphql: { + query: `query ($groupId: ID!, $accountId: String!) { + stripeProducts( + groupId: $groupId + accountId: $accountId + ) { + products { + id + name + description + defaultPriceId + images + active + } + success + } + }`, + variables: { + groupId, + accountId + } + } + } +} + +/** + * Selector to get account status from state + */ +export function getAccountStatus (state) { + return get('PaidContentTab.accountStatus', state) +} + +/** + * Selector to get products from state + */ +export function getProducts (state) { + return get('PaidContentTab.products', state) || [] +} + +/** + * Reducer for PaidContentTab state + */ +export default function reducer (state = {}, action) { + const { type, payload, error } = action + + if (error) return state + + switch (type) { + case FETCH_ACCOUNT_STATUS: + return { + ...state, + accountStatus: payload?.data?.stripeAccountStatus + } + + case FETCH_PRODUCTS: + return { + ...state, + products: payload?.data?.stripeProducts?.products || [] + } + + default: + return state + } +} + diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/index.js b/apps/web/src/routes/GroupSettings/PaidContentTab/index.js new file mode 100644 index 0000000000..b9818e4fab --- /dev/null +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/index.js @@ -0,0 +1 @@ +export { default } from './PaidContentTab' diff --git a/apps/web/src/routes/GroupStore/GroupStore.js b/apps/web/src/routes/GroupStore/GroupStore.js new file mode 100644 index 0000000000..f829156635 --- /dev/null +++ b/apps/web/src/routes/GroupStore/GroupStore.js @@ -0,0 +1,423 @@ +/** + * GroupStore Component + * + * Public-facing storefront that displays products available for purchase + * from a group's Stripe Connected Account. + * + * Customers can browse products and initiate checkout sessions. + * + * URL: /groups/:groupSlug/store + * + * NOTE: In production, you should use a more stable identifier than + * the Stripe account ID in URLs. Consider using your group slug or ID + * and looking up the associated Stripe account ID from your database. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { CreditCard, ShoppingCart, CheckCircle, ExternalLink } from 'lucide-react' + +import Button from 'components/ui/button' +import Loading from 'components/Loading' +import { useViewHeader } from 'contexts/ViewHeaderContext' +import getGroupForSlug from 'store/selectors/getGroupForSlug' + +/** + * Main GroupStore component + * + * Displays all products for a group and allows customers to purchase them + */ +function GroupStore () { + const { t } = useTranslation() + const dispatch = useDispatch() + const { groupSlug } = useParams() + + // Get group from Redux store + const group = useSelector(state => getGroupForSlug(state, groupSlug)) + + // Local state for products and checkout + const [state, setState] = useState({ + // TODO STRIPE: Load accountId from group.stripe_account_id in your database + accountId: '', // PLACEHOLDER: Replace with actual account ID from database + products: [], + loading: true, + error: null, + checkoutLoading: false + }) + + const { setHeaderDetails } = useViewHeader() + + useEffect(() => { + setHeaderDetails({ + title: { + desktop: `${group?.name || ''} ${t('Store')}`, + mobile: t('Store') + }, + icon: 'ShoppingCart' + }) + }, [group, t]) + + /** + * Loads products from the backend + * + * This makes a GraphQL query to fetch products from the connected account + */ + const loadProducts = useCallback(async () => { + if (!group?.id || !state.accountId) { + setState(prev => ({ + ...prev, + loading: false, + error: t('Group or account not found') + })) + return + } + + setState(prev => ({ ...prev, loading: true, error: null })) + + try { + // Make GraphQL query to fetch products + const query = ` + query ($groupId: ID!, $accountId: String!) { + stripeProducts( + groupId: $groupId + accountId: $accountId + ) { + products { + id + name + description + defaultPriceId + images + active + } + success + } + } + ` + + const response = await fetch('/noo/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ + query, + variables: { + groupId: group.id, + accountId: state.accountId + } + }) + }) + + const result = await response.json() + + if (result.errors) { + throw new Error(result.errors[0].message) + } + + const products = result.data?.stripeProducts?.products || [] + + setState(prev => ({ + ...prev, + products, + loading: false + })) + } catch (error) { + console.error('Error loading products:', error) + setState(prev => ({ + ...prev, + loading: false, + error: error.message + })) + } + }, [group, state.accountId, t]) + + useEffect(() => { + if (state.accountId) { + loadProducts() + } + }, [state.accountId]) + + /** + * Initiates a checkout session for a product + * + * Creates a Stripe Checkout session and redirects the customer + * to the hosted checkout page. + */ + const handlePurchase = useCallback(async (product) => { + if (!group?.id || !state.accountId) return + + setState(prev => ({ ...prev, checkoutLoading: true })) + + try { + // Make GraphQL mutation to create checkout session + const mutation = ` + mutation ($groupId: ID!, $accountId: String!, $priceId: String!, $quantity: Int, $successUrl: String!, $cancelUrl: String!, $metadata: JSON) { + createStripeCheckoutSession( + groupId: $groupId + accountId: $accountId + priceId: $priceId + quantity: $quantity + successUrl: $successUrl + cancelUrl: $cancelUrl + metadata: $metadata + ) { + sessionId + url + success + } + } + ` + + const baseUrl = window.location.origin + const successUrl = `${baseUrl}/groups/${groupSlug}/store/success` + const cancelUrl = `${baseUrl}/groups/${groupSlug}/store` + + const response = await fetch('/noo/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ + query: mutation, + variables: { + groupId: group.id, + accountId: state.accountId, + priceId: product.defaultPriceId, + quantity: 1, + successUrl, + cancelUrl, + metadata: { + productId: product.id, + groupSlug: groupSlug + } + } + }) + }) + + const result = await response.json() + + if (result.errors) { + throw new Error(result.errors[0].message) + } + + const checkoutUrl = result.data?.createStripeCheckoutSession?.url + + if (!checkoutUrl) { + throw new Error(t('No checkout URL returned')) + } + + // Redirect to Stripe Checkout + window.location.href = checkoutUrl + } catch (error) { + console.error('Error creating checkout session:', error) + alert(t('Failed to start checkout: {{error}}', { error: error.message })) + setState(prev => ({ ...prev, checkoutLoading: false })) + } + }, [group, state.accountId, groupSlug, t]) + + if (!group) { + return ( +
+ +
+ ) + } + + if (!state.accountId) { + return ( +
+ +
+ ) + } + + if (state.loading) { + return ( +
+ +
+ ) + } + + if (state.error) { + return ( +
+
+

{t('Error')}

+

{state.error}

+
+
+ ) + } + + if (state.products.length === 0) { + return ( +
+ +
+ ) + } + + return ( +
+
+

+ {group.name} {t('Store')} +

+

+ {t('Browse and purchase products from this group')} +

+
+ +
+ {state.products.map(product => ( + handlePurchase(product)} + loading={state.checkoutLoading} + t={t} + /> + ))} +
+
+ ) +} + +/** + * Displayed when the group hasn't set up payments yet + */ +function NoStoreSetup ({ group, t }) { + return ( +
+ +

{t('Store Not Available')}

+

+ {t('This group hasn\'t set up their store yet.')} +

+

+ {t('Group administrators can set up payments in group settings.')} +

+
+ ) +} + +/** + * Displayed when there are no products available + */ +function NoProductsAvailable ({ group, t }) { + return ( +
+ +

{t('No Products Available')}

+

+ {t('This group doesn\'t have any products for sale yet.')} +

+

+ {t('Check back later for new offerings.')} +

+
+ ) +} + +/** + * Card displaying a single product + */ +function ProductCard ({ product, onPurchase, loading, t }) { + return ( +
+ {/* Product Image */} + {product.images && product.images.length > 0 ? ( +
+ {product.name} +
+ ) : ( +
+ +
+ )} + + {/* Product Info */} +
+

+ {product.name} +

+ + {product.description && ( +

+ {product.description} +

+ )} + + {/* Purchase Button */} + + +

+ {t('Powered by Stripe')} +

+
+
+ ) +} + +/** + * Success page after checkout + * + * Displayed when customer returns from successful payment + */ +export function GroupStoreSuccess () { + const { t } = useTranslation() + const { groupSlug } = useParams() + + return ( +
+
+ +

{t('Payment Successful!')}

+

+ {t('Thank you for your purchase. You should receive a confirmation email shortly.')} +

+
+ + +
+
+
+ ) +} + +export default GroupStore + diff --git a/apps/web/src/routes/GroupStore/index.js b/apps/web/src/routes/GroupStore/index.js new file mode 100644 index 0000000000..1d7921bad8 --- /dev/null +++ b/apps/web/src/routes/GroupStore/index.js @@ -0,0 +1,2 @@ +export { default, GroupStoreSuccess } from './GroupStore' + From d08ad7fd1271b1356d5c2d931a5bae67a88312c7 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 28 Oct 2025 20:36:02 +1100 Subject: [PATCH 08/76] Stripe mutations tests all passing --- apps/backend/api/graphql/mutations/stripe.js | 154 ++++++---- .../api/graphql/mutations/stripe.test.js | 263 +++++++++++------- 2 files changed, 249 insertions(+), 168 deletions(-) diff --git a/apps/backend/api/graphql/mutations/stripe.js b/apps/backend/api/graphql/mutations/stripe.js index d4e84d43a6..3005582cf9 100644 --- a/apps/backend/api/graphql/mutations/stripe.js +++ b/apps/backend/api/graphql/mutations/stripe.js @@ -10,36 +10,17 @@ import { GraphQLError } from 'graphql' -/* global StripeProduct */ +/* global StripeProduct, Responsibility, Group, GroupMembership, StripeAccount */ module.exports = { /** * Creates a Stripe Connected Account for a group - * - * This mutation allows group administrators to create a connected account - * that enables them to receive payments directly. The platform takes - * an application fee on each transaction. - * - * Usage: - * mutation { - * createStripeConnectedAccount( - * groupId: "123" - * email: "group@example.com" - * businessName: "My Group" - * country: "US" - * existingAccountId: "acct_xxx" # Optional: existing Stripe account - * ) { - * id - * accountId - * success - * } - * } */ - createStripeConnectedAccount: async (root, { groupId, email, businessName, country, existingAccountId }, { session }) => { + createStripeConnectedAccount: async (userId, { groupId, email, businessName, country, existingAccountId }) => { try { // Check if user is authenticated - if (!session || !session.userId) { + if (!userId) { throw new GraphQLError('You must be logged in to create a connected account') } @@ -50,8 +31,8 @@ module.exports = { } // Check if user is a steward/admin of the group - const membership = await GroupMembership.forPair(session.userId, groupId).fetch() - if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { throw new GraphQLError('You must be a group administrator to create a connected account') } @@ -63,6 +44,11 @@ module.exports = { let account if (existingAccountId) { + // Validate the Stripe account ID format + if (!existingAccountId.startsWith('acct_')) { + throw new GraphQLError('Invalid Stripe account ID provided') + } + // Connect existing Stripe account account = await StripeService.connectExistingAccount({ accountId: existingAccountId, @@ -71,15 +57,26 @@ module.exports = { } else { // Create new Stripe account account = await StripeService.createConnectedAccount({ - email: email || group.get('contact_email'), + email: email || `${group.get('name')}@hylo.com`, country: country || 'US', businessName: businessName || group.get('name'), groupId }) } - // Save the account ID to the database - await group.save({ stripe_account_id: account.id }) + // Find or create a StripeAccount record with this external ID + let stripeAccountRecord = await StripeAccount.where({ + stripe_account_external_id: account.id + }).fetch() + + if (!stripeAccountRecord) { + stripeAccountRecord = await StripeAccount.forge({ + stripe_account_external_id: account.id + }).save() + } + + // Save the database ID to the group + await group.save({ stripe_account_id: stripeAccountRecord.id }) return { id: groupId, @@ -88,6 +85,10 @@ module.exports = { message: 'Connected account created successfully' } } catch (error) { + // If it's already a GraphQLError, rethrow it as-is + if (error instanceof GraphQLError) { + throw error + } console.error('Error in createStripeConnectedAccount:', error) throw new GraphQLError(`Failed to create connected account: ${error.message}`) } @@ -112,16 +113,16 @@ module.exports = { * } * } */ - createStripeAccountLink: async (root, { groupId, accountId, returnUrl, refreshUrl }, { session }) => { + createStripeAccountLink: async (userId, { groupId, accountId, returnUrl, refreshUrl }) => { try { // Check if user is authenticated - if (!session || !session.userId) { + if (!userId) { throw new GraphQLError('You must be logged in to create an account link') } // Verify user has permission for this group - const membership = await GroupMembership.forPair(session.userId, groupId).fetch() - if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { throw new GraphQLError('You must be a group administrator to manage payments') } @@ -138,6 +139,9 @@ module.exports = { success: true } } catch (error) { + if (error instanceof GraphQLError) { + throw error + } console.error('Error in createStripeAccountLink:', error) throw new GraphQLError(`Failed to create account link: ${error.message}`) } @@ -161,16 +165,16 @@ module.exports = { * } * } */ - stripeAccountStatus: async (root, { groupId, accountId }, { session }) => { + stripeAccountStatus: async (userId, { groupId, accountId }) => { try { // Check if user is authenticated - if (!session || !session.userId) { + if (!userId) { throw new GraphQLError('You must be logged in to view account status') } // Verify user has permission for this group - const membership = await GroupMembership.forPair(session.userId, groupId).fetch() - if (!membership) { + const hasMembership = await GroupMembership.hasActiveMembership(userId, groupId) + if (!hasMembership) { throw new GraphQLError('You must be a member of this group to view payment status') } @@ -186,6 +190,9 @@ module.exports = { requirements: status.requirements } } catch (error) { + if (error instanceof GraphQLError) { + throw error + } console.error('Error in stripeAccountStatus:', error) throw new GraphQLError(`Failed to retrieve account status: ${error.message}`) } @@ -220,7 +227,7 @@ module.exports = { * } * } */ - createStripeProduct: async (root, { + createStripeProduct: async (userId, { groupId, accountId, name, @@ -231,16 +238,16 @@ module.exports = { renewalPolicy, duration, publishStatus - }, { session }) => { + }) => { try { // Check if user is authenticated - if (!session || !session.userId) { + if (!userId) { throw new GraphQLError('You must be logged in to create a product') } // Verify user has permission for this group - const membership = await GroupMembership.forPair(session.userId, groupId).fetch() - if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { throw new GraphQLError('You must be a group administrator to create products') } @@ -277,6 +284,9 @@ module.exports = { message: 'Product created successfully' } } catch (error) { + if (error instanceof GraphQLError) { + throw error + } console.error('Error in createStripeProduct:', error) throw new GraphQLError(`Failed to create product: ${error.message}`) } @@ -308,7 +318,7 @@ module.exports = { * } * } */ - updateStripeProduct: async (root, { + updateStripeProduct: async (userId, { productId, name, description, @@ -318,21 +328,21 @@ module.exports = { renewalPolicy, duration, publishStatus - }, { session }) => { + }) => { try { // Check if user is authenticated - if (!session || !session.userId) { + if (!userId) { throw new GraphQLError('You must be logged in to update a product') } // Load the product and verify permissions - const product = await StripeProduct.find(productId) + const product = await StripeProduct.where({ id: productId }).fetch() if (!product) { throw new GraphQLError('Product not found') } - const membership = await GroupMembership.forPair(session.userId, product.get('group_id')).fetch() - if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + const hasAdmin = await GroupMembership.hasResponsibility(userId, product.get('group_id'), Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { throw new GraphQLError('You must be a group administrator to update products') } @@ -378,6 +388,11 @@ module.exports = { throw new GraphQLError('Group does not have a connected Stripe account') } + // If price is being updated but not currency, preserve the existing currency + if (priceInCents !== undefined && currency === undefined) { + stripeSyncFields.currency = product.get('currency') + } + // Update product in Stripe first const updatedStripeProduct = await StripeService.updateProduct({ accountId: group.get('stripe_account_id'), @@ -386,21 +401,31 @@ module.exports = { }) // Update our database with the actual values from Stripe - updateAttrs.name = updatedStripeProduct.name - updateAttrs.description = updatedStripeProduct.description - updateAttrs.price_in_cents = updatedStripeProduct.default_price.unit_amount - updateAttrs.currency = updatedStripeProduct.default_price.currency - updateAttrs.stripe_price_id = updatedStripeProduct.default_price + // Only update fields that were actually provided in the request + if (name !== undefined) updateAttrs.name = updatedStripeProduct.name + if (description !== undefined) updateAttrs.description = updatedStripeProduct.description + if (priceInCents !== undefined) updateAttrs.price_in_cents = updatedStripeProduct.default_price.unit_amount + if (currency !== undefined || (priceInCents !== undefined && currency === undefined)) { + // Update currency if explicitly provided, or if price changed and we preserved it + updateAttrs.currency = updatedStripeProduct.default_price.currency + } + // Always update the stripe_price_id if price changed + if (priceInCents !== undefined || currency !== undefined) { + updateAttrs.stripe_price_id = updatedStripeProduct.default_price.id + } } // Update the product in our database - await StripeProduct.update(productId, updateAttrs) + await product.save(updateAttrs) return { success: true, message: 'Product updated successfully' } } catch (error) { + if (error instanceof GraphQLError) { + throw error + } console.error('Error in updateStripeProduct:', error) throw new GraphQLError(`Failed to update product: ${error.message}`) } @@ -427,21 +452,24 @@ module.exports = { * } * } */ - stripeProducts: async (root, { groupId, accountId }, { session }) => { + stripeProducts: async (userId, { groupId, accountId }) => { try { // Check if user is authenticated - if (!session || !session.userId) { + if (!userId) { throw new GraphQLError('You must be logged in to view products') } // Verify user has permission for this group - const membership = await GroupMembership.forPair(session.userId, groupId).fetch() - if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { throw new GraphQLError('You must be a group administrator to view products') } // Get products from Stripe - const products = await StripeService.getProducts(accountId) + const productsResponse = await StripeService.getProducts(accountId) + + // Extract products array from Stripe response (which has a 'data' property) + const products = productsResponse.data || productsResponse // Format products for GraphQL response const formattedProducts = products.map(product => ({ @@ -458,6 +486,9 @@ module.exports = { success: true } } catch (error) { + if (error instanceof GraphQLError) { + throw error + } console.error('Error in stripeProducts:', error) throw new GraphQLError(`Failed to retrieve products: ${error.message}`) } @@ -484,7 +515,7 @@ module.exports = { * } * } */ - createStripeCheckoutSession: async (root, { + createStripeCheckoutSession: async (userId, { groupId, accountId, priceId, @@ -492,7 +523,7 @@ module.exports = { successUrl, cancelUrl, metadata - }, { session }) => { + }) => { try { // Authentication is optional for checkout - you may want to allow guests // For this demo, we'll allow unauthenticated purchases @@ -519,7 +550,7 @@ module.exports = { cancelUrl, metadata: { groupId, - userId: session?.userId, + userId, priceAmount: priceObject.unit_amount, currency: priceObject.currency, ...metadata @@ -532,6 +563,9 @@ module.exports = { success: true } } catch (error) { + if (error instanceof GraphQLError) { + throw error + } console.error('Error in createStripeCheckoutSession:', error) throw new GraphQLError(`Failed to create checkout session: ${error.message}`) } diff --git a/apps/backend/api/graphql/mutations/stripe.test.js b/apps/backend/api/graphql/mutations/stripe.test.js index aa158b59cf..ad564dd6da 100644 --- a/apps/backend/api/graphql/mutations/stripe.test.js +++ b/apps/backend/api/graphql/mutations/stripe.test.js @@ -1,5 +1,5 @@ /* eslint-disable no-unused-expressions */ -import '../../../test/setup' +import setup from '../../../test/setup' import factories from '../../../test/setup/factories' import mock from 'mock-require' import { @@ -13,7 +13,7 @@ import { } from './stripe' const { expect } = require('chai') -/* global setup */ +/* global StripeAccount, GroupMembership, StripeProduct */ // Mock StripeService to avoid real API calls const mockStripeService = { @@ -106,9 +106,6 @@ describe('Stripe Mutations', () => { adminUser = await factories.user().save() group = await factories.group().save() - // Add Stripe account ID to the test group - await group.save({ stripe_account_id: 'acct_test_123' }) - // Add admin user as group administrator await adminUser.joinGroup(group, { role: GroupMembership.Role.MODERATOR }) // Add regular user as group member @@ -119,12 +116,15 @@ describe('Stripe Mutations', () => { describe('createStripeConnectedAccount', () => { it('creates a connected account for group admins', async () => { - const result = await createStripeConnectedAccount({ - groupId: group.id, + const testGroup = await factories.group().save() + await adminUser.joinGroup(testGroup, { role: GroupMembership.Role.MODERATOR }) + + const result = await createStripeConnectedAccount(adminUser.id, { + groupId: testGroup.id, email: 'group@example.com', businessName: 'Test Group', country: 'US' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.accountId).to.equal('acct_test_123') @@ -132,12 +132,13 @@ describe('Stripe Mutations', () => { }) it('uses group email and name as defaults', async () => { - await group.save({ contact_email: 'default@group.com', name: 'Default Group' }) + const testGroup = await factories.group({ name: 'Default Group' }).save() + await adminUser.joinGroup(testGroup, { role: GroupMembership.Role.MODERATOR }) - const result = await createStripeConnectedAccount({ - groupId: group.id, + const result = await createStripeConnectedAccount(adminUser.id, { + groupId: testGroup.id, country: 'CA' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.accountId).to.equal('acct_test_123') @@ -145,42 +146,45 @@ describe('Stripe Mutations', () => { it('rejects creation for non-authenticated users', async () => { await expect( - createStripeConnectedAccount({ + createStripeConnectedAccount(null, { groupId: group.id, email: 'test@example.com', businessName: 'Test' - }, { session: null }) + }) ).to.be.rejectedWith('You must be logged in to create a connected account') }) it('rejects creation for non-admin users', async () => { await expect( - createStripeConnectedAccount({ + createStripeConnectedAccount(user.id, { groupId: group.id, email: 'test@example.com', businessName: 'Test' - }, { session: { userId: user.id } }) + }) ).to.be.rejectedWith('You must be a group administrator to create a connected account') }) it('rejects creation for non-existent group', async () => { await expect( - createStripeConnectedAccount({ + createStripeConnectedAccount(adminUser.id, { groupId: 99999, email: 'test@example.com', businessName: 'Test' - }, { session: { userId: adminUser.id } }) + }) ).to.be.rejectedWith('Group not found') }) it('connects existing Stripe account when existingAccountId provided', async () => { - const result = await createStripeConnectedAccount({ - groupId: group.id, + const testGroup = await factories.group().save() + await adminUser.joinGroup(testGroup, { role: GroupMembership.Role.MODERATOR }) + + const result = await createStripeConnectedAccount(adminUser.id, { + groupId: testGroup.id, email: 'group@example.com', businessName: 'Test Group', country: 'US', existingAccountId: 'acct_existing_123' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.accountId).to.equal('acct_existing_123') @@ -188,47 +192,56 @@ describe('Stripe Mutations', () => { }) it('rejects connection if group already has Stripe account', async () => { + const testGroup = await factories.group().save() + await adminUser.joinGroup(testGroup, { role: GroupMembership.Role.MODERATOR }) + // First, create an account - await createStripeConnectedAccount({ - groupId: group.id, + await createStripeConnectedAccount(adminUser.id, { + groupId: testGroup.id, email: 'group@example.com', businessName: 'Test Group', country: 'US' - }, { session: { userId: adminUser.id } }) + }) // Try to create another account await expect( - createStripeConnectedAccount({ - groupId: group.id, + createStripeConnectedAccount(adminUser.id, { + groupId: testGroup.id, email: 'group2@example.com', businessName: 'Test Group 2', country: 'US' - }, { session: { userId: adminUser.id } }) + }) ).to.be.rejectedWith('This group already has a Stripe account connected') }) it('rejects connection with invalid existing account ID', async () => { + const testGroup = await factories.group().save() + await adminUser.joinGroup(testGroup, { role: GroupMembership.Role.MODERATOR }) + await expect( - createStripeConnectedAccount({ - groupId: group.id, + createStripeConnectedAccount(adminUser.id, { + groupId: testGroup.id, email: 'group@example.com', businessName: 'Test Group', country: 'US', existingAccountId: 'invalid_account_id' - }, { session: { userId: adminUser.id } }) + }) ).to.be.rejectedWith('Invalid Stripe account ID provided') }) it('allows connection of unverified accounts', async () => { + const testGroup = await factories.group().save() + await adminUser.joinGroup(testGroup, { role: GroupMembership.Role.MODERATOR }) + // This test verifies that we can connect accounts even if they're not fully verified // The verification status will be handled in the UI - const result = await createStripeConnectedAccount({ - groupId: group.id, + const result = await createStripeConnectedAccount(adminUser.id, { + groupId: testGroup.id, email: 'group@example.com', businessName: 'Test Group', country: 'US', existingAccountId: 'acct_unverified_123' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.accountId).to.equal('acct_unverified_123') @@ -237,47 +250,47 @@ describe('Stripe Mutations', () => { describe('createStripeAccountLink', () => { it('creates an account link for group admins', async () => { - const result = await createStripeAccountLink({ + const result = await createStripeAccountLink(adminUser.id, { groupId: group.id, accountId: 'acct_test_123', returnUrl: 'https://example.com/return', refreshUrl: 'https://example.com/refresh' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true - expect(result.url).to.equal('https://connect.stripe.com/setup/test') + expect(result.url).to.equal('https://connect.stripe.com/setup/c/acct_test_123') expect(result.expiresAt).to.be.a('number') }) it('rejects creation for non-authenticated users', async () => { await expect( - createStripeAccountLink({ + createStripeAccountLink(null, { groupId: group.id, accountId: 'acct_test_123', returnUrl: 'https://example.com/return', refreshUrl: 'https://example.com/refresh' - }, { session: null }) + }) ).to.be.rejectedWith('You must be logged in to create an account link') }) it('rejects creation for non-admin users', async () => { await expect( - createStripeAccountLink({ + createStripeAccountLink(user.id, { groupId: group.id, accountId: 'acct_test_123', returnUrl: 'https://example.com/return', refreshUrl: 'https://example.com/refresh' - }, { session: { userId: user.id } }) + }) ).to.be.rejectedWith('You must be a group administrator to manage payments') }) }) describe('stripeAccountStatus', () => { it('returns account status for group members', async () => { - const result = await stripeAccountStatus({ + const result = await stripeAccountStatus(user.id, { groupId: group.id, accountId: 'acct_test_123' - }, { session: { userId: user.id } }) + }) expect(result.accountId).to.equal('acct_test_123') expect(result.chargesEnabled).to.be.true @@ -288,10 +301,10 @@ describe('Stripe Mutations', () => { it('rejects status check for non-authenticated users', async () => { await expect( - stripeAccountStatus({ + stripeAccountStatus(null, { groupId: group.id, accountId: 'acct_test_123' - }, { session: null }) + }) ).to.be.rejectedWith('You must be logged in to view account status') }) @@ -299,15 +312,24 @@ describe('Stripe Mutations', () => { const nonMember = await factories.user().save() await expect( - stripeAccountStatus({ + stripeAccountStatus(nonMember.id, { groupId: group.id, accountId: 'acct_test_123' - }, { session: { userId: nonMember.id } }) + }) ).to.be.rejectedWith('You must be a member of this group to view payment status') }) }) describe('createStripeProduct', () => { + before(async () => { + // Create a stripe_account for these tests + const testStripeAccount = await StripeAccount.forge({ + stripe_account_external_id: 'acct_test_123' + }).save() + + await group.save({ stripe_account_id: testStripeAccount.id }) + }) + it('creates a product for group admins', async () => { const contentAccess = { [group.id]: { @@ -316,7 +338,7 @@ describe('Stripe Mutations', () => { } } - const result = await createStripeProduct({ + const result = await createStripeProduct(adminUser.id, { groupId: group.id, accountId: 'acct_test_123', name: 'Premium Membership', @@ -325,9 +347,9 @@ describe('Stripe Mutations', () => { currency: 'usd', contentAccess, renewalPolicy: 'manual', - duration: 365, + duration: 'lifetime', publishStatus: 'published' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.productId).to.equal('prod_test_123') @@ -343,18 +365,18 @@ describe('Stripe Mutations', () => { expect(savedProduct.get('price_in_cents')).to.equal(2000) expect(savedProduct.get('content_access')).to.deep.equal(contentAccess) expect(savedProduct.get('renewal_policy')).to.equal('manual') - expect(savedProduct.get('duration')).to.equal(365) + expect(savedProduct.get('duration')).to.equal('lifetime') expect(savedProduct.get('publish_status')).to.equal('published') }) it('creates a product with minimal required fields', async () => { - const result = await createStripeProduct({ + const result = await createStripeProduct(adminUser.id, { groupId: group.id, accountId: 'acct_test_123', name: 'Basic Product', description: 'A basic product', priceInCents: 1000 - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.name).to.equal('Basic Product') @@ -368,23 +390,23 @@ describe('Stripe Mutations', () => { it('rejects creation for non-authenticated users', async () => { await expect( - createStripeProduct({ + createStripeProduct(null, { groupId: group.id, accountId: 'acct_test_123', name: 'Test Product', priceInCents: 1000 - }, { session: null }) + }) ).to.be.rejectedWith('You must be logged in to create a product') }) it('rejects creation for non-admin users', async () => { await expect( - createStripeProduct({ + createStripeProduct(user.id, { groupId: group.id, accountId: 'acct_test_123', name: 'Test Product', priceInCents: 1000 - }, { session: { userId: user.id } }) + }) ).to.be.rejectedWith('You must be a group administrator to create products') }) }) @@ -393,8 +415,15 @@ describe('Stripe Mutations', () => { let testProduct before(async () => { + // Create a stripe_account for these tests + const testStripeAccount = await StripeAccount.forge({ + stripe_account_external_id: 'acct_test_123' + }).save() + + await group.save({ stripe_account_id: testStripeAccount.id }) + // Create a test product - const result = await createStripeProduct({ + const result = await createStripeProduct(adminUser.id, { groupId: group.id, accountId: 'acct_test_123', name: 'Original Product', @@ -405,16 +434,16 @@ describe('Stripe Mutations', () => { renewalPolicy: 'manual', duration: 'month', publishStatus: 'unpublished' - }, { session: { userId: adminUser.id } }) + }) testProduct = await StripeProduct.where({ id: result.databaseId }).fetch() }) it('updates product name and description', async () => { - const result = await updateStripeProduct({ + const result = await updateStripeProduct(adminUser.id, { productId: testProduct.id, name: 'Updated Product Name', description: 'Updated description' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.message).to.equal('Product updated successfully') @@ -426,11 +455,11 @@ describe('Stripe Mutations', () => { }) it('updates product price and currency', async () => { - const result = await updateStripeProduct({ + const result = await updateStripeProduct(adminUser.id, { productId: testProduct.id, priceInCents: 2500, currency: 'eur' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true @@ -449,10 +478,10 @@ describe('Stripe Mutations', () => { } } - const result = await updateStripeProduct({ + const result = await updateStripeProduct(adminUser.id, { productId: testProduct.id, contentAccess: newContentAccess - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true @@ -462,11 +491,11 @@ describe('Stripe Mutations', () => { }) it('updates renewal policy and duration', async () => { - const result = await updateStripeProduct({ + const result = await updateStripeProduct(adminUser.id, { productId: testProduct.id, renewalPolicy: 'automatic', duration: 'annual' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true @@ -478,37 +507,37 @@ describe('Stripe Mutations', () => { it('updates publish status to all valid values', async () => { // Test unpublished - let result = await updateStripeProduct({ + let result = await updateStripeProduct(adminUser.id, { productId: testProduct.id, publishStatus: 'unpublished' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true await testProduct.refresh() expect(testProduct.get('publish_status')).to.equal('unpublished') // Test unlisted - result = await updateStripeProduct({ + result = await updateStripeProduct(adminUser.id, { productId: testProduct.id, publishStatus: 'unlisted' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true await testProduct.refresh() expect(testProduct.get('publish_status')).to.equal('unlisted') // Test published - result = await updateStripeProduct({ + result = await updateStripeProduct(adminUser.id, { productId: testProduct.id, publishStatus: 'published' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true await testProduct.refresh() expect(testProduct.get('publish_status')).to.equal('published') // Test archived - result = await updateStripeProduct({ + result = await updateStripeProduct(adminUser.id, { productId: testProduct.id, publishStatus: 'archived' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true await testProduct.refresh() expect(testProduct.get('publish_status')).to.equal('archived') @@ -516,21 +545,21 @@ describe('Stripe Mutations', () => { it('rejects invalid publish status', async () => { await expect( - updateStripeProduct({ + updateStripeProduct(adminUser.id, { productId: testProduct.id, publishStatus: 'invalid_status' - }, { session: { userId: adminUser.id } }) + }) ).to.be.rejectedWith('Invalid publish status. Must be unpublished, unlisted, published, or archived') }) it('updates multiple fields at once', async () => { - const result = await updateStripeProduct({ + const result = await updateStripeProduct(adminUser.id, { productId: testProduct.id, name: 'Multi-Update Product', description: 'Updated with multiple fields', priceInCents: 3000, publishStatus: 'unlisted' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true @@ -539,25 +568,25 @@ describe('Stripe Mutations', () => { expect(testProduct.get('name')).to.equal('Multi-Update Product') expect(testProduct.get('description')).to.equal('Updated with multiple fields') expect(testProduct.get('price_in_cents')).to.equal(3000) - expect(testProduct.get('currency')).to.equal('usd') // From mock + expect(testProduct.get('currency')).to.equal('eur') // Preserved from previous test since not updated expect(testProduct.get('stripe_price_id')).to.equal('price_test_updated') // From mock expect(testProduct.get('publish_status')).to.equal('unlisted') }) it('handles partial updates without affecting other fields', async () => { // First set some values - await updateStripeProduct({ + await updateStripeProduct(adminUser.id, { productId: testProduct.id, name: 'Test Name', description: 'Test Description', priceInCents: 1500 - }, { session: { userId: adminUser.id } }) + }) // Then update only the name - await updateStripeProduct({ + await updateStripeProduct(adminUser.id, { productId: testProduct.id, name: 'Only Name Updated' - }, { session: { userId: adminUser.id } }) + }) // Verify only name changed, other fields remained await testProduct.refresh() @@ -568,28 +597,28 @@ describe('Stripe Mutations', () => { it('rejects update for non-authenticated users', async () => { await expect( - updateStripeProduct({ + updateStripeProduct(null, { productId: testProduct.id, name: 'Unauthorized Update' - }, { session: null }) + }) ).to.be.rejectedWith('You must be logged in to update a product') }) it('rejects update for non-admin users', async () => { await expect( - updateStripeProduct({ + updateStripeProduct(user.id, { productId: testProduct.id, name: 'Unauthorized Update' - }, { session: { userId: user.id } }) + }) ).to.be.rejectedWith('You must be a group administrator to update products') }) it('rejects update for non-existent product', async () => { await expect( - updateStripeProduct({ + updateStripeProduct(adminUser.id, { productId: 99999, name: 'Non-existent Product' - }, { session: { userId: adminUser.id } }) + }) ).to.be.rejectedWith('Product not found') }) @@ -609,19 +638,19 @@ describe('Stripe Mutations', () => { }) await expect( - updateStripeProduct({ + updateStripeProduct(adminUser.id, { productId: productWithoutStripe.id, name: 'Updated Name' - }, { session: { userId: adminUser.id } }) + }) ).to.be.rejectedWith('Group does not have a connected Stripe account') }) it('handles empty update gracefully', async () => { const originalName = testProduct.get('name') - const result = await updateStripeProduct({ + const result = await updateStripeProduct(adminUser.id, { productId: testProduct.id - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true @@ -632,11 +661,20 @@ describe('Stripe Mutations', () => { }) describe('stripeProducts', () => { + before(async () => { + // Create a stripe_account for these tests + const testStripeAccount = await StripeAccount.forge({ + stripe_account_external_id: 'acct_test_123' + }).save() + + await group.save({ stripe_account_id: testStripeAccount.id }) + }) + it('lists products for group admins', async () => { - const result = await stripeProducts({ + const result = await stripeProducts(adminUser.id, { groupId: group.id, accountId: 'acct_test_123' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.products).to.have.length(1) @@ -647,26 +685,35 @@ describe('Stripe Mutations', () => { it('rejects listing for non-authenticated users', async () => { await expect( - stripeProducts({ + stripeProducts(null, { groupId: group.id, accountId: 'acct_test_123' - }, { session: null }) + }) ).to.be.rejectedWith('You must be logged in to view products') }) it('rejects listing for non-admin users', async () => { await expect( - stripeProducts({ + stripeProducts(user.id, { groupId: group.id, accountId: 'acct_test_123' - }, { session: { userId: user.id } }) + }) ).to.be.rejectedWith('You must be a group administrator to view products') }) }) describe('createStripeCheckoutSession', () => { + before(async () => { + // Create a stripe_account for these tests + const testStripeAccount = await StripeAccount.forge({ + stripe_account_external_id: 'acct_test_123' + }).save() + + await group.save({ stripe_account_id: testStripeAccount.id }) + }) + it('creates a checkout session', async () => { - const result = await createStripeCheckoutSession({ + const result = await createStripeCheckoutSession(user.id, { groupId: group.id, accountId: 'acct_test_123', priceId: 'price_test_123', @@ -674,48 +721,48 @@ describe('Stripe Mutations', () => { successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel', metadata: { source: 'web' } - }, { session: { userId: user.id } }) + }) expect(result.success).to.be.true expect(result.sessionId).to.equal('cs_test_123') - expect(result.url).to.equal('https://checkout.stripe.com/test') + expect(result.url).to.equal('https://checkout.stripe.com/pay/cs_test_123') }) it('creates a checkout session with default quantity', async () => { - const result = await createStripeCheckoutSession({ + const result = await createStripeCheckoutSession(user.id, { groupId: group.id, accountId: 'acct_test_123', priceId: 'price_test_123', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' - }, { session: { userId: user.id } }) + }) expect(result.success).to.be.true expect(result.sessionId).to.equal('cs_test_123') }) it('allows unauthenticated checkout sessions', async () => { - const result = await createStripeCheckoutSession({ + const result = await createStripeCheckoutSession(null, { groupId: group.id, accountId: 'acct_test_123', priceId: 'price_test_123', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel' - }, { session: null }) + }) expect(result.success).to.be.true expect(result.sessionId).to.equal('cs_test_123') }) it('includes user ID in metadata when authenticated', async () => { - const result = await createStripeCheckoutSession({ + const result = await createStripeCheckoutSession(user.id, { groupId: group.id, accountId: 'acct_test_123', priceId: 'price_test_123', successUrl: 'https://example.com/success', cancelUrl: 'https://example.com/cancel', metadata: { custom: 'data' } - }, { session: { userId: user.id } }) + }) expect(result.success).to.be.true // Note: In a real test, you'd verify the metadata was passed correctly to StripeService From f925889aae201a77a2a3132bef7e8387e3bebc8d Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 29 Oct 2025 08:33:04 +1100 Subject: [PATCH 09/76] Content access mutation tests passing --- .../api/graphql/mutations/contentAccess.js | 56 +++++---- .../graphql/mutations/contentAccess.test.js | 106 +++++++++++------- apps/backend/api/models/ContentAccess.js | 10 +- .../20251020160838_paid-content-stripe.js | 66 ++++------- apps/backend/migrations/schema.sql | 64 +++-------- 5 files changed, 143 insertions(+), 159 deletions(-) diff --git a/apps/backend/api/graphql/mutations/contentAccess.js b/apps/backend/api/graphql/mutations/contentAccess.js index e11ece7736..0923994b7d 100644 --- a/apps/backend/api/graphql/mutations/contentAccess.js +++ b/apps/backend/api/graphql/mutations/contentAccess.js @@ -9,7 +9,7 @@ import { GraphQLError } from 'graphql' -/* global ContentAccess */ +/* global ContentAccess, GroupMembership, User, Group, Responsibility */ module.exports = { @@ -36,38 +36,38 @@ module.exports = { * } * } */ - grantContentAccess: async (root, { + grantContentAccess: async (sessionUserId, { userId, groupId, productId, trackId, expiresAt, reason - }, { session }) => { + }) => { try { // Check if user is authenticated - if (!session || !session.userId) { + if (!sessionUserId) { throw new GraphQLError('You must be logged in to grant content access') } + // Verify the group exists first + const group = await Group.where({ id: groupId }).fetch() + if (!group) { + throw new GraphQLError('Group not found') + } + // Verify user has admin permission for this group - const membership = await GroupMembership.forPair(session.userId, groupId).fetch() - if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + const hasAdmin = await GroupMembership.hasResponsibility(sessionUserId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { throw new GraphQLError('You must be a group administrator to grant content access') } // Verify the target user exists - const targetUser = await User.find(userId) + const targetUser = await User.where({ id: userId }).fetch() if (!targetUser) { throw new GraphQLError('User not found') } - // Verify the group exists - const group = await Group.find(groupId) - if (!group) { - throw new GraphQLError('Group not found') - } - // Must provide either productId or trackId if (!productId && !trackId) { throw new GraphQLError('Must specify either productId or trackId') @@ -77,7 +77,7 @@ module.exports = { const access = await ContentAccess.grantAccess({ userId, groupId, - grantedById: session.userId, + grantedById: sessionUserId, productId, trackId, expiresAt, @@ -96,6 +96,9 @@ module.exports = { message: 'Access granted successfully' } } catch (error) { + if (error instanceof GraphQLError) { + throw error + } console.error('Error in grantContentAccess:', error) throw new GraphQLError(`Failed to grant access: ${error.message}`) } @@ -118,10 +121,10 @@ module.exports = { * } * } */ - revokeContentAccess: async (root, { accessId, reason }, { session }) => { + revokeContentAccess: async (sessionUserId, { accessId, reason }) => { try { // Check if user is authenticated - if (!session || !session.userId) { + if (!sessionUserId) { throw new GraphQLError('You must be logged in to revoke content access') } @@ -131,19 +134,22 @@ module.exports = { throw new GraphQLError('Access record not found') } - const membership = await GroupMembership.forPair(session.userId, access.get('group_id')).fetch() - if (!membership || !membership.hasResponsibility(GroupMembership.RESP_ADMINISTRATION)) { + const hasAdmin = await GroupMembership.hasResponsibility(sessionUserId, access.get('group_id'), Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { throw new GraphQLError('You must be a group administrator to revoke access') } // Revoke the access using the model method - await ContentAccess.revoke(accessId, session.userId, reason) + await ContentAccess.revoke(accessId, sessionUserId, reason) return { success: true, message: 'Access revoked successfully' } } catch (error) { + if (error instanceof GraphQLError) { + throw error + } console.error('Error in revokeContentAccess:', error) throw new GraphQLError(`Failed to revoke access: ${error.message}`) } @@ -168,7 +174,7 @@ module.exports = { * } * } */ - checkContentAccess: async (root, { userId, groupId, productId, trackId }, { session }) => { + checkContentAccess: async (sessionUserId, { userId, groupId, productId, trackId }) => { try { // Check access using the model method const access = await ContentAccess.checkAccess({ @@ -194,6 +200,9 @@ module.exports = { grantedAt: access.get('created_at') } } catch (error) { + if (error instanceof GraphQLError) { + throw error + } console.error('Error in checkContentAccess:', error) throw new GraphQLError(`Failed to check access: ${error.message}`) } @@ -222,7 +231,7 @@ module.exports = { * } * } */ - recordStripePurchase: async (root, { + recordStripePurchase: async (sessionUserId, { userId, groupId, productId, @@ -234,7 +243,7 @@ module.exports = { currency, expiresAt, metadata - }, { session }) => { + }) => { try { // This mutation should ideally only be called internally from webhook handler // For security, you might want to add special authentication for this @@ -260,6 +269,9 @@ module.exports = { message: 'Purchase recorded successfully' } } catch (error) { + if (error instanceof GraphQLError) { + throw error + } console.error('Error in recordStripePurchase:', error) throw new GraphQLError(`Failed to record purchase: ${error.message}`) } diff --git a/apps/backend/api/graphql/mutations/contentAccess.test.js b/apps/backend/api/graphql/mutations/contentAccess.test.js index 4797c6687f..7def18f5e4 100644 --- a/apps/backend/api/graphql/mutations/contentAccess.test.js +++ b/apps/backend/api/graphql/mutations/contentAccess.test.js @@ -1,5 +1,5 @@ /* eslint-disable no-unused-expressions */ -import '../../../test/setup' +import setup from '../../../test/setup' import factories from '../../../test/setup/factories' import { grantContentAccess, @@ -9,7 +9,7 @@ import { } from './contentAccess' const { expect } = require('chai') -/* global setup */ +/* global ContentAccess, GroupMembership, StripeProduct, Track, GroupRole */ describe('Content Access Mutations', () => { let user, adminUser, group, product, track @@ -19,8 +19,21 @@ describe('Content Access Mutations', () => { user = await factories.user().save() adminUser = await factories.user().save() group = await factories.group().save() - product = await factories.stripeProduct({ group_id: group.id }).save() - track = await factories.track({ group_id: group.id }).save() + product = await StripeProduct.forge({ + group_id: group.id, + stripe_product_id: 'prod_test_123', + stripe_price_id: 'price_test_123', + name: 'Test Product', + description: 'Test Description', + price_in_cents: 1000, + currency: 'usd', + publish_status: 'published' + }).save() + track = await Track.forge({ + name: 'Test Track', + description: 'Test Track Description' + }).save() + await group.tracks().attach(track.id) // Add admin user as group administrator await adminUser.joinGroup(group, { role: GroupMembership.Role.MODERATOR }) @@ -32,12 +45,12 @@ describe('Content Access Mutations', () => { describe('grantContentAccess', () => { it('grants access to a product for a user', async () => { - const result = await grantContentAccess({ + const result = await grantContentAccess(adminUser.id, { userId: user.id, groupId: group.id, productId: product.id, reason: 'Staff member' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.userId).to.equal(user.id) @@ -57,12 +70,12 @@ describe('Content Access Mutations', () => { }) it('grants access to a track for a user', async () => { - const result = await grantContentAccess({ + const result = await grantContentAccess(adminUser.id, { userId: user.id, groupId: group.id, trackId: track.id, reason: 'Promotional access' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.trackId).to.equal(track.id) @@ -75,13 +88,13 @@ describe('Content Access Mutations', () => { it('grants access with expiration date', async () => { const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days from now - const result = await grantContentAccess({ + const result = await grantContentAccess(adminUser.id, { userId: user.id, groupId: group.id, productId: product.id, expiresAt, reason: 'Temporary access' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true @@ -91,55 +104,55 @@ describe('Content Access Mutations', () => { it('rejects access grant for non-authenticated users', async () => { await expect( - grantContentAccess({ + grantContentAccess(null, { userId: user.id, groupId: group.id, productId: product.id, reason: 'Test' - }, { session: null }) + }) ).to.be.rejectedWith('You must be logged in to grant content access') }) it('rejects access grant for non-admin users', async () => { await expect( - grantContentAccess({ + grantContentAccess(user.id, { userId: user.id, groupId: group.id, productId: product.id, reason: 'Test' - }, { session: { userId: user.id } }) + }) ).to.be.rejectedWith('You must be a group administrator to grant content access') }) it('rejects access grant for non-existent user', async () => { await expect( - grantContentAccess({ + grantContentAccess(adminUser.id, { userId: 99999, groupId: group.id, productId: product.id, reason: 'Test' - }, { session: { userId: adminUser.id } }) + }) ).to.be.rejectedWith('User not found') }) it('rejects access grant for non-existent group', async () => { await expect( - grantContentAccess({ + grantContentAccess(adminUser.id, { userId: user.id, groupId: 99999, productId: product.id, reason: 'Test' - }, { session: { userId: adminUser.id } }) + }) ).to.be.rejectedWith('Group not found') }) it('rejects access grant without productId or trackId', async () => { await expect( - grantContentAccess({ + grantContentAccess(adminUser.id, { userId: user.id, groupId: group.id, reason: 'Test' - }, { session: { userId: adminUser.id } }) + }) ).to.be.rejectedWith('Must specify either productId or trackId') }) }) @@ -160,10 +173,10 @@ describe('Content Access Mutations', () => { }) it('revokes access for admin users', async () => { - const result = await revokeContentAccess({ + const result = await revokeContentAccess(adminUser.id, { accessId: accessRecord.id, reason: 'Access no longer needed' - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.message).to.equal('Access revoked successfully') @@ -175,28 +188,28 @@ describe('Content Access Mutations', () => { it('rejects revocation for non-authenticated users', async () => { await expect( - revokeContentAccess({ + revokeContentAccess(null, { accessId: accessRecord.id, reason: 'Test' - }, { session: null }) + }) ).to.be.rejectedWith('You must be logged in to revoke content access') }) it('rejects revocation for non-admin users', async () => { await expect( - revokeContentAccess({ + revokeContentAccess(user.id, { accessId: accessRecord.id, reason: 'Test' - }, { session: { userId: user.id } }) + }) ).to.be.rejectedWith('You must be a group administrator to revoke access') }) it('rejects revocation for non-existent access record', async () => { await expect( - revokeContentAccess({ + revokeContentAccess(adminUser.id, { accessId: 99999, reason: 'Test' - }, { session: { userId: adminUser.id } }) + }) ).to.be.rejectedWith('Access record not found') }) }) @@ -215,11 +228,11 @@ describe('Content Access Mutations', () => { }) it('returns access information for user with access', async () => { - const result = await checkContentAccess({ + const result = await checkContentAccess(user.id, { userId: user.id, groupId: group.id, productId: product.id - }, { session: { userId: user.id } }) + }) expect(result.hasAccess).to.be.true expect(result.accessType).to.equal('admin_grant') @@ -229,11 +242,11 @@ describe('Content Access Mutations', () => { it('returns no access for user without access', async () => { const otherUser = await factories.user().save() - const result = await checkContentAccess({ + const result = await checkContentAccess(otherUser.id, { userId: otherUser.id, groupId: group.id, productId: product.id - }, { session: { userId: otherUser.id } }) + }) expect(result.hasAccess).to.be.false expect(result.accessType).to.be.null @@ -242,11 +255,11 @@ describe('Content Access Mutations', () => { }) it('returns no access for non-existent product', async () => { - const result = await checkContentAccess({ + const result = await checkContentAccess(user.id, { userId: user.id, groupId: group.id, productId: 99999 - }, { session: { userId: user.id } }) + }) expect(result.hasAccess).to.be.false }) @@ -258,7 +271,7 @@ describe('Content Access Mutations', () => { const paymentIntentId = 'pi_test_123' const expiresAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year from now - const result = await recordStripePurchase({ + const result = await recordStripePurchase(adminUser.id, { userId: user.id, groupId: group.id, productId: product.id, @@ -266,7 +279,7 @@ describe('Content Access Mutations', () => { paymentIntentId, expiresAt, metadata: { source: 'webhook' } - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true expect(result.message).to.equal('Purchase recorded successfully') @@ -284,14 +297,14 @@ describe('Content Access Mutations', () => { }) it('records a track-specific purchase', async () => { - const result = await recordStripePurchase({ + const result = await recordStripePurchase(adminUser.id, { userId: user.id, groupId: group.id, trackId: track.id, sessionId: 'cs_test_456', paymentIntentId: 'pi_test_456', metadata: { source: 'webhook' } - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true @@ -301,21 +314,28 @@ describe('Content Access Mutations', () => { }) it('records a role-specific purchase', async () => { - const roleId = 1 + // Create a role for this group first + const role = await GroupRole.forge({ + group_id: group.id, + name: 'Test Role', + emoji: '👤', + color: '#FF0000', + active: true + }).save() - const result = await recordStripePurchase({ + const result = await recordStripePurchase(adminUser.id, { userId: user.id, groupId: group.id, - roleId, + roleId: role.id, sessionId: 'cs_test_789', paymentIntentId: 'pi_test_789', metadata: { source: 'webhook' } - }, { session: { userId: adminUser.id } }) + }) expect(result.success).to.be.true const access = await ContentAccess.where({ id: result.id }).fetch() - expect(access.get('role_id')).to.equal(roleId) + expect(access.get('role_id')).to.equal(role.id) expect(access.get('access_type')).to.equal('stripe_purchase') }) }) diff --git a/apps/backend/api/models/ContentAccess.js b/apps/backend/api/models/ContentAccess.js index ec9e6b75f8..ed5cd8c45e 100644 --- a/apps/backend/api/models/ContentAccess.js +++ b/apps/backend/api/models/ContentAccess.js @@ -107,7 +107,15 @@ module.exports = bookshelf.Model.extend({ metadata: {} } - return this.forge({ ...defaults, ...attrs }).save({}, { transacting }) + // Filter out undefined values to prevent SQL errors + const filteredAttrs = Object.entries(attrs).reduce((acc, [key, value]) => { + if (value !== undefined) { + acc[key] = value + } + return acc + }, {}) + + return this.forge({ ...defaults, ...filteredAttrs }).save({}, { transacting }) }, /** diff --git a/apps/backend/migrations/20251020160838_paid-content-stripe.js b/apps/backend/migrations/20251020160838_paid-content-stripe.js index 46624187e2..87877b6ade 100644 --- a/apps/backend/migrations/20251020160838_paid-content-stripe.js +++ b/apps/backend/migrations/20251020160838_paid-content-stripe.js @@ -23,7 +23,7 @@ exports.up = async function (knex) { table.text('description') table.integer('price_in_cents').notNullable() table.string('currency', 3).notNullable().defaultTo('usd') - table.bigInteger('track_id').unsigned().references('id').inTable('tracks') + table.integer('track_id').unsigned().references('id').inTable('tracks') table.jsonb('content_access').defaultTo('{}').comment('Defines what access this product grants - groups, tracks, roles') table.string('renewal_policy', 20).defaultTo('manual').comment('Renewal policy: automatic or manual') table.string('duration', 20).comment('Duration: month, season, annual, lifetime, or null for no expiration') @@ -41,7 +41,7 @@ exports.up = async function (knex) { table.bigInteger('user_id').unsigned().notNullable().references('id').inTable('users') table.bigInteger('group_id').unsigned().notNullable().references('id').inTable('groups') table.bigInteger('product_id').unsigned().references('id').inTable('stripe_products') - table.bigInteger('track_id').unsigned().references('id').inTable('tracks') + table.integer('track_id').unsigned().references('id').inTable('tracks') table.integer('role_id').unsigned().references('id').inTable('groups_roles') // Access type: 'stripe_purchase', 'admin_grant', 'free' @@ -117,10 +117,8 @@ exports.up = async function (knex) { CREATE OR REPLACE FUNCTION sync_content_access_expires_at() RETURNS TRIGGER AS $$ DECLARE - latest_expires_at TIMESTAMP; + latest_expires_at TIMESTAMP WITH TIME ZONE; BEGIN - -- Update group_memberships if track_id is NULL (includes group-level and role-based purchases) - -- Use the MOST RECENT expires_at from all active content_access records for this user+group IF NEW.track_id IS NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access @@ -128,45 +126,33 @@ exports.up = async function (knex) { AND group_id = NEW.group_id AND track_id IS NULL AND status = 'active'; - UPDATE group_memberships - SET expires_at = latest_expires_at, - updated_at = NOW() - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id; + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND group_id = NEW.group_id; END IF; - - -- If track_id is set, update tracks_users with most recent expires_at for this track IF NEW.track_id IS NOT NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND track_id = NEW.track_id AND status = 'active'; - UPDATE tracks_users - SET expires_at = latest_expires_at, - updated_at = NOW() - WHERE user_id = NEW.user_id - AND track_id = NEW.track_id; + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND track_id = NEW.track_id; END IF; - - -- If role_id is set, update group_memberships_group_roles with most recent expires_at IF NEW.role_id IS NOT NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND group_id = NEW.group_id - AND group_role_id = NEW.role_id + AND role_id = NEW.role_id AND status = 'active'; - UPDATE group_memberships_group_roles - SET expires_at = latest_expires_at + SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.group_id AND group_role_id = NEW.role_id; END IF; - RETURN NEW; END; $$ LANGUAGE plpgsql; @@ -187,10 +173,8 @@ exports.up = async function (knex) { CREATE OR REPLACE FUNCTION clear_content_access_expires_at() RETURNS TRIGGER AS $$ DECLARE - latest_expires_at TIMESTAMP; + latest_expires_at TIMESTAMP WITH TIME ZONE; BEGIN - -- Update group_memberships with most recent expires_at from OTHER active records - -- If no other active records exist, this will set expires_at to NULL IF NEW.track_id IS NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access @@ -198,48 +182,36 @@ exports.up = async function (knex) { AND group_id = NEW.group_id AND track_id IS NULL AND status = 'active' - AND id != NEW.id; -- Exclude the record being revoked - + AND id != NEW.id; UPDATE group_memberships - SET expires_at = latest_expires_at, - updated_at = NOW() - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id; + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND group_id = NEW.group_id; END IF; - - -- If track_id is set, update tracks_users with most recent from other active records IF NEW.track_id IS NOT NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND track_id = NEW.track_id AND status = 'active' - AND id != NEW.id; -- Exclude the record being revoked - + AND id != NEW.id; UPDATE tracks_users - SET expires_at = latest_expires_at, - updated_at = NOW() - WHERE user_id = NEW.user_id - AND track_id = NEW.track_id; + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND track_id = NEW.track_id; END IF; - - -- If role_id is set, update group_memberships_group_roles with most recent IF NEW.role_id IS NOT NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND group_id = NEW.group_id - AND group_role_id = NEW.role_id + AND role_id = NEW.role_id AND status = 'active' - AND id != NEW.id; -- Exclude the record being revoked - + AND id != NEW.id; UPDATE group_memberships_group_roles - SET expires_at = latest_expires_at + SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.group_id AND group_role_id = NEW.role_id; END IF; - RETURN NEW; END; $$ LANGUAGE plpgsql; diff --git a/apps/backend/migrations/schema.sql b/apps/backend/migrations/schema.sql index 905ab99a63..0e8ea32e74 100644 --- a/apps/backend/migrations/schema.sql +++ b/apps/backend/migrations/schema.sql @@ -2921,7 +2921,7 @@ CREATE TABLE public.content_access ( user_id bigint NOT NULL, group_id bigint NOT NULL, product_id bigint, - track_id bigint, + track_id integer, role_id integer, access_type character varying(50) NOT NULL, stripe_session_id character varying(255), @@ -7116,10 +7116,8 @@ ALTER TABLE ONLY public.zapier_triggers CREATE OR REPLACE FUNCTION sync_content_access_expires_at() RETURNS TRIGGER AS $$ DECLARE - latest_expires_at TIMESTAMP; + latest_expires_at TIMESTAMP WITH TIME ZONE; BEGIN - -- Update group_memberships if track_id is NULL (includes group-level and role-based purchases) - -- Use the MOST RECENT expires_at from all active content_access records for this user+group IF NEW.track_id IS NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access @@ -7127,45 +7125,33 @@ BEGIN AND group_id = NEW.group_id AND track_id IS NULL AND status = 'active'; - UPDATE group_memberships - SET expires_at = latest_expires_at, - updated_at = NOW() - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id; + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND group_id = NEW.group_id; END IF; - - -- If track_id is set, update tracks_users with most recent expires_at for this track IF NEW.track_id IS NOT NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND track_id = NEW.track_id AND status = 'active'; - UPDATE tracks_users - SET expires_at = latest_expires_at, - updated_at = NOW() - WHERE user_id = NEW.user_id - AND track_id = NEW.track_id; + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND track_id = NEW.track_id; END IF; - - -- If role_id is set, update group_memberships_group_roles with most recent expires_at IF NEW.role_id IS NOT NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND group_id = NEW.group_id - AND group_role_id = NEW.role_id + AND role_id = NEW.role_id AND status = 'active'; - UPDATE group_memberships_group_roles - SET expires_at = latest_expires_at + SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.group_id AND group_role_id = NEW.role_id; END IF; - RETURN NEW; END; $$ LANGUAGE plpgsql; @@ -7178,10 +7164,8 @@ $$ LANGUAGE plpgsql; CREATE OR REPLACE FUNCTION clear_content_access_expires_at() RETURNS TRIGGER AS $$ DECLARE - latest_expires_at TIMESTAMP; + latest_expires_at TIMESTAMP WITH TIME ZONE; BEGIN - -- Update group_memberships with most recent expires_at from OTHER active records - -- If no other active records exist, this will set expires_at to NULL IF NEW.track_id IS NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access @@ -7189,48 +7173,36 @@ BEGIN AND group_id = NEW.group_id AND track_id IS NULL AND status = 'active' - AND id != NEW.id; -- Exclude the record being revoked - + AND id != NEW.id; UPDATE group_memberships - SET expires_at = latest_expires_at, - updated_at = NOW() - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id; + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND group_id = NEW.group_id; END IF; - - -- If track_id is set, update tracks_users with most recent from other active records IF NEW.track_id IS NOT NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND track_id = NEW.track_id AND status = 'active' - AND id != NEW.id; -- Exclude the record being revoked - + AND id != NEW.id; UPDATE tracks_users - SET expires_at = latest_expires_at, - updated_at = NOW() - WHERE user_id = NEW.user_id - AND track_id = NEW.track_id; + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND track_id = NEW.track_id; END IF; - - -- If role_id is set, update group_memberships_group_roles with most recent IF NEW.role_id IS NOT NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND group_id = NEW.group_id - AND group_role_id = NEW.role_id + AND role_id = NEW.role_id AND status = 'active' - AND id != NEW.id; -- Exclude the record being revoked - + AND id != NEW.id; UPDATE group_memberships_group_roles - SET expires_at = latest_expires_at + SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.group_id AND group_role_id = NEW.role_id; END IF; - RETURN NEW; END; $$ LANGUAGE plpgsql; From da4d0eda1c6d4e53212416c0d4bea431759ebbac Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 29 Oct 2025 16:02:05 +1100 Subject: [PATCH 10/76] Switch connect access to track granting group and permissioned group separately, instead of implicitly assuming they are the same --- apps/backend/api/graphql/makeSchema.js | 12 ++ .../api/graphql/mutations/contentAccess.js | 62 +++++++---- .../graphql/mutations/contentAccess.test.js | 46 ++++---- apps/backend/api/graphql/mutations/index.js | 6 + apps/backend/api/models/ContentAccess.js | 46 +++++--- apps/backend/api/models/StripeProduct.js | 7 +- .../20251020160838_paid-content-stripe.js | 103 +++++++----------- apps/backend/migrations/schema.sql | 96 +++++++++------- docs/CONTENT_ACCESS_SUMMARY.md | 29 +++-- 9 files changed, 229 insertions(+), 178 deletions(-) diff --git a/apps/backend/api/graphql/makeSchema.js b/apps/backend/api/graphql/makeSchema.js index 931e701864..372af19385 100644 --- a/apps/backend/api/graphql/makeSchema.js +++ b/apps/backend/api/graphql/makeSchema.js @@ -25,6 +25,7 @@ import { blockUser, cancelGroupRelationshipInvite, cancelJoinRequest, + checkContentAccess, clearModerationAction, completePost, createAffiliation, @@ -67,6 +68,7 @@ import { findOrCreateThread, flagInappropriateContent, fulfillPost, + grantContentAccess, inviteGroupToGroup, invitePeerRelationship, invitePeopleToEvent, @@ -85,6 +87,7 @@ import { reactOn, reactivateUser, recordClickthrough, + recordStripePurchase, regenerateAccessCode, registerDevice, registerStripeAccount, @@ -106,6 +109,7 @@ import { reorderPostInCollection, resendInvitation, respondToEvent, + revokeContentAccess, savePost, sendEmailVerification, sendPasswordReset, @@ -424,6 +428,14 @@ export function makeMutations ({ fetchOne }) { completePost: (root, { postId, completionResponse }, context) => completePost(context.currentUserId, postId, completionResponse), + checkContentAccess: (root, args, context) => checkContentAccess(context.currentUserId, args), + + grantContentAccess: (root, args, context) => grantContentAccess(context.currentUserId, args), + + revokeContentAccess: (root, args, context) => revokeContentAccess(context.currentUserId, args), + + recordStripePurchase: (root, args, context) => recordStripePurchase(context.currentUserId, args), + createAffiliation: (root, { data }, context) => createAffiliation(context.currentUserId, data), createCollection: (root, { data }, context) => createCollection(context.currentUserId, data), diff --git a/apps/backend/api/graphql/mutations/contentAccess.js b/apps/backend/api/graphql/mutations/contentAccess.js index 0923994b7d..ec3ed28381 100644 --- a/apps/backend/api/graphql/mutations/contentAccess.js +++ b/apps/backend/api/graphql/mutations/contentAccess.js @@ -24,7 +24,8 @@ module.exports = { * mutation { * grantContentAccess( * userId: "456" - * groupId: "123" + * grantedByGroupId: "123" + * groupId: "789" // optional - for access to a group * productId: "789" // optional - for product-based access * trackId: "101" // optional - for track-based access * expiresAt: "2025-12-31T23:59:59Z" // optional @@ -38,9 +39,11 @@ module.exports = { */ grantContentAccess: async (sessionUserId, { userId, + grantedByGroupId, groupId, productId, trackId, + roleId, expiresAt, reason }) => { @@ -50,16 +53,16 @@ module.exports = { throw new GraphQLError('You must be logged in to grant content access') } - // Verify the group exists first - const group = await Group.where({ id: groupId }).fetch() - if (!group) { - throw new GraphQLError('Group not found') + // Verify the granting group exists first + const grantingGroup = await Group.where({ id: grantedByGroupId }).fetch() + if (!grantingGroup) { + throw new GraphQLError('Granting group not found') } - // Verify user has admin permission for this group - const hasAdmin = await GroupMembership.hasResponsibility(sessionUserId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + // Verify user has admin permission for the granting group + const hasAdmin = await GroupMembership.hasResponsibility(sessionUserId, grantedByGroupId, Responsibility.constants.RESP_ADMINISTRATION) if (!hasAdmin) { - throw new GraphQLError('You must be a group administrator to grant content access') + throw new GraphQLError('You must be an administrator of the granting group to grant content access') } // Verify the target user exists @@ -68,18 +71,28 @@ module.exports = { throw new GraphQLError('User not found') } - // Must provide either productId or trackId - if (!productId && !trackId) { - throw new GraphQLError('Must specify either productId or trackId') + // If groupId is provided, verify it exists + if (groupId) { + const targetGroup = await Group.where({ id: groupId }).fetch() + if (!targetGroup) { + throw new GraphQLError('Target group not found') + } + } + + // Must provide either productId, trackId, or roleId + if (!productId && !trackId && !roleId) { + throw new GraphQLError('Must specify either productId, trackId, or roleId') } // Grant access using the ContentAccess model const access = await ContentAccess.grantAccess({ userId, + grantedByGroupId, groupId, grantedById: sessionUserId, productId, trackId, + roleId, expiresAt, reason }) @@ -87,9 +100,11 @@ module.exports = { return { id: access.id, userId, + grantedByGroupId, groupId, productId, trackId, + roleId, accessType: access.get('access_type'), status: access.get('status'), success: true, @@ -134,9 +149,10 @@ module.exports = { throw new GraphQLError('Access record not found') } - const hasAdmin = await GroupMembership.hasResponsibility(sessionUserId, access.get('group_id'), Responsibility.constants.RESP_ADMINISTRATION) + // Check permissions against the granting group + const hasAdmin = await GroupMembership.hasResponsibility(sessionUserId, access.get('granted_by_group_id'), Responsibility.constants.RESP_ADMINISTRATION) if (!hasAdmin) { - throw new GraphQLError('You must be a group administrator to revoke access') + throw new GraphQLError('You must be an administrator of the granting group to revoke access') } // Revoke the access using the model method @@ -165,8 +181,9 @@ module.exports = { * query { * checkContentAccess( * userId: "456" - * groupId: "123" - * productId: "789" // or trackId: "101" + * grantedByGroupId: "123" + * groupId: "789" // optional - for group-specific access + * productId: "789" // optional - or trackId: "101" or roleId: "202" * ) { * hasAccess * accessType @@ -174,14 +191,16 @@ module.exports = { * } * } */ - checkContentAccess: async (sessionUserId, { userId, groupId, productId, trackId }) => { + checkContentAccess: async (sessionUserId, { userId, grantedByGroupId, groupId, productId, trackId, roleId }) => { try { // Check access using the model method const access = await ContentAccess.checkAccess({ userId, + grantedByGroupId, groupId, productId, - trackId + trackId, + roleId }) if (!access) { @@ -219,12 +238,13 @@ module.exports = { * mutation { * recordStripePurchase( * userId: "456" - * groupId: "123" + * grantedByGroupId: "123" + * groupId: "789" // optional * productId: "789" + * trackId: "101" // optional + * roleId: "202" // optional * sessionId: "cs_xxx" * paymentIntentId: "pi_xxx" - * amountPaid: 2000 - * currency: "usd" * ) { * id * success @@ -233,6 +253,7 @@ module.exports = { */ recordStripePurchase: async (sessionUserId, { userId, + grantedByGroupId, groupId, productId, trackId, @@ -251,6 +272,7 @@ module.exports = { // Record the purchase using the model method const access = await ContentAccess.recordPurchase({ userId, + grantedByGroupId, groupId, productId, trackId, diff --git a/apps/backend/api/graphql/mutations/contentAccess.test.js b/apps/backend/api/graphql/mutations/contentAccess.test.js index 7def18f5e4..f4c72a82c5 100644 --- a/apps/backend/api/graphql/mutations/contentAccess.test.js +++ b/apps/backend/api/graphql/mutations/contentAccess.test.js @@ -47,14 +47,14 @@ describe('Content Access Mutations', () => { it('grants access to a product for a user', async () => { const result = await grantContentAccess(adminUser.id, { userId: user.id, - groupId: group.id, + grantedByGroupId: group.id, productId: product.id, reason: 'Staff member' }) expect(result.success).to.be.true expect(result.userId).to.equal(user.id) - expect(result.groupId).to.equal(group.id) + expect(result.grantedByGroupId).to.equal(group.id) expect(result.productId).to.equal(product.id) expect(result.accessType).to.equal('admin_grant') expect(result.status).to.equal('active') @@ -63,7 +63,7 @@ describe('Content Access Mutations', () => { const access = await ContentAccess.where({ id: result.id }).fetch() expect(access).to.exist expect(access.get('user_id')).to.equal(user.id) - expect(access.get('group_id')).to.equal(group.id) + expect(access.get('granted_by_group_id')).to.equal(group.id) expect(access.get('product_id')).to.equal(product.id) expect(access.get('access_type')).to.equal('admin_grant') expect(access.get('status')).to.equal('active') @@ -72,7 +72,7 @@ describe('Content Access Mutations', () => { it('grants access to a track for a user', async () => { const result = await grantContentAccess(adminUser.id, { userId: user.id, - groupId: group.id, + grantedByGroupId: group.id, trackId: track.id, reason: 'Promotional access' }) @@ -90,7 +90,7 @@ describe('Content Access Mutations', () => { const result = await grantContentAccess(adminUser.id, { userId: user.id, - groupId: group.id, + grantedByGroupId: group.id, productId: product.id, expiresAt, reason: 'Temporary access' @@ -106,7 +106,7 @@ describe('Content Access Mutations', () => { await expect( grantContentAccess(null, { userId: user.id, - groupId: group.id, + grantedByGroupId: group.id, productId: product.id, reason: 'Test' }) @@ -117,18 +117,18 @@ describe('Content Access Mutations', () => { await expect( grantContentAccess(user.id, { userId: user.id, - groupId: group.id, + grantedByGroupId: group.id, productId: product.id, reason: 'Test' }) - ).to.be.rejectedWith('You must be a group administrator to grant content access') + ).to.be.rejectedWith('You must be an administrator of the granting group to grant content access') }) it('rejects access grant for non-existent user', async () => { await expect( grantContentAccess(adminUser.id, { userId: 99999, - groupId: group.id, + grantedByGroupId: group.id, productId: product.id, reason: 'Test' }) @@ -139,21 +139,21 @@ describe('Content Access Mutations', () => { await expect( grantContentAccess(adminUser.id, { userId: user.id, - groupId: 99999, + grantedByGroupId: 99999, productId: product.id, reason: 'Test' }) - ).to.be.rejectedWith('Group not found') + ).to.be.rejectedWith('Granting group not found') }) it('rejects access grant without productId or trackId', async () => { await expect( grantContentAccess(adminUser.id, { userId: user.id, - groupId: group.id, + grantedByGroupId: group.id, reason: 'Test' }) - ).to.be.rejectedWith('Must specify either productId or trackId') + ).to.be.rejectedWith('Must specify either productId, trackId, or roleId') }) }) @@ -164,7 +164,7 @@ describe('Content Access Mutations', () => { // Create an access record to revoke accessRecord = await ContentAccess.create({ user_id: user.id, - group_id: group.id, + granted_by_group_id: group.id, product_id: product.id, access_type: 'admin_grant', status: 'active', @@ -201,7 +201,7 @@ describe('Content Access Mutations', () => { accessId: accessRecord.id, reason: 'Test' }) - ).to.be.rejectedWith('You must be a group administrator to revoke access') + ).to.be.rejectedWith('You must be an administrator of the granting group to revoke access') }) it('rejects revocation for non-existent access record', async () => { @@ -219,7 +219,7 @@ describe('Content Access Mutations', () => { // Create an active access record await ContentAccess.create({ user_id: user.id, - group_id: group.id, + granted_by_group_id: group.id, product_id: product.id, access_type: 'admin_grant', status: 'active', @@ -230,7 +230,7 @@ describe('Content Access Mutations', () => { it('returns access information for user with access', async () => { const result = await checkContentAccess(user.id, { userId: user.id, - groupId: group.id, + grantedByGroupId: group.id, productId: product.id }) @@ -244,7 +244,7 @@ describe('Content Access Mutations', () => { const result = await checkContentAccess(otherUser.id, { userId: otherUser.id, - groupId: group.id, + grantedByGroupId: group.id, productId: product.id }) @@ -257,7 +257,7 @@ describe('Content Access Mutations', () => { it('returns no access for non-existent product', async () => { const result = await checkContentAccess(user.id, { userId: user.id, - groupId: group.id, + grantedByGroupId: group.id, productId: 99999 }) @@ -273,7 +273,7 @@ describe('Content Access Mutations', () => { const result = await recordStripePurchase(adminUser.id, { userId: user.id, - groupId: group.id, + grantedByGroupId: group.id, productId: product.id, sessionId, paymentIntentId, @@ -287,7 +287,7 @@ describe('Content Access Mutations', () => { // Verify the access record was created const access = await ContentAccess.where({ id: result.id }).fetch() expect(access.get('user_id')).to.equal(user.id) - expect(access.get('group_id')).to.equal(group.id) + expect(access.get('granted_by_group_id')).to.equal(group.id) expect(access.get('product_id')).to.equal(product.id) expect(access.get('access_type')).to.equal('stripe_purchase') expect(access.get('stripe_session_id')).to.equal(sessionId) @@ -299,7 +299,7 @@ describe('Content Access Mutations', () => { it('records a track-specific purchase', async () => { const result = await recordStripePurchase(adminUser.id, { userId: user.id, - groupId: group.id, + grantedByGroupId: group.id, trackId: track.id, sessionId: 'cs_test_456', paymentIntentId: 'pi_test_456', @@ -325,7 +325,7 @@ describe('Content Access Mutations', () => { const result = await recordStripePurchase(adminUser.id, { userId: user.id, - groupId: group.id, + grantedByGroupId: group.id, roleId: role.id, sessionId: 'cs_test_789', paymentIntentId: 'pi_test_789', diff --git a/apps/backend/api/graphql/mutations/index.js b/apps/backend/api/graphql/mutations/index.js index 9ab150068f..94610320b9 100644 --- a/apps/backend/api/graphql/mutations/index.js +++ b/apps/backend/api/graphql/mutations/index.js @@ -12,6 +12,12 @@ export { reorderPostInCollection, removePostFromCollection } from './collection' +export { + grantContentAccess, + revokeContentAccess, + checkContentAccess, + recordStripePurchase +} from './contentAccess' export { createComment, createMessage, diff --git a/apps/backend/api/models/ContentAccess.js b/apps/backend/api/models/ContentAccess.js index ed5cd8c45e..910e49ce3e 100644 --- a/apps/backend/api/models/ContentAccess.js +++ b/apps/backend/api/models/ContentAccess.js @@ -13,7 +13,16 @@ module.exports = bookshelf.Model.extend({ }, /** - * The group this access grant is for + * The group that grants this access + * Required - this is the group administering the access grant + */ + grantedByGroup: function () { + return this.belongsTo(Group, 'granted_by_group_id') + }, + + /** + * The group this access grant is for (optional) + * Can be null for system-level or cross-group grants */ group: function () { return this.belongsTo(Group, 'group_id') @@ -87,14 +96,15 @@ module.exports = bookshelf.Model.extend({ * * @param {Object} attrs - Access attributes * @param {String|Number} attrs.user_id - User receiving access - * @param {String|Number} attrs.group_id - Group the access is for - * @param {String} attrs.access_type - Type: 'stripe_purchase', 'admin_grant', or 'free' + * @param {String|Number} attrs.granted_by_group_id - Group granting the access (required) + * @param {String|Number} [attrs.group_id] - Group the access is for (optional) + * @param {String} attrs.access_type - Type: 'stripe_purchase' or 'admin_grant' * @param {String} [attrs.product_id] - Optional Stripe product ID * @param {String} [attrs.track_id] - Optional track ID * @param {String} [attrs.role_id] - Optional role ID * @param {Date} [attrs.expires_at] - Optional expiration date * @param {String} [attrs.stripe_session_id] - For Stripe purchases - * @param {Number} [attrs.amount_paid] - Amount paid in cents + * @param {String} [attrs.stripe_payment_intent_id] - Stripe payment intent ID * @param {String} [attrs.granted_by_id] - Admin who granted access * @param {Object} [attrs.metadata] - Additional metadata * @param {Object} options - Options including transacting @@ -122,8 +132,9 @@ module.exports = bookshelf.Model.extend({ * Grant free access to content (admin action) * @param {Object} params * @param {String|Number} params.userId - User to grant access to - * @param {String|Number} params.groupId - Group to grant access for - * @param {String|Number} params.grantedById - Admin granting the access + * @param {String|Number} params.grantedByGroupId - Group granting the access (required) + * @param {String|Number} params.groupId - Group to grant access for (optional) + * @param {String|Number} params.grantedById - Admin granting the access (optional) * @param {String|Number} [params.productId] - Optional product * @param {String|Number} [params.trackId] - Optional track * @param {String|Number} [params.roleId] - Optional role @@ -134,6 +145,7 @@ module.exports = bookshelf.Model.extend({ */ grantAccess: async function ({ userId, + grantedByGroupId, groupId, grantedById, productId, @@ -147,6 +159,7 @@ module.exports = bookshelf.Model.extend({ return this.create({ user_id: userId, + granted_by_group_id: grantedByGroupId, group_id: groupId, product_id: productId, track_id: trackId, @@ -162,7 +175,8 @@ module.exports = bookshelf.Model.extend({ * Record a Stripe purchase (called from webhook handler) * @param {Object} params * @param {String|Number} params.userId - User who made the purchase - * @param {String|Number} params.groupId - Group the purchase is for + * @param {String|Number} params.grantedByGroupId - Group making the purchase available (required) + * @param {String|Number} params.groupId - Group the purchase is for (optional) * @param {String|Number} params.productId - Stripe product ID (from stripe_products table) * @param {String|Number} [params.trackId] - Optional track * @param {String|Number} [params.roleId] - Optional role @@ -175,6 +189,7 @@ module.exports = bookshelf.Model.extend({ */ recordPurchase: async function ({ userId, + grantedByGroupId, groupId, productId, trackId, @@ -186,6 +201,7 @@ module.exports = bookshelf.Model.extend({ }, { transacting } = {}) { return this.create({ user_id: userId, + granted_by_group_id: grantedByGroupId, group_id: groupId, product_id: productId, track_id: trackId, @@ -229,19 +245,21 @@ module.exports = bookshelf.Model.extend({ * Check if a user has active access to content * @param {Object} params * @param {String|Number} params.userId - User to check - * @param {String|Number} params.groupId - Group to check + * @param {String|Number} params.grantedByGroupId - Group granting the access (required) + * @param {String|Number} [params.groupId] - Specific group access is for (optional) * @param {String|Number} [params.productId] - Optional product * @param {String|Number} [params.trackId] - Optional track * @param {String|Number} [params.roleId] - Optional role * @returns {Promise} */ - checkAccess: async function ({ userId, groupId, productId, trackId, roleId }) { + checkAccess: async function ({ userId, grantedByGroupId, groupId, productId, trackId, roleId }) { const query = this.where({ user_id: userId, - group_id: groupId, + granted_by_group_id: grantedByGroupId, status: this.Status.ACTIVE }) + if (groupId) query.where({ group_id: groupId }) if (productId) query.where({ product_id: productId }) if (trackId) query.where({ track_id: trackId }) if (roleId) query.where({ role_id: roleId }) @@ -259,15 +277,15 @@ module.exports = bookshelf.Model.extend({ }, /** - * Get all active access records for a user in a group + * Get all active access records for a user granted by a specific group * @param {String|Number} userId - * @param {String|Number} groupId + * @param {String|Number} grantedByGroupId - Group granting the access * @returns {Promise>} */ - forUser: function (userId, groupId) { + forUser: function (userId, grantedByGroupId) { return this.where({ user_id: userId, - group_id: groupId, + granted_by_group_id: grantedByGroupId, status: this.Status.ACTIVE }).fetchAll() }, diff --git a/apps/backend/api/models/StripeProduct.js b/apps/backend/api/models/StripeProduct.js index 1886cebbb3..7118441850 100644 --- a/apps/backend/api/models/StripeProduct.js +++ b/apps/backend/api/models/StripeProduct.js @@ -115,7 +115,7 @@ module.exports = bookshelf.Model.extend({ metadata = {} }, { transacting } = {}) { const contentAccess = this.get('content_access') || {} - const groupId = this.get('group_id') + const grantedByGroupId = this.get('group_id') // The group that owns/sells this product const productId = this.get('id') const duration = this.get('duration') const accessRecords = [] @@ -127,7 +127,7 @@ module.exports = bookshelf.Model.extend({ if (Object.keys(contentAccess).length === 0) { const record = await ContentAccess.recordPurchase({ userId, - groupId, + grantedByGroupId, productId, sessionId, paymentIntentId, @@ -145,6 +145,7 @@ module.exports = bookshelf.Model.extend({ // Create base group access record const baseRecord = await ContentAccess.recordPurchase({ userId, + grantedByGroupId, groupId: groupIdNum, productId, sessionId, @@ -162,6 +163,7 @@ module.exports = bookshelf.Model.extend({ for (const trackId of groupAccess.trackIds) { const trackRecord = await ContentAccess.recordPurchase({ userId, + grantedByGroupId, groupId: groupIdNum, productId, trackId, @@ -182,6 +184,7 @@ module.exports = bookshelf.Model.extend({ for (const roleId of groupAccess.roleIds) { const roleRecord = await ContentAccess.recordPurchase({ userId, + grantedByGroupId, groupId: groupIdNum, productId, roleId, diff --git a/apps/backend/migrations/20251020160838_paid-content-stripe.js b/apps/backend/migrations/20251020160838_paid-content-stripe.js index 87877b6ade..602e75c8c2 100644 --- a/apps/backend/migrations/20251020160838_paid-content-stripe.js +++ b/apps/backend/migrations/20251020160838_paid-content-stripe.js @@ -39,7 +39,8 @@ exports.up = async function (knex) { await knex.schema.createTable('content_access', function (table) { table.bigIncrements('id').primary() table.bigInteger('user_id').unsigned().notNullable().references('id').inTable('users') - table.bigInteger('group_id').unsigned().notNullable().references('id').inTable('groups') + table.bigInteger('granted_by_group_id').unsigned().notNullable().references('id').inTable('groups') + table.bigInteger('group_id').unsigned().references('id').inTable('groups') table.bigInteger('product_id').unsigned().references('id').inTable('stripe_products') table.integer('track_id').unsigned().references('id').inTable('tracks') table.integer('role_id').unsigned().references('id').inTable('groups_roles') @@ -66,6 +67,7 @@ exports.up = async function (knex) { table.timestamps(true, true) table.index(['user_id', 'status']) + table.index(['granted_by_group_id']) table.index(['group_id']) table.index(['product_id']) table.index(['track_id']) @@ -81,7 +83,7 @@ exports.up = async function (knex) { }) await knex.schema.table('tracks_users', function (table) { - table.timestamp('expires_at').comment('Mirrored from content_access table via trigger') + table.boolean('access_granted').comment('Whether user has access to track (set via trigger)') }) await knex.schema.table('group_memberships_group_roles', function (table) { @@ -102,57 +104,43 @@ exports.up = async function (knex) { table.boolean('paywall').defaultTo(false).comment('Whether this group requires purchased membership') }) - // Create trigger function to sync expires_at to related tables + // Create trigger function to sync content_access expiration to related tables // This function automatically updates the related tables whenever content_access is modified // + // IMPORTANT: track_id, role_id, and granted_by_group_id are mutually exclusive + // Only ONE of these will be set for any given content_access record + // // IMPORTANT: Multiple content_access records can reference the same stripe product // (e.g., a bundle purchase, recurring subscriptions, or multiple purchases over time) // // IMPORTANT: Uses the MOST RECENT expires_at from ALL active content_access records // This prevents old/expired records from overwriting newer access grants - // - // IMPORTANT: Only update group_memberships.expires_at when track_id is NULL - // This prevents track purchases from overwriting the group membership expiration await knex.raw(` CREATE OR REPLACE FUNCTION sync_content_access_expires_at() RETURNS TRIGGER AS $$ DECLARE latest_expires_at TIMESTAMP WITH TIME ZONE; BEGIN - IF NEW.track_id IS NULL THEN - SELECT MAX(expires_at) INTO latest_expires_at - FROM content_access - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id - AND track_id IS NULL - AND status = 'active'; - UPDATE group_memberships - SET expires_at = latest_expires_at, updated_at = NOW() - WHERE user_id = NEW.user_id AND group_id = NEW.group_id; - END IF; + -- Track-level access: sync to tracks_users IF NEW.track_id IS NOT NULL THEN - SELECT MAX(expires_at) INTO latest_expires_at - FROM content_access - WHERE user_id = NEW.user_id - AND track_id = NEW.track_id - AND status = 'active'; UPDATE tracks_users - SET expires_at = latest_expires_at, updated_at = NOW() + SET access_granted = true, updated_at = NOW() WHERE user_id = NEW.user_id AND track_id = NEW.track_id; END IF; + + -- Role-level access: sync to group_memberships_group_roles and group_memberships IF NEW.role_id IS NOT NULL THEN - SELECT MAX(expires_at) INTO latest_expires_at - FROM content_access - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id - AND role_id = NEW.role_id - AND status = 'active'; - UPDATE group_memberships_group_roles - SET expires_at = latest_expires_at, updated_at = NOW() - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id - AND group_role_id = NEW.role_id; + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND role_id = NEW.role_id AND granted_by_group_id = NEW.granted_by_group_id AND status = 'active'; + UPDATE group_memberships_group_roles SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id AND group_role_id = NEW.role_id; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + + -- Group-level access: sync to group_memberships + IF NEW.track_id IS NULL AND NEW.role_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND granted_by_group_id = NEW.granted_by_group_id AND track_id IS NULL AND role_id IS NULL AND status = 'active'; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; END IF; + RETURN NEW; END; $$ LANGUAGE plpgsql; @@ -175,43 +163,26 @@ exports.up = async function (knex) { DECLARE latest_expires_at TIMESTAMP WITH TIME ZONE; BEGIN - IF NEW.track_id IS NULL THEN - SELECT MAX(expires_at) INTO latest_expires_at - FROM content_access - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id - AND track_id IS NULL - AND status = 'active' - AND id != NEW.id; - UPDATE group_memberships - SET expires_at = latest_expires_at, updated_at = NOW() - WHERE user_id = NEW.user_id AND group_id = NEW.group_id; - END IF; + -- Track-level access: clear from tracks_users IF NEW.track_id IS NOT NULL THEN - SELECT MAX(expires_at) INTO latest_expires_at - FROM content_access - WHERE user_id = NEW.user_id - AND track_id = NEW.track_id - AND status = 'active' - AND id != NEW.id; UPDATE tracks_users - SET expires_at = latest_expires_at, updated_at = NOW() + SET access_granted = false, updated_at = NOW() WHERE user_id = NEW.user_id AND track_id = NEW.track_id; END IF; + + -- Role-level access: clear from group_memberships_group_roles and group_memberships IF NEW.role_id IS NOT NULL THEN - SELECT MAX(expires_at) INTO latest_expires_at - FROM content_access - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id - AND role_id = NEW.role_id - AND status = 'active' - AND id != NEW.id; - UPDATE group_memberships_group_roles - SET expires_at = latest_expires_at, updated_at = NOW() - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id - AND group_role_id = NEW.role_id; + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND role_id = NEW.role_id AND granted_by_group_id = NEW.granted_by_group_id AND status = 'active' AND id != NEW.id; + UPDATE group_memberships_group_roles SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id AND group_role_id = NEW.role_id; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; END IF; + + -- Group-level access: clear from group_memberships + IF NEW.track_id IS NULL AND NEW.role_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND granted_by_group_id = NEW.granted_by_group_id AND track_id IS NULL AND role_id IS NULL AND status = 'active' AND id != NEW.id; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + RETURN NEW; END; $$ LANGUAGE plpgsql; @@ -240,7 +211,7 @@ exports.down = async function (knex) { }) await knex.schema.table('tracks_users', function (table) { - table.dropColumn('expires_at') + table.dropColumn('access_granted') }) await knex.schema.table('group_memberships_group_roles', function (table) { diff --git a/apps/backend/migrations/schema.sql b/apps/backend/migrations/schema.sql index 0e8ea32e74..05e60b7a5e 100644 --- a/apps/backend/migrations/schema.sql +++ b/apps/backend/migrations/schema.sql @@ -2919,7 +2919,8 @@ CREATE SEQUENCE public.content_access_id_seq CREATE TABLE public.content_access ( id bigint DEFAULT nextval('public.content_access_id_seq'::regclass) NOT NULL, user_id bigint NOT NULL, - group_id bigint NOT NULL, + granted_by_group_id bigint NOT NULL, + group_id bigint, product_id bigint, track_id integer, role_id integer, @@ -3104,7 +3105,7 @@ CREATE TABLE public.tracks_users ( completed_at timestamp with time zone, created_at timestamp with time zone, updated_at timestamp with time zone, - expires_at timestamp with time zone + access_granted boolean ); @@ -6533,6 +6534,14 @@ ALTER TABLE ONLY public.content_access ADD CONSTRAINT content_access_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: content_access content_access_granted_by_group_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_granted_by_group_id_foreign FOREIGN KEY (granted_by_group_id) REFERENCES public.groups(id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: content_access content_access_group_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -7118,40 +7127,42 @@ RETURNS TRIGGER AS $$ DECLARE latest_expires_at TIMESTAMP WITH TIME ZONE; BEGIN - IF NEW.track_id IS NULL THEN - SELECT MAX(expires_at) INTO latest_expires_at - FROM content_access - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id - AND track_id IS NULL - AND status = 'active'; - UPDATE group_memberships - SET expires_at = latest_expires_at, updated_at = NOW() - WHERE user_id = NEW.user_id AND group_id = NEW.group_id; - END IF; IF NEW.track_id IS NOT NULL THEN - SELECT MAX(expires_at) INTO latest_expires_at - FROM content_access - WHERE user_id = NEW.user_id - AND track_id = NEW.track_id - AND status = 'active'; UPDATE tracks_users - SET expires_at = latest_expires_at, updated_at = NOW() + SET access_granted = true, updated_at = NOW() WHERE user_id = NEW.user_id AND track_id = NEW.track_id; END IF; + IF NEW.role_id IS NOT NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id - AND group_id = NEW.group_id AND role_id = NEW.role_id + AND granted_by_group_id = NEW.granted_by_group_id AND status = 'active'; UPDATE group_memberships_group_roles SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id - AND group_id = NEW.group_id + AND group_id = NEW.granted_by_group_id AND group_role_id = NEW.role_id; + UPDATE group_memberships + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + + IF NEW.track_id IS NULL AND NEW.role_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND granted_by_group_id = NEW.granted_by_group_id + AND track_id IS NULL + AND role_id IS NULL + AND status = 'active'; + UPDATE group_memberships + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; END IF; + RETURN NEW; END; $$ LANGUAGE plpgsql; @@ -7166,43 +7177,44 @@ RETURNS TRIGGER AS $$ DECLARE latest_expires_at TIMESTAMP WITH TIME ZONE; BEGIN - IF NEW.track_id IS NULL THEN - SELECT MAX(expires_at) INTO latest_expires_at - FROM content_access - WHERE user_id = NEW.user_id - AND group_id = NEW.group_id - AND track_id IS NULL - AND status = 'active' - AND id != NEW.id; - UPDATE group_memberships - SET expires_at = latest_expires_at, updated_at = NOW() - WHERE user_id = NEW.user_id AND group_id = NEW.group_id; - END IF; IF NEW.track_id IS NOT NULL THEN - SELECT MAX(expires_at) INTO latest_expires_at - FROM content_access - WHERE user_id = NEW.user_id - AND track_id = NEW.track_id - AND status = 'active' - AND id != NEW.id; UPDATE tracks_users - SET expires_at = latest_expires_at, updated_at = NOW() + SET access_granted = false, updated_at = NOW() WHERE user_id = NEW.user_id AND track_id = NEW.track_id; END IF; + IF NEW.role_id IS NOT NULL THEN SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id - AND group_id = NEW.group_id AND role_id = NEW.role_id + AND granted_by_group_id = NEW.granted_by_group_id AND status = 'active' AND id != NEW.id; UPDATE group_memberships_group_roles SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id - AND group_id = NEW.group_id + AND group_id = NEW.granted_by_group_id AND group_role_id = NEW.role_id; + UPDATE group_memberships + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + + IF NEW.track_id IS NULL AND NEW.role_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND granted_by_group_id = NEW.granted_by_group_id + AND track_id IS NULL + AND role_id IS NULL + AND status = 'active' + AND id != NEW.id; + UPDATE group_memberships + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; END IF; + RETURN NEW; END; $$ LANGUAGE plpgsql; diff --git a/docs/CONTENT_ACCESS_SUMMARY.md b/docs/CONTENT_ACCESS_SUMMARY.md index bdb1855f65..5e94f9688d 100644 --- a/docs/CONTENT_ACCESS_SUMMARY.md +++ b/docs/CONTENT_ACCESS_SUMMARY.md @@ -31,7 +31,8 @@ Tracks products/offerings created by groups: **Key Columns:** - `user_id` - Who has access -- `group_id` - Which group +- `granted_by_group_id` - Which group is granting the access (required, not null) +- `group_id` - Which group access is FOR (optional, nullable) - can differ from granting group - `product_id` - Product id denotes the entity in Stripe that tracks an offering/product - `track_id` - Optional: Grants access to a specific track within a group - `role_id` - Optional: References `groups_roles` table, represents role-based access grants by admins @@ -40,22 +41,28 @@ Tracks products/offerings created by groups: - `amount_paid` - Amount paid (0 for free grants) - `status` - active/expired/revoked - `granted_by_id` - Admin who granted access (for admin grants) -- `expires_at` - Optional expiration date +- `expires_at` - Optional expiration date (for group and role access, NOT for tracks) - `metadata` - Flexible JSONB for additional info **Access Granularity:** Access can be granted at multiple levels: -- **Group-level**: Just `group_id` set - access to entire group content -- **Track-level**: `track_id` set - access to specific track content +- **Group-level**: `group_id` set - access to group content +- **Track-level**: `track_id` set - access to specific track content (tracks are one-time, no expiration tracking) - **Role-level**: `role_id` set - access tied to a specific group role +**Important: track_id, role_id, and group_id are MUTUALLY EXCLUSIVE in terms of their triggers:** +Only ONE of these combinations will be active for a content_access record: +- `track_id` set (implies track-level access) +- `role_id` set (implies role-level access) +- `group_id` set (implies group-level access) + **Automatic Expiration Mirroring:** The `expires_at` value from `content_access` is automatically mirrored to related tables using PostgreSQL triggers: - **When track_id is NULL** (group-level or role-based): `group_memberships.expires_at` (based on `user_id` + `group_id`) -- **When track_id is set**: `tracks_users.expires_at` (based on `user_id` + `track_id`) -- **When role_id is set**: `group_memberships_group_roles.expires_at` (based on `user_id` + `group_id` + `group_role_id`) +- **When track_id is set**: `tracks_users.access_granted` (boolean, set to true/false since tracks don't expire) +- **When role_id is set**: `group_memberships_group_roles.expires_at` (based on `user_id` + `group_id` + `group_role_id`) AND `group_memberships.expires_at` -**Important:** Track purchases do NOT update `group_memberships.expires_at`. This prevents a one-off track purchase from overwriting a long-term group membership expiration. However, role bumps DO update group membership (having a role implies group access). +**Important:** Track purchases (track_id set) do NOT update any expiration dates. Instead, the `access_granted` boolean in `tracks_users` is set to true when access is granted and false when revoked. This is because track access is one-time and doesn't expire. This avoids the need for JOINs when checking expiration - you can query the respective tables directly. @@ -69,10 +76,10 @@ To avoid constantly joining `content_access` with membership tables, PostgreSQL **When content access is granted or updated:** 1. User inserts/updates a record in `content_access` with `expires_at` set 2. Trigger `content_access_expires_at_sync` fires automatically -3. Function `sync_content_access_expires_at()` executes: - - **If track_id is NULL**: Updates `group_memberships.expires_at` (group-level or role-based purchase) - - **If track_id is set**: Updates `tracks_users.expires_at` (track-specific purchase, does NOT update group_memberships) - - **If role_id is set**: Updates `group_memberships_group_roles.expires_at` (role-specific purchase, also updates group_memberships if no track_id) +3. Function `sync_content_access_expires_at()` executes (THREE MUTUALLY EXCLUSIVE CONDITIONALS): + - **If track_id is NOT NULL**: Sets `tracks_users.access_granted = true` (one-time access, no expiration) + - **If role_id is NOT NULL**: Updates `group_memberships_group_roles.expires_at` AND `group_memberships.expires_at` based on `granted_by_group_id` + - **If BOTH are NULL** (group-level access): Updates `group_memberships.expires_at` based on `granted_by_group_id` **When access is revoked or expires:** 1. Status changes to 'revoked' or 'expired' in `content_access` From de7a3353c944e2b1ef3676e158d2d0d50caf0dde Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 30 Oct 2025 10:03:03 +1100 Subject: [PATCH 11/76] Sync schema with queries --- apps/backend/api/graphql/makeSchema.js | 3 +- apps/backend/api/graphql/schema.graphql | 108 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/apps/backend/api/graphql/makeSchema.js b/apps/backend/api/graphql/makeSchema.js index 372af19385..9c4b4b519b 100644 --- a/apps/backend/api/graphql/makeSchema.js +++ b/apps/backend/api/graphql/makeSchema.js @@ -306,6 +306,7 @@ export function makePublicQueries ({ fetchOne, fetchMany }) { export function makeAuthenticatedQueries ({ fetchOne, fetchMany }) { return { activity: (root, { id }) => fetchOne('Activity', id), + checkContentAccess: (root, args, context) => checkContentAccess(context.currentUserId, args), checkInvitation: (root, { invitationToken, accessCode }) => InvitationService.check(invitationToken, accessCode), collection: (root, { id }) => fetchOne('Collection', id), @@ -428,8 +429,6 @@ export function makeMutations ({ fetchOne }) { completePost: (root, { postId, completionResponse }, context) => completePost(context.currentUserId, postId, completionResponse), - checkContentAccess: (root, args, context) => checkContentAccess(context.currentUserId, args), - grantContentAccess: (root, args, context) => grantContentAccess(context.currentUserId, args), revokeContentAccess: (root, args, context) => revokeContentAccess(context.currentUserId, args), diff --git a/apps/backend/api/graphql/schema.graphql b/apps/backend/api/graphql/schema.graphql index 31042a5873..d5ba05c951 100644 --- a/apps/backend/api/graphql/schema.graphql +++ b/apps/backend/api/graphql/schema.graphql @@ -18,6 +18,16 @@ type Query { # Find an Activity by ID activity(id: ID): Activity + # Check if a user has access to content + checkContentAccess( + userId: ID!, + grantedByGroupId: ID!, + groupId: ID, + productId: ID, + trackId: ID, + roleId: ID + ): ContentAccessCheckResult + # Check if a group invitation is still valid checkInvitation(invitationToken: String, accessCode: String): CheckInvitationResult @@ -2584,6 +2594,7 @@ type StripeProduct { updatedAt: Date # The group this product belongs to group: Group + groupId: ID # Stripe product ID from Stripe API stripeProductId: String # Stripe price ID from Stripe API @@ -2598,6 +2609,7 @@ type StripeProduct { currency: String # Optional track this product grants access to (legacy field) track: Track + trackId: ID # JSONB object defining what access this product grants contentAccess: JSON # Renewal policy: automatic or manual @@ -2608,6 +2620,65 @@ type StripeProduct { publishStatus: PublishStatus } +type ContentAccess { + id: ID + createdAt: Date + updatedAt: Date + # User who has access + user: Person + userId: ID + # Group that granted the access + grantedByGroup: Group + grantedByGroupId: ID + # Group access is for (optional) + group: Group + groupId: ID + # Stripe product (if purchased) + product: StripeProduct + productId: ID + # Track access is for (optional) + track: Track + trackId: ID + # Role access is for (optional) + role: GroupRole + roleId: ID + # How access was granted: stripe_purchase or admin_grant + accessType: String + # Stripe session ID (for purchases) + stripeSessionId: String + # Stripe payment intent ID (for purchases) + stripePaymentIntentId: String + # Current status: active, expired, revoked + status: String + # User who granted access (for admin grants) + grantedBy: Person + grantedById: ID + # When access expires (optional) + expiresAt: Date + # Additional metadata + metadata: JSON +} + +type ContentAccessResult { + id: ID + userId: ID + grantedByGroupId: ID + groupId: ID + productId: ID + trackId: ID + roleId: ID + accessType: String + status: String + success: Boolean + message: String +} + +type ContentAccessCheckResult { + hasAccess: Boolean + accessType: String + expiresAt: Date +} + # Current user's personal settings type UserSettings { # Has this person seen the tour that displays the first time someone logs in @@ -2742,6 +2813,37 @@ type Mutation { cancelJoinRequest(joinRequestId: ID): GenericResult # Clear a moderation action; only a 'content manager' or initial reporter can do this. clearModerationAction(postId: ID, groupId: ID, moderationActionId: ID): GenericResult + # Grant content access to a user (admin only) + grantContentAccess( + userId: ID!, + grantedByGroupId: ID!, + groupId: ID, + productId: ID, + trackId: ID, + roleId: ID, + expiresAt: Date, + reason: String + ): ContentAccessResult + # Revoke content access (admin only) + revokeContentAccess( + accessId: ID!, + reason: String + ): GenericResult + # Record a Stripe purchase (internal use) + recordStripePurchase( + userId: ID!, + grantedByGroupId: ID!, + groupId: ID, + productId: ID, + trackId: ID, + roleId: ID, + sessionId: String!, + paymentIntentId: String, + amountPaid: Int, + currency: String, + expiresAt: Date, + metadata: JSON + ): ContentAccessResult # Mark a post as completed completePost(postId: ID, completionResponse: JSON): Post # For logged in user to add an Affiliation to their profile @@ -3194,6 +3296,12 @@ type StripeProductsResult { success: Boolean } +type StripeProductUpdateResult { + product: StripeProduct + success: Boolean + message: String +} + type StripeCheckoutSessionResult { sessionId: String url: String From 16a7e15e8004eb859c44790b19ddea86c4238b8c Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 30 Oct 2025 16:37:46 +1100 Subject: [PATCH 12/76] Allow group admins to create stripe account; add check for stripe status; add link to stripe dashboard for connected accounts --- apps/backend/api/graphql/makeModels.js | 4 + apps/backend/api/graphql/makeSchema.js | 22 + apps/backend/api/graphql/mutations/index.js | 4 +- apps/backend/api/graphql/mutations/project.js | 9 +- apps/backend/api/graphql/mutations/stripe.js | 124 +++- apps/backend/api/graphql/schema.graphql | 43 +- apps/backend/api/models/Group.js | 19 +- apps/web/public/locales/en.json | 1 + apps/web/public/locales/es.json | 1 + .../components/ContextMenu/ContextMenu.jsx | 1 + .../src/routes/GroupSettings/GroupSettings.js | 8 + .../GroupSettings/GroupSettings.store.js | 11 + .../PaidContentTab/PaidContentTab.js | 660 +++++++++++++----- .../PaidContentTab/PaidContentTab.store.js | 98 ++- apps/web/src/store/models/Group.js | 8 +- apps/web/src/util/contextWidgets.js | 2 +- 16 files changed, 792 insertions(+), 223 deletions(-) diff --git a/apps/backend/api/graphql/makeModels.js b/apps/backend/api/graphql/makeModels.js index 93e56f569e..764aa446d6 100644 --- a/apps/backend/api/graphql/makeModels.js +++ b/apps/backend/api/graphql/makeModels.js @@ -608,6 +608,9 @@ export default function makeModels (userId, isAdmin, apiClient) { 'purpose', 'slug', 'stripe_account_id', + 'stripe_charges_enabled', + 'stripe_payouts_enabled', + 'stripe_details_submitted', 'type', 'visibility', 'website_url', @@ -786,6 +789,7 @@ export default function makeModels (userId, isAdmin, apiClient) { getters: { // commonRoles: async g => g.commonRoles(), homeWidget: g => g.homeWidget(), + stripeDashboardUrl: g => g.stripeDashboardUrl(), invitePath: g => userId && GroupMembership.hasResponsibility(userId, g, Responsibility.constants.RESP_ADD_MEMBERS) .then(canInvite => canInvite ? Frontend.Route.invitePath(g) : null), diff --git a/apps/backend/api/graphql/makeSchema.js b/apps/backend/api/graphql/makeSchema.js index 9c4b4b519b..2479306940 100644 --- a/apps/backend/api/graphql/makeSchema.js +++ b/apps/backend/api/graphql/makeSchema.js @@ -141,6 +141,14 @@ import { updateStripeAccount, updateWidget, useInvitation, + createStripeConnectedAccount, + createStripeAccountLink, + stripeAccountStatus, + createStripeProduct, + updateStripeProduct, + stripeProducts, + createStripeCheckoutSession, + checkStripeStatus, verifyEmail } from './mutations' import peopleTyping from './mutations/peopleTyping' @@ -366,6 +374,8 @@ export function makeAuthenticatedQueries ({ fetchOne, fetchMany }) { }) }, skills: (root, args) => fetchMany('Skill', args), + stripeAccountStatus: (root, { groupId, accountId }, context) => stripeAccountStatus(context.currentUserId, { groupId, accountId }), + stripeProducts: (root, { groupId, accountId }, context) => stripeProducts(context.currentUserId, { groupId, accountId }), // you can specify id or name, but not both topic: (root, { id, name }) => fetchOne('Topic', name || id, name ? 'name' : 'id'), topicFollow: (root, { groupId, topicName }, context) => TagFollow.findOrCreate({ groupId, topicName, userId: context.currentUserId }), @@ -556,6 +566,18 @@ export function makeMutations ({ fetchOne }) { registerStripeAccount: (root, { authorizationCode }, context) => registerStripeAccount(context.currentUserId, authorizationCode), + createStripeConnectedAccount: (root, { groupId, email, businessName, country, existingAccountId }, context) => createStripeConnectedAccount(context.currentUserId, { groupId, email, businessName, country, existingAccountId }), + + createStripeAccountLink: (root, { groupId, accountId, returnUrl, refreshUrl }, context) => createStripeAccountLink(context.currentUserId, { groupId, accountId, returnUrl, refreshUrl }), + + createStripeProduct: (root, { input }, context) => createStripeProduct(context.currentUserId, input), + + updateStripeProduct: (root, { productId, name, description, priceInCents, currency, contentAccess, renewalPolicy, duration, publishStatus }, context) => updateStripeProduct(context.currentUserId, { productId, name, description, priceInCents, currency, contentAccess, renewalPolicy, duration, publishStatus }), + + createStripeCheckoutSession: (root, { groupId, accountId, priceId, quantity, successUrl, cancelUrl, metadata }, context) => createStripeCheckoutSession(context.currentUserId, { groupId, accountId, priceId, quantity, successUrl, cancelUrl, metadata }), + + checkStripeStatus: (root, { groupId }, context) => checkStripeStatus(context.currentUserId, { groupId }), + reinviteAll: (root, { groupId }, context) => reinviteAll(context.currentUserId, groupId), rejectGroupRelationshipInvite: (root, { groupRelationshipInviteId }, context) => rejectGroupRelationshipInvite(context.currentUserId, groupRelationshipInviteId), diff --git a/apps/backend/api/graphql/mutations/index.js b/apps/backend/api/graphql/mutations/index.js index 94610320b9..ba4aaf6340 100644 --- a/apps/backend/api/graphql/mutations/index.js +++ b/apps/backend/api/graphql/mutations/index.js @@ -159,8 +159,10 @@ export { createStripeAccountLink, stripeAccountStatus, createStripeProduct, + updateStripeProduct, stripeProducts, - createStripeCheckoutSession + createStripeCheckoutSession, + checkStripeStatus } from './stripe' export { default as findOrCreateThread } from '../../models/post/findOrCreateThread' diff --git a/apps/backend/api/graphql/mutations/project.js b/apps/backend/api/graphql/mutations/project.js index 8b83ca0cf7..aeb4b6ed65 100644 --- a/apps/backend/api/graphql/mutations/project.js +++ b/apps/backend/api/graphql/mutations/project.js @@ -2,7 +2,10 @@ import { GraphQLError } from 'graphql' import { uniq } from 'lodash/fp' import { createPost } from './post' -const stripe = require('stripe')(process.env.STRIPE_API_KEY) +const Stripe = require('stripe') +const stripe = process.env.STRIPE_SECRET_KEY + ? new Stripe(process.env.STRIPE_SECRET_KEY) + : null export function createProject (userId, data) { // add creator as a member of project on creation @@ -135,6 +138,10 @@ export async function createStripePaymentNotifications (contribution, creatorId) } export async function processStripeToken (userId, projectId, token, amount) { + if (!stripe) { + throw new GraphQLError('Stripe is not configured. Please set STRIPE_SECRET_KEY environment variable.') + } + const applicationFeeFraction = 0.01 const project = await Post.find(projectId) if (!project) { diff --git a/apps/backend/api/graphql/mutations/stripe.js b/apps/backend/api/graphql/mutations/stripe.js index 3005582cf9..e8eb565ae1 100644 --- a/apps/backend/api/graphql/mutations/stripe.js +++ b/apps/backend/api/graphql/mutations/stripe.js @@ -12,6 +12,25 @@ import { GraphQLError } from 'graphql' /* global StripeProduct, Responsibility, Group, GroupMembership, StripeAccount */ +/** + * Helper function to convert a database account ID to an external Stripe account ID + * If the accountId already starts with 'acct_', it's already the external ID + * Otherwise, look it up from the database + */ +async function getExternalAccountId (accountId) { + // If it already starts with 'acct_', it's already the external ID + if (accountId && accountId.startsWith('acct_')) { + return accountId + } + + // Otherwise, it's a database ID - look up the external account ID + const stripeAccount = await StripeAccount.where({ id: accountId }).fetch() + if (!stripeAccount) { + throw new GraphQLError('Stripe account record not found') + } + return stripeAccount.get('stripe_account_external_id') +} + module.exports = { /** @@ -126,9 +145,12 @@ module.exports = { throw new GraphQLError('You must be a group administrator to manage payments') } + // Convert database ID to external account ID if needed + const externalAccountId = await getExternalAccountId(accountId) + // Create the account link const accountLink = await StripeService.createAccountLink({ - accountId, + accountId: externalAccountId, returnUrl, refreshUrl }) @@ -178,8 +200,11 @@ module.exports = { throw new GraphQLError('You must be a member of this group to view payment status') } - // Get account status from Stripe - const status = await StripeService.getAccountStatus(accountId) + // Convert database ID to external account ID if needed + const externalAccountId = await getExternalAccountId(accountId) + + // Get account status from Stripe using the external account ID + const status = await StripeService.getAccountStatus(externalAccountId) return { accountId: status.id, @@ -251,9 +276,12 @@ module.exports = { throw new GraphQLError('You must be a group administrator to create products') } + // Convert database ID to external account ID if needed + const externalAccountId = await getExternalAccountId(accountId) + // Create the product on the connected account const product = await StripeService.createProduct({ - accountId, + accountId: externalAccountId, name, description, priceInCents, @@ -465,8 +493,11 @@ module.exports = { throw new GraphQLError('You must be a group administrator to view products') } + // Convert database ID to external account ID if needed + const externalAccountId = await getExternalAccountId(accountId) + // Get products from Stripe - const productsResponse = await StripeService.getProducts(accountId) + const productsResponse = await StripeService.getProducts(externalAccountId) // Extract products array from Stripe response (which has a 'data' property) const products = productsResponse.data || productsResponse @@ -528,8 +559,11 @@ module.exports = { // Authentication is optional for checkout - you may want to allow guests // For this demo, we'll allow unauthenticated purchases + // Convert database ID to external account ID if needed + const externalAccountId = await getExternalAccountId(accountId) + // Fetch the actual price from Stripe to calculate the application fee accurately - const priceObject = await StripeService.getPrice(accountId, priceId) + const priceObject = await StripeService.getPrice(externalAccountId, priceId) // Calculate the total amount (price * quantity) const totalAmount = priceObject.unit_amount * (quantity || 1) @@ -542,7 +576,7 @@ module.exports = { // Create the checkout session const checkoutSession = await StripeService.createCheckoutSession({ - accountId, + accountId: externalAccountId, priceId, quantity: quantity || 1, applicationFeeAmount, @@ -569,5 +603,81 @@ module.exports = { console.error('Error in createStripeCheckoutSession:', error) throw new GraphQLError(`Failed to create checkout session: ${error.message}`) } + }, + + /** + * Checks the Stripe account status and updates the database + * + * This mutation fetches the current status from Stripe and updates + * the group's stripe status fields in the database. + * + * Usage: + * mutation { + * checkStripeStatus(groupId: "123") { + * success + * message + * chargesEnabled + * payoutsEnabled + * detailsSubmitted + * } + * } + */ + checkStripeStatus: async (userId, { groupId }) => { + try { + // Check if user is authenticated + if (!userId) { + throw new GraphQLError('You must be logged in to check Stripe status') + } + + // Load the group + const group = await Group.find(groupId) + if (!group) { + throw new GraphQLError('Group not found') + } + + // Verify user has permission for this group + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { + throw new GraphQLError('You must be a group administrator to check Stripe status') + } + + // Get the Stripe account ID from the group + const stripeAccountId = group.get('stripe_account_id') + if (!stripeAccountId) { + throw new GraphQLError('Group does not have a connected Stripe account') + } + + // Get the StripeAccount record to find the external account ID + const stripeAccount = await StripeAccount.where({ id: stripeAccountId }).fetch() + if (!stripeAccount) { + throw new GraphQLError('Stripe account record not found') + } + + const externalAccountId = stripeAccount.get('stripe_account_external_id') + + // Get account status from Stripe + const status = await StripeService.getAccountStatus(externalAccountId) + + // Update the group with the latest status from Stripe + await group.save({ + stripe_charges_enabled: status.charges_enabled, + stripe_payouts_enabled: status.payouts_enabled, + stripe_details_submitted: status.details_submitted + }, { patch: true }) + + return { + success: true, + message: 'Stripe status updated successfully', + chargesEnabled: status.charges_enabled, + payoutsEnabled: status.payouts_enabled, + detailsSubmitted: status.details_submitted + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in checkStripeStatus:', error) + throw new GraphQLError(`Failed to check Stripe status: ${error.message}`) + } } } diff --git a/apps/backend/api/graphql/schema.graphql b/apps/backend/api/graphql/schema.graphql index d5ba05c951..e5b05d8be6 100644 --- a/apps/backend/api/graphql/schema.graphql +++ b/apps/backend/api/graphql/schema.graphql @@ -307,6 +307,18 @@ type Query { offset: Int ): SkillQuerySet, + # Get the status of a connected Stripe account + stripeAccountStatus( + groupId: ID! + accountId: String! + ): StripeAccountStatusResult + + # List all products for a connected Stripe account + stripeProducts( + groupId: ID! + accountId: String! + ): StripeProductsResult + # Find a Topic by ID or by name (text) topic(id: ID, name: String): Topic @@ -822,6 +834,14 @@ type Group { stewardDescriptorPlural: String # Stripe Connected Account ID for accepting payments stripeAccountId: String + # Direct link to the Stripe Dashboard for this group's connected account + stripeDashboardUrl: String + # Whether the Stripe account can accept charges + stripeChargesEnabled: Boolean + # Whether the Stripe account can receive payouts + stripePayoutsEnabled: Boolean + # Whether all required Stripe details have been submitted + stripeDetailsSubmitted: Boolean # Right now can either by 'farm' or null type: String # Word used to describe this type of group. Defaults to Group, or if type is set then defaults to the type (e.g. Farm) @@ -3166,12 +3186,6 @@ type Mutation { refreshUrl: String! ): StripeAccountLinkResult - # Get the status of a connected account - stripeAccountStatus( - groupId: ID! - accountId: String! - ): StripeAccountStatusResult - # Create a product on the connected account createStripeProduct(input: StripeProductInput!): StripeProductResult @@ -3188,12 +3202,6 @@ type Mutation { publishStatus: PublishStatus ): StripeProductUpdateResult - # List all products for a connected account - stripeProducts( - groupId: ID! - accountId: String! - ): StripeProductsResult - # Create a checkout session for purchasing a product createStripeCheckoutSession( groupId: ID! @@ -3204,6 +3212,9 @@ type Mutation { cancelUrl: String! metadata: JSON ): StripeCheckoutSessionResult + + # Check Stripe account status and update database values + checkStripeStatus(groupId: ID!): StripeStatusCheckResult } # Result of acceptGroupRelationshipInvite mutation @@ -3282,6 +3293,14 @@ type StripeAccountStatusResult { requirements: JSON } +type StripeStatusCheckResult { + success: Boolean + message: String + chargesEnabled: Boolean + payoutsEnabled: Boolean + detailsSubmitted: Boolean +} + type StripeProductResult { productId: String priceId: String diff --git a/apps/backend/api/models/Group.js b/apps/backend/api/models/Group.js index 02d56a59b4..681849e55d 100644 --- a/apps/backend/api/models/Group.js +++ b/apps/backend/api/models/Group.js @@ -50,6 +50,23 @@ module.exports = bookshelf.Model.extend(merge({ return response }, + /** + * Builds a Stripe Dashboard URL for this group's connected account + */ + async stripeDashboardUrl () { + try { + const stripeAccountId = this.get('stripe_account_id') + if (!stripeAccountId) return null + const acct = await StripeAccount.where({ id: stripeAccountId }).fetch() + if (!acct) return null + const externalId = acct.get('stripe_account_external_id') + if (!externalId) return null + return `https://dashboard.stripe.com/${externalId}` + } catch (e) { + return null + } + }, + // ******** Getters ******* // agreements: function () { @@ -717,7 +734,7 @@ module.exports = bookshelf.Model.extend(merge({ 'about_video_uri', 'active', 'access_code', 'accessibility', 'avatar_url', 'banner_url', 'description', 'geo_shape', 'location', 'location_id', 'name', 'purpose', 'settings', 'steward_descriptor', 'steward_descriptor_plural', 'type_descriptor', 'type_descriptor_plural', 'visibility', - 'welcome_page', 'website_url' + 'welcome_page', 'website_url', 'stripe_account_id', 'stripe_charges_enabled', 'stripe_payouts_enabled', 'stripe_details_submitted', 'paywall' ] const trimAttrs = ['name', 'description', 'purpose'] diff --git a/apps/web/public/locales/en.json b/apps/web/public/locales/en.json index 658ee913d6..8abd82c159 100644 --- a/apps/web/public/locales/en.json +++ b/apps/web/public/locales/en.json @@ -746,6 +746,7 @@ "Override Name": "Override Name", "Overview": "Overview", "PERSONAL": "PERSONAL", + "Paid Content": "Paid Content", "Parent Groups": "Parent Groups", "Password": "Password", "Password (at least 9 characters)": "Password (at least 9 characters)", diff --git a/apps/web/public/locales/es.json b/apps/web/public/locales/es.json index 8870dc9119..88e5d829ac 100644 --- a/apps/web/public/locales/es.json +++ b/apps/web/public/locales/es.json @@ -750,6 +750,7 @@ "Override Name": "Anular nombre", "Overview": "Descripción", "PERSONAL": "PERSONAL", + "Paid Content": "Contenido pago", "Parent Groups": "Grupos principales", "Password": "Contraseña", "Password (at least 9 characters)": "Contraseña (al menos 9 caracteres)", diff --git a/apps/web/src/routes/AuthLayoutRouter/components/ContextMenu/ContextMenu.jsx b/apps/web/src/routes/AuthLayoutRouter/components/ContextMenu/ContextMenu.jsx index 16c36d5d69..03ebc1fb13 100644 --- a/apps/web/src/routes/AuthLayoutRouter/components/ContextMenu/ContextMenu.jsx +++ b/apps/web/src/routes/AuthLayoutRouter/components/ContextMenu/ContextMenu.jsx @@ -812,6 +812,7 @@ function GroupSettingsMenu ({ group }) { canManageTracks && { title: 'Tracks & Actions', url: 'settings/tracks' }, canAdminister && { title: 'Custom Views', url: 'settings/views' }, canAdminister && { title: 'Export Data', url: 'settings/export' }, + canAdminister && { title: 'Paid Content', url: 'settings/paid-content' }, canAdminister && { title: 'Delete', url: 'settings/delete' } ].filter(Boolean), [canAdminister, canAddMembers, canManageTracks]) diff --git a/apps/web/src/routes/GroupSettings/GroupSettings.js b/apps/web/src/routes/GroupSettings/GroupSettings.js index 4023ea02f3..53bdc7c8ea 100644 --- a/apps/web/src/routes/GroupSettings/GroupSettings.js +++ b/apps/web/src/routes/GroupSettings/GroupSettings.js @@ -18,6 +18,7 @@ import ResponsibilitiesTab from './ResponsibilitiesTab' import ExportDataTab from './ExportDataTab' import TracksTab from './TracksTab' import WelcomePageTab from './WelcomePageTab' +import PaidContentTab from './PaidContentTab' import Loading from 'components/Loading' import { fetchLocation } from 'components/LocationInput/LocationInput.store' import FullPageModal from 'routes/FullPageModal' @@ -182,6 +183,12 @@ export default function GroupSettings () { component: } + const paidContentSettings = { + name: t('Paid Content'), + path: 'paid-content', + component: + } + const deleteSettings = { name: t('Delete'), path: 'delete', @@ -206,6 +213,7 @@ export default function GroupSettings () { canManageTracks ? tracksSettings : null, canAdminister ? importSettings : null, canAdminister ? exportSettings : null, + canAdminister ? paidContentSettings : null, canAdminister ? deleteSettings : null ])} /> diff --git a/apps/web/src/routes/GroupSettings/GroupSettings.store.js b/apps/web/src/routes/GroupSettings/GroupSettings.store.js index f85e5a3541..eb517c17d5 100644 --- a/apps/web/src/routes/GroupSettings/GroupSettings.store.js +++ b/apps/web/src/routes/GroupSettings/GroupSettings.store.js @@ -68,6 +68,12 @@ export function fetchGroupSettings (slug) { type slug visibility + stripeAccountId + stripeDashboardUrl + stripeChargesEnabled + stripePayoutsEnabled + stripeDetailsSubmitted + paywall agreements { items { id @@ -254,6 +260,11 @@ export function updateGroupSettings (id, changes) { query: `mutation ($id: ID, $changes: GroupInput) { updateGroupSettings(id: $id, changes: $changes) { id + stripeAccountId + stripeChargesEnabled + stripePayoutsEnabled + stripeDetailsSubmitted + paywall agreements { items { id diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js index 0911558776..2fab262131 100644 --- a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js @@ -24,22 +24,26 @@ import { createConnectedAccount, createAccountLink, fetchAccountStatus, + checkStripeStatus, createProduct, fetchProducts + // updateProduct - TODO: Enable when database product IDs are available } from './PaidContentTab.store' -import { updateGroupSettings } from '../GroupSettings.store' +import { updateGroupSettings, fetchGroupSettings } from '../GroupSettings.store' /** * Main PaidContentTab component * Orchestrates the Stripe Connect integration UI */ -function PaidContentTab ({ group }) { +function PaidContentTab ({ group, currentUser }) { const { t } = useTranslation() const dispatch = useDispatch() // Local state for managing Stripe account and products + // Note: accountId comes from camelCase GraphQL field + const initialAccountId = group?.stripeAccountId || '' const [state, setState] = useState({ - accountId: group?.stripeAccountId || '', + accountId: initialAccountId, accountStatus: null, products: [], loading: false, @@ -60,68 +64,42 @@ function PaidContentTab ({ group }) { // Update accountId when group changes useEffect(() => { - if (group?.stripeAccountId && group.stripeAccountId !== state.accountId) { - setState(prev => ({ ...prev, accountId: group.stripeAccountId })) + const newAccountId = group?.stripeAccountId || '' + if (newAccountId && newAccountId !== state.accountId) { + setState(prev => ({ ...prev, accountId: newAccountId })) } }, [group?.stripeAccountId]) - // Load account status if we have an account ID - useEffect(() => { - if (state.accountId && group?.id) { - loadAccountStatus() - loadProducts() - } - }, [state.accountId, group?.id]) - /** - * Creates a new Stripe Connected Account for this group + * Loads the current account status from Stripe * - * This is the first step in enabling payments. Once the account is created, - * the group admin needs to complete onboarding via an Account Link. + * This fetches the live status to show onboarding progress + * and payment capabilities. */ - const handleCreateAccount = useCallback(async () => { - if (!group) return - - setState(prev => ({ ...prev, loading: true, error: null })) + const loadAccountStatus = useCallback(async () => { + if (!group?.id || !state.accountId) return try { - const result = await dispatch(createConnectedAccount( - group.id, - group.contactEmail || '', // TODO STRIPE: Use appropriate email field - group.name, - 'US' // TODO STRIPE: Make country selectable or detect from user - )) + const result = await dispatch(fetchAccountStatus(group.id, state.accountId)) if (result.error) { - throw new Error(result.error.message || 'Failed to create connected account') - } - - const accountId = result.payload?.data?.createStripeConnectedAccount?.accountId - - if (!accountId) { - throw new Error('No account ID returned from server') + throw new Error(result.error.message) } - // Save accountId to your group model in the database - await dispatch(updateGroupSettings(group.id, { stripeAccountId: accountId })) + const status = result.payload?.data?.stripeAccountStatus setState(prev => ({ ...prev, - accountId, - loading: false + accountStatus: status })) - - // Automatically trigger onboarding after account creation - handleStartOnboarding(accountId) } catch (error) { - console.error('Error creating connected account:', error) + console.error('Error loading account status:', error) setState(prev => ({ ...prev, - loading: false, error: error.message })) } - }, [dispatch, group]) + }, [dispatch, group, state.accountId]) /** * Generates an Account Link and redirects the user to Stripe @@ -177,35 +155,86 @@ function PaidContentTab ({ group }) { }, [dispatch, group, state.accountId]) /** - * Loads the current account status from Stripe + * Creates a new Stripe Connected Account for this group * - * This fetches the live status to show onboarding progress - * and payment capabilities. + * This is the first step in enabling payments. Once the account is created, + * the group admin needs to complete onboarding via an Account Link. */ - const loadAccountStatus = useCallback(async () => { - if (!group?.id || !state.accountId) return + const handleCreateAccount = useCallback(async ({ email, businessName, country, existingAccountId }) => { + if (!group) return + + setState(prev => ({ ...prev, loading: true, error: null })) try { - const result = await dispatch(fetchAccountStatus(group.id, state.accountId)) + const result = await dispatch(createConnectedAccount( + group.id, + email, + businessName || group.name, + country || 'US', + existingAccountId || null + )) - if (result.error) { - throw new Error(result.error.message) + // Check for errors in the response + if (result.error || result.payload?.errors) { + const error = result.error || result.payload?.errors?.[0] + throw new Error(error?.message || 'Failed to create connected account') } - const status = result.payload?.data?.stripeAccountStatus + // Access data - getData() is at result.payload.getData + const responseData = result.payload?.getData + ? result.payload.getData() + : result.payload?.data?.createStripeConnectedAccount + + // If the mutation returned null, log full details + if (!responseData || responseData === null) { + console.error('Mutation returned null. Full response:', { + payload: result.payload, + payloadData: result.payload?.data, + getDataResult: result.payload?.getData ? result.payload.getData() : null, + errors: result.payload?.errors + }) + throw new Error('The server returned null. This usually means the mutation failed. Check the browser console and server logs for details.') + } + + const accountId = responseData.accountId + + if (!accountId) { + throw new Error('Server response missing accountId field: ' + JSON.stringify(responseData)) + } + + // Save accountId to your group model in the database + await dispatch(updateGroupSettings(group.id, { stripeAccountId: accountId })) setState(prev => ({ ...prev, - accountStatus: status + accountId, + loading: false })) + + // Automatically trigger onboarding after account creation (only for new accounts) + if (!existingAccountId) { + handleStartOnboarding(accountId) + } else { + // For existing accounts, just refresh status + loadAccountStatus() + } } catch (error) { - console.error('Error loading account status:', error) + console.error('Error creating/connecting account:', error) setState(prev => ({ ...prev, + loading: false, error: error.message })) } - }, [dispatch, group, state.accountId]) + }, [dispatch, group, handleStartOnboarding, loadAccountStatus]) + + // Load account status if we have an account ID + useEffect(() => { + if (state.accountId && group?.id) { + loadAccountStatus() + loadProducts() + } + }, [state.accountId, group?.id]) /** * Loads all products for this connected account @@ -232,16 +261,51 @@ function PaidContentTab ({ group }) { }, [dispatch, group, state.accountId]) /** - * Refreshes account status - useful after returning from onboarding + * Checks Stripe status and updates the database */ - const handleRefreshStatus = useCallback(() => { - loadAccountStatus() - loadProducts() - }, [loadAccountStatus, loadProducts]) + const handleCheckStripeStatus = useCallback(async () => { + if (!group) return + + setState(prev => ({ ...prev, loading: true, error: null })) + + try { + const result = await dispatch(checkStripeStatus(group.id)) + + if (result.error || result.payload?.errors) { + const error = result.error || result.payload?.errors?.[0] + throw new Error(error?.message || 'Failed to check Stripe status') + } + + const responseData = result.payload?.getData + ? result.payload.getData() + : result.payload?.data?.checkStripeStatus + + if (!responseData || !responseData.success) { + throw new Error(responseData?.message || 'Failed to check Stripe status') + } + + // Refresh the group data to get updated stripe status values from the DB + if (group?.slug) { + await dispatch(fetchGroupSettings(group.slug)) + } + + setState(prev => ({ ...prev, loading: false, error: null })) + + // Reload live account status to reflect the updates + loadAccountStatus() + } catch (error) { + console.error('Error checking Stripe status:', error) + setState(prev => ({ + ...prev, + loading: false, + error: error.message + })) + } + }, [dispatch, group, loadAccountStatus]) if (!group) return - const { accountId, accountStatus, products, loading, error } = state + const { accountId, products, loading, error } = state return (
@@ -261,31 +325,34 @@ function PaidContentTab ({ group }) { )} - {!accountId ? ( - - ) : ( - <> - - - - - )} + />) + : ( + <> + + + + )}
) @@ -295,63 +362,186 @@ function PaidContentTab ({ group }) { * Section for initial account setup * * Displayed when the group doesn't have a Stripe account yet. + * Provides a form to collect account information for creating or connecting accounts. */ -function AccountSetupSection ({ loading, onCreateAccount, t }) { +function AccountSetupSection ({ loading, onCreateAccount, onConnectAccount, group, currentUser, t }) { + const [formData, setFormData] = useState({ + email: currentUser?.email || '', + businessName: group?.name || '', + country: 'US', + existingAccountId: '' + }) + const [isConnectingExisting, setIsConnectingExisting] = useState(false) + const [submitting, setSubmitting] = useState(false) + + const handleSubmit = useCallback(async (e) => { + e.preventDefault() + + // Validate required fields + if (!formData.email.trim()) { + alert(t('Email is required')) + return + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(formData.email)) { + alert(t('Please enter a valid email address')) + return + } + + // If connecting existing account, validate account ID + if (isConnectingExisting) { + if (!formData.existingAccountId.trim()) { + alert(t('Please enter a Stripe account ID')) + return + } + if (!formData.existingAccountId.startsWith('acct_')) { + alert(t('Stripe account IDs must start with "acct_"')) + return + } + } + + setSubmitting(true) + try { + await (isConnectingExisting ? onConnectAccount : onCreateAccount)({ + email: formData.email.trim(), + businessName: formData.businessName.trim() || group.name, + country: formData.country, + existingAccountId: isConnectingExisting ? formData.existingAccountId.trim() : null + }) + } catch (error) { + console.error('Error creating/connecting account:', error) + // Error state is handled by parent component + } finally { + setSubmitting(false) + } + }, [formData, isConnectingExisting, onCreateAccount, onConnectAccount, group, t]) + return ( -
-
- -
-

{t('Get started with payments')}

-

- {t('Create a Stripe Connect account to start accepting payments. This allows your group to receive payments directly while maintaining security and compliance.')} -

-
    -
  • {t('Accept credit card and other payment methods')}
  • -
  • {t('Automatic payouts to your bank account')}
  • -
  • {t('Full dashboard access to view transactions')}
  • -
  • {t('Stripe handles all payment disputes and fraud')}
  • -
+ +
+

{t('Get started with payments')}

+

+ {t('Set up Stripe Connect to accept payments. You can create a new account or connect an existing Stripe account.')} +

+
+ +
+ setFormData(prev => ({ ...prev, email: e.target.value }))} + placeholder={currentUser?.email || t('your-email@example.com')} + required + helpText={t('This email will be associated with your Stripe account')} + /> + + setFormData(prev => ({ ...prev, businessName: e.target.value }))} + placeholder={group?.name || t('Business or Organization Name')} + helpText={t('The name of your business or organization (defaults to group name)')} + /> + +
+
+ setFormData(prev => ({ ...prev, country: e.target.value }))} + renderControl={(props) => ( + + )} + helpText={t('Country where your business is located')} + /> +
+
+ +
+ setIsConnectingExisting(e.target.checked)} + className='w-4 h-4' + /> + +
+ + {isConnectingExisting && ( + setFormData(prev => ({ ...prev, existingAccountId: e.target.value }))} + placeholder='acct_...' + required={isConnectingExisting} + helpText={t('Enter your existing Stripe Connect account ID (starts with "acct_")')} + /> + )} + +
+ + +
+

{t('About Stripe Connect:')}

+
    +
  • {t('Accept credit card and other payment methods')}
  • +
  • {t('Automatic payouts to your bank account')}
  • +
  • {t('Full dashboard access to view transactions')}
  • +
  • {t('Stripe handles all payment disputes and fraud')}
  • +
-
+ ) } /** - * Section showing account onboarding status + * Section showing Stripe account status from database * - * Displays the current state of the connected account and provides - * actions to complete onboarding or view the Stripe dashboard. + * Displays the current state of the connected account based on database values, + * and provides actions to check status with Stripe or complete onboarding. */ -function AccountStatusSection ({ accountStatus, loading, onStartOnboarding, onRefreshStatus, t }) { - if (loading && !accountStatus) { - return ( -
- -
- ) - } - - const isFullyOnboarded = accountStatus?.chargesEnabled && accountStatus?.payoutsEnabled - const needsOnboarding = !accountStatus?.detailsSubmitted +function StripeStatusSection ({ group, loading, onCheckStatus, onStartOnboarding, t }) { + const chargesEnabled = group?.stripeChargesEnabled + const payoutsEnabled = group?.stripePayoutsEnabled + const detailsSubmitted = group?.stripeDetailsSubmitted + const paywall = group?.paywall + const isFullyOnboarded = chargesEnabled && payoutsEnabled + const needsOnboarding = !detailsSubmitted return (
- {isFullyOnboarded ? ( - - ) : ( - - )} + {isFullyOnboarded + ? () + : ()}

{isFullyOnboarded ? t('Account Active') : t('Account Setup Required')} @@ -359,40 +549,72 @@ function AccountStatusSection ({ accountStatus, loading, onStartOnboarding, onRe

{isFullyOnboarded ? t('Your Stripe account is fully set up and ready to accept payments.') - : t('Complete your account setup to start accepting payments.') - } + : t('Complete your account setup to start accepting payments.')}

- {accountStatus && ( -
- - - +
+ + + +
+ + {group?.stripeDashboardUrl && ( + )} +
+ ( +
+ + + {paywall ? t('Yes') : t('No')} + +
+ )} + /> +
+ {needsOnboarding && ( )} - - {accountStatus?.requirements && accountStatus.requirements.currently_due?.length > 0 && ( -
-

{t('Action Required')}

-

- {t('There are {{count}} items that need your attention.', { count: accountStatus.requirements.currently_due.length })} -

-
- )}
) } @@ -438,6 +651,7 @@ function StatusBadge ({ label, value, t }) { function ProductManagementSection ({ group, accountId, products, onRefreshProducts, t }) { const dispatch = useDispatch() const [showCreateForm, setShowCreateForm] = useState(false) + const [editingProduct, setEditingProduct] = useState(null) const [formData, setFormData] = useState({ name: '', description: '', @@ -445,6 +659,7 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc currency: 'usd' }) const [creating, setCreating] = useState(false) + const [updating, setUpdating] = useState(false) /** * Handles product creation @@ -491,6 +706,71 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc } }, [dispatch, group, accountId, formData, onRefreshProducts, t]) + /** + * Handles product updates + */ + const handleUpdateProduct = useCallback(async (e) => { + e.preventDefault() + + if (!editingProduct || !formData.name) { + alert(t('Please fill in all required fields')) + return + } + + setUpdating(true) + + try { + const updates = {} + if (formData.name !== editingProduct.name) updates.name = formData.name + if (formData.description !== editingProduct.description) updates.description = formData.description + + // Note: We can't update price easily since it requires creating a new price in Stripe + // For now, we'll only allow updating name and description + + if (Object.keys(updates).length === 0) { + setEditingProduct(null) + setUpdating(false) + return + } + + // Note: updateProduct requires the database product ID, not the Stripe product ID + // For now, we'll show a message that this feature needs database product IDs + // In production, you'd need to fetch/store the database product ID + alert(t('Product updates require database product IDs. This feature is coming soon.')) + + // TODO: Implement full update when database product IDs are available + // const result = await dispatch(updateProduct(editingProduct.databaseId, updates)) + // if (result.error) { + // throw new Error(result.error.message) + // } + + setEditingProduct(null) + setFormData({ name: '', description: '', price: '', currency: 'usd' }) + onRefreshProducts() + } catch (error) { + console.error('Error updating product:', error) + alert(t('Failed to update product: {{error}}', { error: error.message })) + } finally { + setUpdating(false) + } + }, [dispatch, editingProduct, formData, onRefreshProducts, t]) + + const handleStartEdit = useCallback((product) => { + setEditingProduct(product) + setFormData({ + name: product.name || '', + description: product.description || '', + price: '', // Price editing would be complex, skipping for now + currency: 'usd' + }) + setShowCreateForm(false) + }, []) + + const handleCancelEdit = useCallback(() => { + setEditingProduct(null) + setFormData({ name: '', description: '', price: '', currency: 'usd' }) + }, []) + return (
@@ -510,9 +790,11 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc
- {showCreateForm && ( -
-

{t('Create New Product')}

+ {(showCreateForm || editingProduct) && ( + +

+ {editingProduct ? t('Edit Product') : t('Create New Product')} +

setShowCreateForm(false)} + onClick={editingProduct ? handleCancelEdit : () => setShowCreateForm(false)} > {t('Cancel')}
)} - {products.length === 0 ? ( -
- -

{t('No products yet')}

-

{t('Create your first product to start accepting payments')}

-
- ) : ( -
- {products.map(product => ( - - ))} -
- )} + {products.length === 0 + ? ( +
+ +

{t('No products yet')}

+

{t('Create your first product to start accepting payments')}

+
+ ) + : ( +
+ {products.map(product => ( + + ))} +
+ )} {products.length > 0 && (
@@ -623,21 +908,30 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc /** * Card displaying a single product */ -function ProductCard ({ product, groupSlug, t }) { +function ProductCard ({ product, groupSlug, onEdit, t }) { return ( -
+

{product.name}

{product.description && (

{product.description}

)}
-
-

{t('ID')}: {product.id}

- {product.active ? ( - {t('Active')} - ) : ( - {t('Inactive')} +
+
+

{t('ID')}: {product.id}

+ {product.active + ? ({t('Active')}) + : ({t('Inactive')})} +
+ {onEdit && ( + )}
diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js index 88a47bd39b..49f7cc3e13 100644 --- a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js @@ -12,25 +12,28 @@ export const MODULE_NAME = 'PaidContentTab' export const CREATE_CONNECTED_ACCOUNT = `${MODULE_NAME}/CREATE_CONNECTED_ACCOUNT` export const CREATE_ACCOUNT_LINK = `${MODULE_NAME}/CREATE_ACCOUNT_LINK` export const FETCH_ACCOUNT_STATUS = `${MODULE_NAME}/FETCH_ACCOUNT_STATUS` +export const CHECK_STRIPE_STATUS = `${MODULE_NAME}/CHECK_STRIPE_STATUS` export const CREATE_PRODUCT = `${MODULE_NAME}/CREATE_PRODUCT` +export const UPDATE_PRODUCT = `${MODULE_NAME}/UPDATE_PRODUCT` export const FETCH_PRODUCTS = `${MODULE_NAME}/FETCH_PRODUCTS` /** * Creates a Stripe Connected Account for the group - * + * * This allows the group to receive payments directly while the platform - * takes an application fee. + * takes an application fee. Can create a new account or connect an existing one. */ -export function createConnectedAccount (groupId, email, businessName, country = 'US') { +export function createConnectedAccount (groupId, email, businessName, country = 'US', existingAccountId = null) { return { type: CREATE_CONNECTED_ACCOUNT, graphql: { - query: `mutation ($groupId: ID!, $email: String!, $businessName: String!, $country: String) { + query: `mutation ($groupId: ID!, $email: String!, $businessName: String!, $country: String, $existingAccountId: String) { createStripeConnectedAccount( groupId: $groupId email: $email businessName: $businessName country: $country + existingAccountId: $existingAccountId ) { id accountId @@ -42,7 +45,8 @@ export function createConnectedAccount (groupId, email, businessName, country = groupId, email, businessName, - country + country, + existingAccountId } } } @@ -50,7 +54,7 @@ export function createConnectedAccount (groupId, email, businessName, country = /** * Creates an Account Link for onboarding - * + * * Generates a temporary URL that allows the connected account to complete * their onboarding process and gain access to the Stripe Dashboard. */ @@ -82,7 +86,7 @@ export function createAccountLink (groupId, accountId, returnUrl, refreshUrl) { /** * Fetches the current status of a connected account - * + * * Retrieves onboarding status, payment capabilities, and requirements * directly from Stripe. */ @@ -100,12 +104,7 @@ export function fetchAccountStatus (groupId, accountId) { payoutsEnabled detailsSubmitted email - requirements { - currently_due - eventually_due - past_due - pending_verification - } + requirements } }`, variables: { @@ -118,7 +117,7 @@ export function fetchAccountStatus (groupId, accountId) { /** * Creates a product on the connected account - * + * * Products represent subscription tiers, content access, or other offerings * that the group wants to sell. */ @@ -154,9 +153,35 @@ export function createProduct (groupId, accountId, name, description, priceInCen } } +/** + * Checks Stripe account status and updates the database + * + * Fetches the current status from Stripe and updates the group's + * stripe status fields in the database. + */ +export function checkStripeStatus (groupId) { + return { + type: CHECK_STRIPE_STATUS, + graphql: { + query: `mutation ($groupId: ID!) { + checkStripeStatus(groupId: $groupId) { + success + message + chargesEnabled + payoutsEnabled + detailsSubmitted + } + }`, + variables: { + groupId + } + } + } +} + /** * Fetches all products for a connected account - * + * * Lists all active products that the group has created for sale. */ export function fetchProducts (groupId, accountId) { @@ -187,6 +212,48 @@ export function fetchProducts (groupId, accountId) { } } +/** + * Updates an existing Stripe product + * + * Allows updating product details including name, description, price, etc. + */ +export function updateProduct (productId, updates) { + const { name, description, priceInCents, currency, contentAccess, renewalPolicy, duration, publishStatus } = updates || {} + + return { + type: UPDATE_PRODUCT, + graphql: { + query: `mutation ($productId: ID!, $name: String, $description: String, $priceInCents: Int, $currency: String, $contentAccess: JSON, $renewalPolicy: String, $duration: String, $publishStatus: PublishStatus) { + updateStripeProduct( + productId: $productId + name: $name + description: $description + priceInCents: $priceInCents + currency: $currency + contentAccess: $contentAccess + renewalPolicy: $renewalPolicy + duration: $duration + publishStatus: $publishStatus + ) { + success + message + } + }`, + variables: { + productId, + name, + description, + priceInCents, + currency, + contentAccess, + renewalPolicy, + duration, + publishStatus + } + } + } +} + /** * Selector to get account status from state */ @@ -226,4 +293,3 @@ export default function reducer (state = {}, action) { return state } } - diff --git a/apps/web/src/store/models/Group.js b/apps/web/src/store/models/Group.js index c97dbb5923..2483c7b189 100644 --- a/apps/web/src/store/models/Group.js +++ b/apps/web/src/store/models/Group.js @@ -206,7 +206,13 @@ Group.fields = { relatedName: 'eventGroups' }), visibility: attr(), - widgets: many('Widget') + widgets: many('Widget'), + stripeAccountId: attr(), + stripeDashboardUrl: attr(), + stripeChargesEnabled: attr(), + stripePayoutsEnabled: attr(), + stripeDetailsSubmitted: attr(), + paywall: attr() } export const DEFAULT_BANNER = '/default-group-banner.svg' diff --git a/apps/web/src/util/contextWidgets.js b/apps/web/src/util/contextWidgets.js index 94d8c3cc34..955f219075 100644 --- a/apps/web/src/util/contextWidgets.js +++ b/apps/web/src/util/contextWidgets.js @@ -99,7 +99,7 @@ export function replaceHomeWidget ({ widgets, newHomeWidgetId }) { // so by here the updatedWidgets array has removed the new home widget from its prior position and its prior peers are settled // if the old home widget is a chat, we need to move it to the top of the chats widget, otherwise we remove it from the menu - if (homeChild.type === 'chat' || homeChild.type === 'viewChat') { + if (homeChild.type === 'viewChat') { const chatsWidgetId = updatedWidgets.find(widget => widget.type === 'chats')?.id const newPeers = updatedWidgets.filter(widget => widget.parentId === chatsWidgetId).map(peer => ({ ...peer, From b8d685c9b3e7527952ea770af306d6652de10d8f Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 5 Nov 2025 11:12:45 +1030 Subject: [PATCH 13/76] Switch from product to offering for paid content descriptor --- apps/backend/api/graphql/makeModels.js | 2 +- apps/backend/api/graphql/makeSchema.js | 12 +- apps/backend/api/graphql/mutations/index.js | 6 +- apps/backend/api/graphql/mutations/stripe.js | 74 ++++---- apps/backend/api/graphql/schema.graphql | 60 +++---- .../PaidContentTab/PaidContentTab.js | 162 +++++++++--------- .../PaidContentTab/PaidContentTab.store.js | 54 +++--- apps/web/src/routes/GroupStore/GroupStore.js | 91 +++++----- 8 files changed, 230 insertions(+), 231 deletions(-) diff --git a/apps/backend/api/graphql/makeModels.js b/apps/backend/api/graphql/makeModels.js index ab0a3f0640..765efda179 100644 --- a/apps/backend/api/graphql/makeModels.js +++ b/apps/backend/api/graphql/makeModels.js @@ -1554,7 +1554,7 @@ export default function makeModels (userId, isAdmin, apiClient) { ] }, - StripeProduct: { + StripeOffering: { model: StripeProduct, attributes: [ 'id', diff --git a/apps/backend/api/graphql/makeSchema.js b/apps/backend/api/graphql/makeSchema.js index 7e684bfd50..727a0137a5 100644 --- a/apps/backend/api/graphql/makeSchema.js +++ b/apps/backend/api/graphql/makeSchema.js @@ -151,9 +151,9 @@ import { createStripeConnectedAccount, createStripeAccountLink, stripeAccountStatus, - createStripeProduct, - updateStripeProduct, - stripeProducts, + createStripeOffering, + updateStripeOffering, + stripeOfferings, createStripeCheckoutSession, checkStripeStatus, verifyEmail @@ -401,7 +401,7 @@ export function makeAuthenticatedQueries ({ fetchOne, fetchMany }) { }, skills: (root, args) => fetchMany('Skill', args), stripeAccountStatus: (root, { groupId, accountId }, context) => stripeAccountStatus(context.currentUserId, { groupId, accountId }), - stripeProducts: (root, { groupId, accountId }, context) => stripeProducts(context.currentUserId, { groupId, accountId }), + stripeOfferings: (root, { groupId, accountId }, context) => stripeOfferings(context.currentUserId, { groupId, accountId }), // you can specify id or name, but not both topic: (root, { id, name }) => fetchOne('Topic', name || id, name ? 'name' : 'id'), topicFollow: (root, { groupId, topicName }, context) => TagFollow.findOrCreate({ groupId, topicName, userId: context.currentUserId }), @@ -608,9 +608,9 @@ export function makeMutations ({ fetchOne }) { createStripeAccountLink: (root, { groupId, accountId, returnUrl, refreshUrl }, context) => createStripeAccountLink(context.currentUserId, { groupId, accountId, returnUrl, refreshUrl }), - createStripeProduct: (root, { input }, context) => createStripeProduct(context.currentUserId, input), + createStripeOffering: (root, { input }, context) => createStripeOffering(context.currentUserId, input), - updateStripeProduct: (root, { productId, name, description, priceInCents, currency, contentAccess, renewalPolicy, duration, publishStatus }, context) => updateStripeProduct(context.currentUserId, { productId, name, description, priceInCents, currency, contentAccess, renewalPolicy, duration, publishStatus }), + updateStripeOffering: (root, { offeringId, name, description, priceInCents, currency, contentAccess, renewalPolicy, duration, publishStatus }, context) => updateStripeOffering(context.currentUserId, { offeringId, name, description, priceInCents, currency, contentAccess, renewalPolicy, duration, publishStatus }), createStripeCheckoutSession: (root, { groupId, accountId, priceId, quantity, successUrl, cancelUrl, metadata }, context) => createStripeCheckoutSession(context.currentUserId, { groupId, accountId, priceId, quantity, successUrl, cancelUrl, metadata }), diff --git a/apps/backend/api/graphql/mutations/index.js b/apps/backend/api/graphql/mutations/index.js index 9a7a325671..fa6dde7d82 100644 --- a/apps/backend/api/graphql/mutations/index.js +++ b/apps/backend/api/graphql/mutations/index.js @@ -167,9 +167,9 @@ export { createStripeConnectedAccount, createStripeAccountLink, stripeAccountStatus, - createStripeProduct, - updateStripeProduct, - stripeProducts, + createStripeOffering, + updateStripeOffering, + stripeOfferings, createStripeCheckoutSession, checkStripeStatus } from './stripe' diff --git a/apps/backend/api/graphql/mutations/stripe.js b/apps/backend/api/graphql/mutations/stripe.js index e8eb565ae1..044a6fec25 100644 --- a/apps/backend/api/graphql/mutations/stripe.js +++ b/apps/backend/api/graphql/mutations/stripe.js @@ -4,7 +4,7 @@ * Provides GraphQL API for Stripe Connect functionality: * - Creating connected accounts for groups * - Generating onboarding links - * - Managing products and prices + * - Managing offerings and prices * - Creating checkout sessions */ @@ -224,14 +224,14 @@ module.exports = { }, /** - * Creates a product on the connected account + * Creates an offering on the connected account * - * Products represent subscription tiers, content access, or other + * Offerings represent subscription tiers, content access, or other * offerings that the group wants to sell. * * Usage: * mutation { - * createStripeProduct( + * createStripeOffering( * groupId: "123" * accountId: "acct_xxx" * name: "Premium Membership" @@ -252,7 +252,7 @@ module.exports = { * } * } */ - createStripeProduct: async (userId, { + createStripeOffering: async (userId, { groupId, accountId, name, @@ -267,13 +267,13 @@ module.exports = { try { // Check if user is authenticated if (!userId) { - throw new GraphQLError('You must be logged in to create a product') + throw new GraphQLError('You must be logged in to create an offering') } // Verify user has permission for this group const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) if (!hasAdmin) { - throw new GraphQLError('You must be a group administrator to create products') + throw new GraphQLError('You must be a group administrator to create offerings') } // Convert database ID to external account ID if needed @@ -288,7 +288,7 @@ module.exports = { currency: currency || 'usd' }) - // Save product to database for tracking and association with content + // Save offering to database for tracking and association with content const stripeProduct = await StripeProduct.create({ group_id: groupId, stripe_product_id: product.id, @@ -309,27 +309,27 @@ module.exports = { name: product.name, databaseId: stripeProduct.id, success: true, - message: 'Product created successfully' + message: 'Offering created successfully' } } catch (error) { if (error instanceof GraphQLError) { throw error } - console.error('Error in createStripeProduct:', error) - throw new GraphQLError(`Failed to create product: ${error.message}`) + console.error('Error in createStripeOffering:', error) + throw new GraphQLError(`Failed to create offering: ${error.message}`) } }, /** - * Updates an existing Stripe product + * Updates an existing Stripe offering * - * Allows group administrators to update product details including name, description, + * Allows group administrators to update offering details including name, description, * price, content access, renewal policy, duration, and publish status. * * Usage: * mutation { - * updateStripeProduct( - * productId: "123" + * updateStripeOffering( + * offeringId: "123" * name: "Updated Premium Membership" * description: "Updated description" * priceInCents: 2500 @@ -346,8 +346,8 @@ module.exports = { * } * } */ - updateStripeProduct: async (userId, { - productId, + updateStripeOffering: async (userId, { + offeringId, name, description, priceInCents, @@ -360,18 +360,18 @@ module.exports = { try { // Check if user is authenticated if (!userId) { - throw new GraphQLError('You must be logged in to update a product') + throw new GraphQLError('You must be logged in to update an offering') } - // Load the product and verify permissions - const product = await StripeProduct.where({ id: productId }).fetch() + // Load the offering and verify permissions + const product = await StripeProduct.where({ id: offeringId }).fetch() if (!product) { - throw new GraphQLError('Product not found') + throw new GraphQLError('Offering not found') } const hasAdmin = await GroupMembership.hasResponsibility(userId, product.get('group_id'), Responsibility.constants.RESP_ADMINISTRATION) if (!hasAdmin) { - throw new GraphQLError('You must be a group administrator to update products') + throw new GraphQLError('You must be a group administrator to update offerings') } // Prepare update attributes (only include provided fields) @@ -443,32 +443,32 @@ module.exports = { } } - // Update the product in our database + // Update the offering in our database await product.save(updateAttrs) return { success: true, - message: 'Product updated successfully' + message: 'Offering updated successfully' } } catch (error) { if (error instanceof GraphQLError) { throw error } - console.error('Error in updateStripeProduct:', error) - throw new GraphQLError(`Failed to update product: ${error.message}`) + console.error('Error in updateStripeOffering:', error) + throw new GraphQLError(`Failed to update offering: ${error.message}`) } }, /** - * Lists all products for a connected account + * Lists all offerings for a connected account * * Usage: * query { - * stripeProducts( + * stripeOfferings( * groupId: "123" * accountId: "acct_xxx" * ) { - * products { + * offerings { * id * name * description @@ -480,17 +480,17 @@ module.exports = { * } * } */ - stripeProducts: async (userId, { groupId, accountId }) => { + stripeOfferings: async (userId, { groupId, accountId }) => { try { // Check if user is authenticated if (!userId) { - throw new GraphQLError('You must be logged in to view products') + throw new GraphQLError('You must be logged in to view offerings') } // Verify user has permission for this group const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) if (!hasAdmin) { - throw new GraphQLError('You must be a group administrator to view products') + throw new GraphQLError('You must be a group administrator to view offerings') } // Convert database ID to external account ID if needed @@ -502,8 +502,8 @@ module.exports = { // Extract products array from Stripe response (which has a 'data' property) const products = productsResponse.data || productsResponse - // Format products for GraphQL response - const formattedProducts = products.map(product => ({ + // Format offerings for GraphQL response + const formattedOfferings = products.map(product => ({ id: product.id, name: product.name, description: product.description, @@ -513,15 +513,15 @@ module.exports = { })) return { - products: formattedProducts, + offerings: formattedOfferings, success: true } } catch (error) { if (error instanceof GraphQLError) { throw error } - console.error('Error in stripeProducts:', error) - throw new GraphQLError(`Failed to retrieve products: ${error.message}`) + console.error('Error in stripeOfferings:', error) + throw new GraphQLError(`Failed to retrieve offerings: ${error.message}`) } }, diff --git a/apps/backend/api/graphql/schema.graphql b/apps/backend/api/graphql/schema.graphql index 6a68f39928..c38a774ccd 100644 --- a/apps/backend/api/graphql/schema.graphql +++ b/apps/backend/api/graphql/schema.graphql @@ -327,11 +327,11 @@ type Query { accountId: String! ): StripeAccountStatusResult - # List all products for a connected Stripe account - stripeProducts( + # List all offerings for a connected Stripe account + stripeOfferings( groupId: ID! accountId: String! - ): StripeProductsResult + ): StripeOfferingsResult # Find a Topic by ID or by name (text) topic(id: ID, name: String): Topic @@ -2660,30 +2660,30 @@ type TrackUser { user: Person } -# Stripe product for paid content access -type StripeProduct { +# Stripe offering for paid content access +type StripeOffering { id: ID createdAt: Date updatedAt: Date - # The group this product belongs to + # The group this offering belongs to group: Group groupId: ID # Stripe product ID from Stripe API stripeProductId: String # Stripe price ID from Stripe API stripePriceId: String - # Product name + # Offering name name: String - # Product description + # Offering description description: String # Price in cents priceInCents: Int # Currency code (e.g. 'usd') currency: String - # Optional track this product grants access to (legacy field) + # Optional track this offering grants access to (legacy field) track: Track trackId: ID - # JSONB object defining what access this product grants + # JSONB object defining what access this offering grants contentAccess: JSON # Renewal policy: automatic or manual renewalPolicy: String @@ -2706,9 +2706,9 @@ type ContentAccess { # Group access is for (optional) group: Group groupId: ID - # Stripe product (if purchased) - product: StripeProduct - productId: ID + # Stripe offering (if purchased) + offering: StripeOffering + offeringId: ID # Track access is for (optional) track: Track trackId: ID @@ -3343,12 +3343,12 @@ type Mutation { refreshUrl: String! ): StripeAccountLinkResult - # Create a product on the connected account - createStripeProduct(input: StripeProductInput!): StripeProductResult + # Create an offering on the connected account + createStripeOffering(input: StripeOfferingInput!): StripeOfferingResult - # Update an existing Stripe product - updateStripeProduct( - productId: ID! + # Update an existing Stripe offering + updateStripeOffering( + offeringId: ID! name: String description: String priceInCents: Int @@ -3357,7 +3357,7 @@ type Mutation { renewalPolicy: String duration: String publishStatus: PublishStatus - ): StripeProductUpdateResult + ): StripeOfferingUpdateResult # Create a checkout session for purchasing a product createStripeCheckoutSession( @@ -3458,7 +3458,7 @@ type StripeStatusCheckResult { detailsSubmitted: Boolean } -type StripeProductResult { +type StripeOfferingResult { productId: String priceId: String name: String @@ -3467,13 +3467,13 @@ type StripeProductResult { message: String } -type StripeProductsResult { - products: [StripeProduct] +type StripeOfferingsResult { + offerings: [StripeOffering] success: Boolean } -type StripeProductUpdateResult { - product: StripeProduct +type StripeOfferingUpdateResult { + offering: StripeOffering success: Boolean message: String } @@ -3833,21 +3833,21 @@ input GroupWidgetSettingsInput { title: String } -# Input for creating Stripe products -input StripeProductInput { - # The group this product belongs to +# Input for creating Stripe offerings +input StripeOfferingInput { + # The group this offering belongs to groupId: ID! # Stripe connected account ID accountId: String! - # Product name + # Offering name name: String! - # Product description + # Offering description description: String # Price in cents priceInCents: Int! # Currency code (e.g. 'usd') currency: String - # JSONB object defining what access this product grants + # JSONB object defining what access this offering grants # Format: { "groupId": { "trackIds": [1, 2], "roleIds": [3, 4] } } contentAccess: JSON # Renewal policy: automatic or manual diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js index 2fab262131..4c62d4a25c 100644 --- a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js @@ -25,9 +25,9 @@ import { createAccountLink, fetchAccountStatus, checkStripeStatus, - createProduct, - fetchProducts - // updateProduct - TODO: Enable when database product IDs are available + createOffering, + fetchOfferings + // updateOffering - TODO: Enable when database offering IDs are available } from './PaidContentTab.store' import { updateGroupSettings, fetchGroupSettings } from '../GroupSettings.store' @@ -45,7 +45,7 @@ function PaidContentTab ({ group, currentUser }) { const [state, setState] = useState({ accountId: initialAccountId, accountStatus: null, - products: [], + offerings: [], loading: false, error: null }) @@ -228,38 +228,38 @@ function PaidContentTab ({ group, currentUser }) { } }, [dispatch, group, handleStartOnboarding, loadAccountStatus]) - // Load account status if we have an account ID - useEffect(() => { - if (state.accountId && group?.id) { - loadAccountStatus() - loadProducts() - } - }, [state.accountId, group?.id]) - /** - * Loads all products for this connected account + * Loads all offerings for this connected account */ - const loadProducts = useCallback(async () => { + const loadOfferings = useCallback(async () => { if (!group?.id || !state.accountId) return try { - const result = await dispatch(fetchProducts(group.id, state.accountId)) + const result = await dispatch(fetchOfferings(group.id, state.accountId)) if (result.error) { throw new Error(result.error.message) } - const products = result.payload?.data?.stripeProducts?.products || [] + const offerings = result.payload?.data?.stripeOfferings?.offerings || [] setState(prev => ({ ...prev, - products + offerings })) } catch (error) { - console.error('Error loading products:', error) + console.error('Error loading offerings:', error) } }, [dispatch, group, state.accountId]) + // Load account status if we have an account ID + useEffect(() => { + if (state.accountId && group?.id) { + loadAccountStatus() + loadOfferings() + } + }, [state.accountId, group?.id, loadAccountStatus, loadOfferings]) + /** * Checks Stripe status and updates the database */ @@ -305,7 +305,7 @@ function PaidContentTab ({ group, currentUser }) { if (!group) return - const { accountId, products, loading, error } = state + const { accountId, offerings, loading, error } = state return (
@@ -345,11 +345,11 @@ function PaidContentTab ({ group, currentUser }) { t={t} /> - )} @@ -644,14 +644,14 @@ function StatusBadge ({ label, value, t }) { } /** - * Section for managing products + * Section for managing offerings * - * Allows creating and viewing products that customers can purchase. + * Allows creating and viewing offerings that customers can purchase. */ -function ProductManagementSection ({ group, accountId, products, onRefreshProducts, t }) { +function OfferingManagementSection ({ group, accountId, offerings, onRefreshOfferings, t }) { const dispatch = useDispatch() const [showCreateForm, setShowCreateForm] = useState(false) - const [editingProduct, setEditingProduct] = useState(null) + const [editingOffering, setEditingOffering] = useState(null) const [formData, setFormData] = useState({ name: '', description: '', @@ -662,9 +662,9 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc const [updating, setUpdating] = useState(false) /** - * Handles product creation + * Handles offering creation */ - const handleCreateProduct = useCallback(async (e) => { + const handleCreateOffering = useCallback(async (e) => { e.preventDefault() if (!formData.name || !formData.price) { @@ -681,7 +681,7 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc throw new Error(t('Invalid price')) } - const result = await dispatch(createProduct( + const result = await dispatch(createOffering( group.id, accountId, formData.name, @@ -694,25 +694,25 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc throw new Error(result.error.message) } - // Reset form and refresh products + // Reset form and refresh offerings setFormData({ name: '', description: '', price: '', currency: 'usd' }) setShowCreateForm(false) - onRefreshProducts() + onRefreshOfferings() } catch (error) { - console.error('Error creating product:', error) - alert(t('Failed to create product: {{error}}', { error: error.message })) + console.error('Error creating offering:', error) + alert(t('Failed to create offering: {{error}}', { error: error.message })) } finally { setCreating(false) } - }, [dispatch, group, accountId, formData, onRefreshProducts, t]) + }, [dispatch, group, accountId, formData, onRefreshOfferings, t]) /** - * Handles product updates + * Handles offering updates */ - const handleUpdateProduct = useCallback(async (e) => { + const handleUpdateOffering = useCallback(async (e) => { e.preventDefault() - if (!editingProduct || !formData.name) { + if (!editingOffering || !formData.name) { alert(t('Please fill in all required fields')) return } @@ -721,45 +721,45 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc try { const updates = {} - if (formData.name !== editingProduct.name) updates.name = formData.name - if (formData.description !== editingProduct.description) updates.description = formData.description + if (formData.name !== editingOffering.name) updates.name = formData.name + if (formData.description !== editingOffering.description) updates.description = formData.description // Note: We can't update price easily since it requires creating a new price in Stripe // For now, we'll only allow updating name and description if (Object.keys(updates).length === 0) { - setEditingProduct(null) + setEditingOffering(null) setUpdating(false) return } - // Note: updateProduct requires the database product ID, not the Stripe product ID - // For now, we'll show a message that this feature needs database product IDs - // In production, you'd need to fetch/store the database product ID - alert(t('Product updates require database product IDs. This feature is coming soon.')) + // Note: updateOffering requires the database offering ID, not the Stripe product ID + // For now, we'll show a message that this feature needs database offering IDs + // In production, you'd need to fetch/store the database offering ID + alert(t('Offering updates require database offering IDs. This feature is coming soon.')) - // TODO: Implement full update when database product IDs are available - // const result = await dispatch(updateProduct(editingProduct.databaseId, updates)) + // TODO STRIPE: Implement full update when database offering IDs are available + // const result = await dispatch(updateOffering(editingOffering.databaseId, updates)) // if (result.error) { // throw new Error(result.error.message) // } - setEditingProduct(null) + setEditingOffering(null) setFormData({ name: '', description: '', price: '', currency: 'usd' }) - onRefreshProducts() + onRefreshOfferings() } catch (error) { - console.error('Error updating product:', error) - alert(t('Failed to update product: {{error}}', { error: error.message })) + console.error('Error updating offering:', error) + alert(t('Failed to update offering: {{error}}', { error: error.message })) } finally { setUpdating(false) } - }, [dispatch, editingProduct, formData, onRefreshProducts, t]) + }, [dispatch, editingOffering, formData, onRefreshOfferings, t]) - const handleStartEdit = useCallback((product) => { - setEditingProduct(product) + const handleStartEdit = useCallback((offering) => { + setEditingOffering(offering) setFormData({ - name: product.name || '', - description: product.description || '', + name: offering.name || '', + description: offering.description || '', price: '', // Price editing would be complex, skipping for now currency: 'usd' }) @@ -767,7 +767,7 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc }, []) const handleCancelEdit = useCallback(() => { - setEditingProduct(null) + setEditingOffering(null) setFormData({ name: '', description: '', price: '', currency: 'usd' }) }, []) @@ -775,9 +775,9 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc
-

{t('Products & Pricing')}

+

{t('Offerings & Pricing')}

- {t('Create products for memberships, tracks, or content access')} + {t('Create offerings for memberships, tracks, or content access')}

- {(showCreateForm || editingProduct) && ( -
+ {(showCreateForm || editingOffering) && ( +

- {editingProduct ? t('Edit Product') : t('Create New Product')} + {editingOffering ? t('Edit Offering') : t('Create New Offering')}

setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder={t('e.g., Premium Membership')} @@ -846,7 +846,7 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc @@ -854,27 +854,27 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc type='submit' disabled={creating || updating} > - {creating ? t('Creating...') : updating ? t('Updating...') : editingProduct ? t('Update Product') : t('Create Product')} + {creating ? t('Creating...') : updating ? t('Updating...') : editingOffering ? t('Update Offering') : t('Create Offering')}
)} - {products.length === 0 + {offerings.length === 0 ? (
-

{t('No products yet')}

-

{t('Create your first product to start accepting payments')}

+

{t('No offerings yet')}

+

{t('Create your first offering to start accepting payments')}

) : (
- {products.map(product => ( - ( + )} - {products.length > 0 && ( + {offerings.length > 0 && (

{t('Storefront Link:')}{' '} @@ -906,21 +906,21 @@ function ProductManagementSection ({ group, accountId, products, onRefreshProduc } /** - * Card displaying a single product + * Card displaying a single offering */ -function ProductCard ({ product, groupSlug, onEdit, t }) { +function OfferingCard ({ offering, groupSlug, onEdit, t }) { return (

-

{product.name}

- {product.description && ( -

{product.description}

+

{offering.name}

+ {offering.description && ( +

{offering.description}

)}
-

{t('ID')}: {product.id}

- {product.active +

{t('ID')}: {offering.id}

+ {offering.active ? ({t('Active')}) : ({t('Inactive')})}
@@ -928,7 +928,7 @@ function ProductCard ({ product, groupSlug, onEdit, t }) { diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js index 49f7cc3e13..445df84511 100644 --- a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js @@ -13,9 +13,9 @@ export const CREATE_CONNECTED_ACCOUNT = `${MODULE_NAME}/CREATE_CONNECTED_ACCOUNT export const CREATE_ACCOUNT_LINK = `${MODULE_NAME}/CREATE_ACCOUNT_LINK` export const FETCH_ACCOUNT_STATUS = `${MODULE_NAME}/FETCH_ACCOUNT_STATUS` export const CHECK_STRIPE_STATUS = `${MODULE_NAME}/CHECK_STRIPE_STATUS` -export const CREATE_PRODUCT = `${MODULE_NAME}/CREATE_PRODUCT` -export const UPDATE_PRODUCT = `${MODULE_NAME}/UPDATE_PRODUCT` -export const FETCH_PRODUCTS = `${MODULE_NAME}/FETCH_PRODUCTS` +export const CREATE_OFFERING = `${MODULE_NAME}/CREATE_OFFERING` +export const UPDATE_OFFERING = `${MODULE_NAME}/UPDATE_OFFERING` +export const FETCH_OFFERINGS = `${MODULE_NAME}/FETCH_OFFERINGS` /** * Creates a Stripe Connected Account for the group @@ -116,17 +116,17 @@ export function fetchAccountStatus (groupId, accountId) { } /** - * Creates a product on the connected account + * Creates an offering on the connected account * - * Products represent subscription tiers, content access, or other offerings + * Offerings represent subscription tiers, content access, or other offerings * that the group wants to sell. */ -export function createProduct (groupId, accountId, name, description, priceInCents, currency = 'usd') { +export function createOffering (groupId, accountId, name, description, priceInCents, currency = 'usd') { return { - type: CREATE_PRODUCT, + type: CREATE_OFFERING, graphql: { query: `mutation ($groupId: ID!, $accountId: String!, $name: String!, $description: String, $priceInCents: Int!, $currency: String) { - createStripeProduct( + createStripeOffering( groupId: $groupId accountId: $accountId name: $name @@ -180,20 +180,20 @@ export function checkStripeStatus (groupId) { } /** - * Fetches all products for a connected account + * Fetches all offerings for a connected account * - * Lists all active products that the group has created for sale. + * Lists all active offerings that the group has created for sale. */ -export function fetchProducts (groupId, accountId) { +export function fetchOfferings (groupId, accountId) { return { - type: FETCH_PRODUCTS, + type: FETCH_OFFERINGS, graphql: { query: `query ($groupId: ID!, $accountId: String!) { - stripeProducts( + stripeOfferings( groupId: $groupId accountId: $accountId ) { - products { + offerings { id name description @@ -213,19 +213,19 @@ export function fetchProducts (groupId, accountId) { } /** - * Updates an existing Stripe product + * Updates an existing Stripe offering * - * Allows updating product details including name, description, price, etc. + * Allows updating offering details including name, description, price, etc. */ -export function updateProduct (productId, updates) { +export function updateOffering (offeringId, updates) { const { name, description, priceInCents, currency, contentAccess, renewalPolicy, duration, publishStatus } = updates || {} return { - type: UPDATE_PRODUCT, + type: UPDATE_OFFERING, graphql: { - query: `mutation ($productId: ID!, $name: String, $description: String, $priceInCents: Int, $currency: String, $contentAccess: JSON, $renewalPolicy: String, $duration: String, $publishStatus: PublishStatus) { - updateStripeProduct( - productId: $productId + query: `mutation ($offeringId: ID!, $name: String, $description: String, $priceInCents: Int, $currency: String, $contentAccess: JSON, $renewalPolicy: String, $duration: String, $publishStatus: PublishStatus) { + updateStripeOffering( + offeringId: $offeringId name: $name description: $description priceInCents: $priceInCents @@ -240,7 +240,7 @@ export function updateProduct (productId, updates) { } }`, variables: { - productId, + offeringId, name, description, priceInCents, @@ -262,10 +262,10 @@ export function getAccountStatus (state) { } /** - * Selector to get products from state + * Selector to get offerings from state */ -export function getProducts (state) { - return get('PaidContentTab.products', state) || [] +export function getOfferings (state) { + return get('PaidContentTab.offerings', state) || [] } /** @@ -283,10 +283,10 @@ export default function reducer (state = {}, action) { accountStatus: payload?.data?.stripeAccountStatus } - case FETCH_PRODUCTS: + case FETCH_OFFERINGS: return { ...state, - products: payload?.data?.stripeProducts?.products || [] + offerings: payload?.data?.stripeOfferings?.offerings || [] } default: diff --git a/apps/web/src/routes/GroupStore/GroupStore.js b/apps/web/src/routes/GroupStore/GroupStore.js index f829156635..d087a75014 100644 --- a/apps/web/src/routes/GroupStore/GroupStore.js +++ b/apps/web/src/routes/GroupStore/GroupStore.js @@ -1,10 +1,10 @@ /** * GroupStore Component * - * Public-facing storefront that displays products available for purchase + * Public-facing storefront that displays offerings available for purchase * from a group's Stripe Connected Account. * - * Customers can browse products and initiate checkout sessions. + * Customers can browse offerings and initiate checkout sessions. * * URL: /groups/:groupSlug/store * @@ -27,7 +27,7 @@ import getGroupForSlug from 'store/selectors/getGroupForSlug' /** * Main GroupStore component * - * Displays all products for a group and allows customers to purchase them + * Displays all offerings for a group and allows customers to purchase them */ function GroupStore () { const { t } = useTranslation() @@ -37,11 +37,10 @@ function GroupStore () { // Get group from Redux store const group = useSelector(state => getGroupForSlug(state, groupSlug)) - // Local state for products and checkout + // Local state for offerings and checkout const [state, setState] = useState({ - // TODO STRIPE: Load accountId from group.stripe_account_id in your database - accountId: '', // PLACEHOLDER: Replace with actual account ID from database - products: [], + accountId: group?.stripeAccountId || '', + offerings: [], loading: true, error: null, checkoutLoading: false @@ -60,11 +59,11 @@ function GroupStore () { }, [group, t]) /** - * Loads products from the backend + * Loads offerings from the backend * - * This makes a GraphQL query to fetch products from the connected account + * This makes a GraphQL query to fetch offerings from the connected account */ - const loadProducts = useCallback(async () => { + const loadOfferings = useCallback(async () => { if (!group?.id || !state.accountId) { setState(prev => ({ ...prev, @@ -77,14 +76,14 @@ function GroupStore () { setState(prev => ({ ...prev, loading: true, error: null })) try { - // Make GraphQL query to fetch products + // Make GraphQL query to fetch offerings const query = ` query ($groupId: ID!, $accountId: String!) { - stripeProducts( + stripeOfferings( groupId: $groupId accountId: $accountId ) { - products { + offerings { id name description @@ -118,15 +117,15 @@ function GroupStore () { throw new Error(result.errors[0].message) } - const products = result.data?.stripeProducts?.products || [] + const offerings = result.data?.stripeOfferings?.offerings || [] setState(prev => ({ ...prev, - products, + offerings, loading: false })) } catch (error) { - console.error('Error loading products:', error) + console.error('Error loading offerings:', error) setState(prev => ({ ...prev, loading: false, @@ -137,17 +136,17 @@ function GroupStore () { useEffect(() => { if (state.accountId) { - loadProducts() + loadOfferings() } - }, [state.accountId]) + }, [state.accountId, loadOfferings]) /** - * Initiates a checkout session for a product + * Initiates a checkout session for an offering * * Creates a Stripe Checkout session and redirects the customer * to the hosted checkout page. */ - const handlePurchase = useCallback(async (product) => { + const handlePurchase = useCallback(async (offering) => { if (!group?.id || !state.accountId) return setState(prev => ({ ...prev, checkoutLoading: true })) @@ -187,12 +186,12 @@ function GroupStore () { variables: { groupId: group.id, accountId: state.accountId, - priceId: product.defaultPriceId, + priceId: offering.defaultPriceId, quantity: 1, successUrl, cancelUrl, metadata: { - productId: product.id, + offeringId: offering.id, groupSlug: groupSlug } } @@ -255,10 +254,10 @@ function GroupStore () { ) } - if (state.products.length === 0) { + if (state.offerings.length === 0) { return (
- +
) } @@ -270,16 +269,16 @@ function GroupStore () { {group.name} {t('Store')}

- {t('Browse and purchase products from this group')} + {t('Browse and purchase offerings from this group')}

- {state.products.map(product => ( - handlePurchase(product)} + {state.offerings.map(offering => ( + handlePurchase(offering)} loading={state.checkoutLoading} t={t} /> @@ -308,15 +307,15 @@ function NoStoreSetup ({ group, t }) { } /** - * Displayed when there are no products available + * Displayed when there are no offerings available */ -function NoProductsAvailable ({ group, t }) { +function NoOfferingsAvailable ({ group, t }) { return (
-

{t('No Products Available')}

+

{t('No Offerings Available')}

- {t('This group doesn\'t have any products for sale yet.')} + {t('This group doesn\'t have any offerings for sale yet.')}

{t('Check back later for new offerings.')} @@ -326,17 +325,17 @@ function NoProductsAvailable ({ group, t }) { } /** - * Card displaying a single product + * Card displaying a single offering */ -function ProductCard ({ product, onPurchase, loading, t }) { +function OfferingCard ({ offering, onPurchase, loading, t }) { return (

- {/* Product Image */} - {product.images && product.images.length > 0 ? ( + {/* Offering Image */} + {offering.images && offering.images.length > 0 ? (
{product.name}
@@ -346,25 +345,25 @@ function ProductCard ({ product, onPurchase, loading, t }) {
)} - {/* Product Info */} + {/* Offering Info */}

- {product.name} + {offering.name}

- {product.description && ( + {offering.description && (

- {product.description} + {offering.description}

)} {/* Purchase Button */}
-
- + - + - + + t={t} + />
{group?.stripeDashboardUrl && ( @@ -863,25 +863,25 @@ function OfferingManagementSection ({ group, accountId, offerings, onRefreshOffe {offerings.length === 0 ? ( -
- +
+

{t('No offerings yet')}

{t('Create your first offering to start accepting payments')}

-
+
) : ( -
+
{offerings.map(offering => ( - ))} -
- )} + t={t} + /> + ))} +
+ )} {offerings.length > 0 && (
@@ -893,7 +893,7 @@ function OfferingManagementSection ({ group, accountId, offerings, onRefreshOffe rel='noopener noreferrer' className='text-blue-600 hover:underline' > - {window.location.origin}/groups/{group.slug}/store + {getHost()}/groups/{group.slug}/store

@@ -918,7 +918,7 @@ function OfferingCard ({ offering, groupSlug, onEdit, t }) { )}

-
+

{t('ID')}: {offering.id}

{offering.active ? ({t('Active')}) From 9968bb8383389802e12b17e477a7ec0819b17478 Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 5 Nov 2025 13:27:51 +1030 Subject: [PATCH 15/76] Merge conflict syntax errors missed in the conflict resolution --- apps/backend/api/graphql/schema.graphql | 5 ++-- .../PaidContentTab/PaidContentTab.js | 26 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/backend/api/graphql/schema.graphql b/apps/backend/api/graphql/schema.graphql index c38a774ccd..33f82a2ae6 100644 --- a/apps/backend/api/graphql/schema.graphql +++ b/apps/backend/api/graphql/schema.graphql @@ -7,13 +7,13 @@ enum LocationDisplayPrecision { region } -<<<<<<< HEAD enum PublishStatus { unpublished unlisted published archived -======= +} + enum FundingRoundPhase { draft published @@ -21,7 +21,6 @@ enum FundingRoundPhase { discussion voting completed ->>>>>>> dev } type Query { diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js index e254a9ca1d..ca5d48e206 100644 --- a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js @@ -563,22 +563,22 @@ function StripeStatusSection ({ group, loading, onCheckStatus, onStartOnboarding
-
- + - + - + + t={t} + />
{group?.stripeDashboardUrl && ( From 983b839f3c67432fefbda34d523d82f3640844c6 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 6 Nov 2025 09:57:05 +1030 Subject: [PATCH 16/76] Expand paid content sub-tabs in the group settings --- .../src/routes/GroupSettings/GroupSettings.js | 2 +- .../PaidContentTab/PaidContentTab.js | 684 ++++++++++-------- 2 files changed, 379 insertions(+), 307 deletions(-) diff --git a/apps/web/src/routes/GroupSettings/GroupSettings.js b/apps/web/src/routes/GroupSettings/GroupSettings.js index 53bdc7c8ea..5efb0e0b5d 100644 --- a/apps/web/src/routes/GroupSettings/GroupSettings.js +++ b/apps/web/src/routes/GroupSettings/GroupSettings.js @@ -185,7 +185,7 @@ export default function GroupSettings () { const paidContentSettings = { name: t('Paid Content'), - path: 'paid-content', + path: 'paid-content/*', component: } diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js index ca5d48e206..f17a08f0d4 100644 --- a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js @@ -12,7 +12,8 @@ import React, { useCallback, useEffect, useState } from 'react' import { useDispatch } from 'react-redux' import { useTranslation } from 'react-i18next' -import { CreditCard, CheckCircle, AlertCircle, ExternalLink, PlusCircle } from 'lucide-react' +import { Link, Routes, Route, useLocation } from 'react-router-dom' +import { CreditCard, CheckCircle, AlertCircle, ExternalLink, PlusCircle, Edit } from 'lucide-react' import Button from 'components/ui/button' import Loading from 'components/Loading' @@ -39,6 +40,7 @@ import { getHost } from 'store/middleware/apiMiddleware' function PaidContentTab ({ group, currentUser }) { const { t } = useTranslation() const dispatch = useDispatch() + const location = useLocation() // Local state for managing Stripe account and products // Note: accountId comes from camelCase GraphQL field @@ -292,7 +294,7 @@ function PaidContentTab ({ group, currentUser }) { setState(prev => ({ ...prev, loading: false, error: null })) // Reload live account status to reflect the updates - loadAccountStatus() + loadAccountStatus() } catch (error) { console.error('Error checking Stripe status:', error) setState(prev => ({ @@ -303,12 +305,40 @@ function PaidContentTab ({ group, currentUser }) { } }, [dispatch, group, loadAccountStatus]) + // Extract nested tab from pathname + // URL structure: /groups/:groupSlug/settings/paid-content/:subTab + const pathParts = location.pathname.split('/') + const paidContentIndex = pathParts.indexOf('paid-content') + const subTab = paidContentIndex > -1 && pathParts[paidContentIndex + 1] ? pathParts[paidContentIndex + 1] : null + const currentTab = subTab || 'account' + if (!group) return const { accountId, offerings, loading, error } = state return (
+
+ + {t('Account')} + + + {t('Offerings')} + + + {t('Content Access')} + +
+

{t('Accept payments for your group')}

{t('Set up Stripe Connect to accept payments for group memberships, track content, and other offerings. Stripe handles all payment processing securely.')} @@ -324,40 +354,336 @@ function PaidContentTab ({ group, currentUser }) {

)} - - {!accountId - ? ( - ) - : ( - <> - + + } /> + } /> + + } + /> + +
+
+ ) +} - - )} - +/** + * Account Tab Component + * + * Main tab showing account setup and status + */ +function AccountTab ({ group, currentUser, accountId, loading, onCreateAccount, onConnectAccount, onCheckStatus, onStartOnboarding }) { + const { t } = useTranslation() + + return ( + <> + {!accountId + ? ( + ) + : ( + )} + + ) +} + +/** + * Offerings Tab Component + * + * Displays a list of offerings with details and management options + */ +function OfferingsTab ({ group, accountId, offerings, onRefreshOfferings }) { + const { t } = useTranslation() + const dispatch = useDispatch() + const [showCreateForm, setShowCreateForm] = useState(false) + const [editingOffering, setEditingOffering] = useState(null) + const [formData, setFormData] = useState({ + name: '', + description: '', + price: '', + currency: 'usd' + }) + const [creating, setCreating] = useState(false) + const [updating, setUpdating] = useState(false) + + const handleCreateOffering = useCallback(async (e) => { + e.preventDefault() + + if (!formData.name || !formData.price) { + alert(t('Please fill in all required fields')) + return + } + + setCreating(true) + + try { + const priceInCents = Math.round(parseFloat(formData.price) * 100) + + if (isNaN(priceInCents) || priceInCents < 0) { + throw new Error(t('Invalid price')) + } + + const result = await dispatch(createOffering( + group.id, + accountId, + formData.name, + formData.description, + priceInCents, + formData.currency + )) + + if (result.error) { + throw new Error(result.error.message) + } + + // Reset form and refresh offerings + setFormData({ name: '', description: '', price: '', currency: 'usd' }) + setShowCreateForm(false) + onRefreshOfferings() + } catch (error) { + console.error('Error creating offering:', error) + alert(t('Failed to create offering: {{error}}', { error: error.message })) + } finally { + setCreating(false) + } + }, [dispatch, group, accountId, formData, onRefreshOfferings, t]) + + const handleUpdateOffering = useCallback(async (e) => { + e.preventDefault() + + if (!editingOffering || !formData.name) { + alert(t('Please fill in all required fields')) + return + } + + setUpdating(true) + + try { + const updates = {} + if (formData.name !== editingOffering.name) updates.name = formData.name + if (formData.description !== editingOffering.description) updates.description = formData.description + + if (Object.keys(updates).length === 0) { + setEditingOffering(null) + setUpdating(false) + return + } + + alert(t('Offering updates require database offering IDs. This feature is coming soon.')) + + setEditingOffering(null) + setFormData({ name: '', description: '', price: '', currency: 'usd' }) + onRefreshOfferings() + } catch (error) { + console.error('Error updating offering:', error) + alert(t('Failed to update offering: {{error}}', { error: error.message })) + } finally { + setUpdating(false) + } + }, [dispatch, editingOffering, formData, onRefreshOfferings, t]) + + const handleStartEdit = useCallback((offering) => { + setEditingOffering(offering) + setFormData({ + name: offering.name || '', + description: offering.description || '', + price: '', + currency: 'usd' + }) + setShowCreateForm(false) + }, []) + + const handleCancelEdit = useCallback(() => { + setEditingOffering(null) + setFormData({ name: '', description: '', price: '', currency: 'usd' }) + setShowCreateForm(false) + }, []) + + return ( +
+ + + {showCreateForm && ( +
+

{t('Create New Offering')}

+
+ setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder={t('e.g., Premium Membership')} + required + /> + setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder={t('What does this offering include?')} + /> +
+
+ setFormData(prev => ({ ...prev, price: e.target.value }))} + placeholder='20.00' + required + /> +
+
+ setFormData(prev => ({ ...prev, currency: e.target.value }))} + renderControl={(props) => ( + + )} + /> +
+
+
+ + +
+ +
+ )} + + {editingOffering && ( +
+

{t('Edit Offering')}

+
+ setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder={t('e.g., Premium Membership')} + required + /> + setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder={t('What does this offering include?')} + /> +
+ + +
+ +
+ )} + +
+

{t('Offerings')}

+
+ {offerings.length === 0 + ? ( +
+ +

{t('No offerings yet')}

+

{t('Create your first offering to start accepting payments')}

+
+ ) + : ( + offerings.map(offering => ( + + )) + )} +
+
) } +/** + * Content Access Tab Component + * + * Placeholder component for managing content access + */ +function ContentAccessTab ({ group }) { + const { t } = useTranslation() + + return ( + +
+

{t('Content Access')}

+

+ {t('Manage content access settings for your group offerings.')} +

+
+
+ ) +} + /** * Section for initial account setup * @@ -419,7 +745,7 @@ function AccountSetupSection ({ loading, onCreateAccount, onConnectAccount, grou }, [formData, isConnectingExisting, onCreateAccount, onConnectAccount, group, t]) return ( - + <>

{t('Get started with payments')}

@@ -517,7 +843,7 @@ function AccountSetupSection ({ loading, onCreateAccount, onConnectAccount, grou

  • {t('Stripe handles all payment disputes and fraud')}
  • -
    + ) } @@ -585,13 +911,13 @@ function StripeStatusSection ({ group, loading, onCheckStatus, onStartOnboarding )} @@ -644,292 +970,38 @@ function StatusBadge ({ label, value, t }) { } /** - * Section for managing offerings - * - * Allows creating and viewing offerings that customers can purchase. + * List item displaying a single offering with details + * Used in the OfferingsTab list view */ -function OfferingManagementSection ({ group, accountId, offerings, onRefreshOfferings, t }) { - const dispatch = useDispatch() - const [showCreateForm, setShowCreateForm] = useState(false) - const [editingOffering, setEditingOffering] = useState(null) - const [formData, setFormData] = useState({ - name: '', - description: '', - price: '', - currency: 'usd' - }) - const [creating, setCreating] = useState(false) - const [updating, setUpdating] = useState(false) - - /** - * Handles offering creation - */ - const handleCreateOffering = useCallback(async (e) => { - e.preventDefault() - - if (!formData.name || !formData.price) { - alert(t('Please fill in all required fields')) - return - } - - setCreating(true) - - try { - const priceInCents = Math.round(parseFloat(formData.price) * 100) - - if (isNaN(priceInCents) || priceInCents < 0) { - throw new Error(t('Invalid price')) - } - - const result = await dispatch(createOffering( - group.id, - accountId, - formData.name, - formData.description, - priceInCents, - formData.currency - )) - - if (result.error) { - throw new Error(result.error.message) - } - - // Reset form and refresh offerings - setFormData({ name: '', description: '', price: '', currency: 'usd' }) - setShowCreateForm(false) - onRefreshOfferings() - } catch (error) { - console.error('Error creating offering:', error) - alert(t('Failed to create offering: {{error}}', { error: error.message })) - } finally { - setCreating(false) - } - }, [dispatch, group, accountId, formData, onRefreshOfferings, t]) - - /** - * Handles offering updates - */ - const handleUpdateOffering = useCallback(async (e) => { - e.preventDefault() - - if (!editingOffering || !formData.name) { - alert(t('Please fill in all required fields')) - return - } - - setUpdating(true) - - try { - const updates = {} - if (formData.name !== editingOffering.name) updates.name = formData.name - if (formData.description !== editingOffering.description) updates.description = formData.description - - // Note: We can't update price easily since it requires creating a new price in Stripe - // For now, we'll only allow updating name and description - - if (Object.keys(updates).length === 0) { - setEditingOffering(null) - setUpdating(false) - return - } - - // Note: updateOffering requires the database offering ID, not the Stripe product ID - // For now, we'll show a message that this feature needs database offering IDs - // In production, you'd need to fetch/store the database offering ID - alert(t('Offering updates require database offering IDs. This feature is coming soon.')) - - // TODO STRIPE: Implement full update when database offering IDs are available - // const result = await dispatch(updateOffering(editingOffering.databaseId, updates)) - // if (result.error) { - // throw new Error(result.error.message) - // } - - setEditingOffering(null) - setFormData({ name: '', description: '', price: '', currency: 'usd' }) - onRefreshOfferings() - } catch (error) { - console.error('Error updating offering:', error) - alert(t('Failed to update offering: {{error}}', { error: error.message })) - } finally { - setUpdating(false) - } - }, [dispatch, editingOffering, formData, onRefreshOfferings, t]) - - const handleStartEdit = useCallback((offering) => { - setEditingOffering(offering) - setFormData({ - name: offering.name || '', - description: offering.description || '', - price: '', // Price editing would be complex, skipping for now - currency: 'usd' - }) - setShowCreateForm(false) - }, []) - - const handleCancelEdit = useCallback(() => { - setEditingOffering(null) - setFormData({ name: '', description: '', price: '', currency: 'usd' }) - }, []) - +function OfferingListItem ({ offering, onEdit, t }) { return ( -
    -
    -
    -

    {t('Offerings & Pricing')}

    -

    - {t('Create offerings for memberships, tracks, or content access')} -

    -
    - -
    - - {(showCreateForm || editingOffering) && ( -
    -

    - {editingOffering ? t('Edit Offering') : t('Create New Offering')} -

    - -
    - setFormData(prev => ({ ...prev, name: e.target.value }))} - placeholder={t('e.g., Premium Membership')} - required - /> - - setFormData(prev => ({ ...prev, description: e.target.value }))} - placeholder={t('What does this product include?')} - /> - -
    -
    - setFormData(prev => ({ ...prev, price: e.target.value }))} - placeholder='20.00' - required - /> -
    -
    - setFormData(prev => ({ ...prev, currency: e.target.value }))} - renderControl={(props) => ( - - )} - /> -
    -
    - -
    - - -
    -
    -
    - )} - - {offerings.length === 0 - ? ( -
    - -

    {t('No offerings yet')}

    -

    {t('Create your first offering to start accepting payments')}

    -
    - ) - : ( -
    - {offerings.map(offering => ( - - ))} -
    - )} - - {offerings.length > 0 && ( -
    -

    - {t('Storefront Link:')}{' '} - - {getHost()}/groups/{group.slug}/store - -

    -

    - {t('Share this link with your members so they can view and purchase products')} -

    -
    - )} -
    - ) -} - -/** - * Card displaying a single offering - */ -function OfferingCard ({ offering, groupSlug, onEdit, t }) { - return ( -
    +
    +
    -

    {offering.name}

    - {offering.description && ( -

    {offering.description}

    +
    +

    {offering.name}

    + {offering.active + ? ({t('Active')}) + : ({t('Inactive')})} +
    + {offering.description && ( +

    {offering.description}

    + )} +
    + {t('Stripe ID')}: {offering.id} + {offering.defaultPriceId && ( + {t('Price ID')}: {offering.defaultPriceId} )}
    -
    -
    -

    {t('ID')}: {offering.id}

    - {offering.active - ? ({t('Active')}) - : ({t('Inactive')})}
    {onEdit && ( )} From 7b3de5497521043c9ad459890345e1a1146b2551 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 6 Nov 2025 11:21:40 +1030 Subject: [PATCH 17/76] Clean up stripe account connection --- .../api/controllers/StripeController.js | 18 ++- apps/backend/api/graphql/mutations/stripe.js | 47 +++--- apps/backend/api/graphql/schema.graphql | 1 - apps/backend/api/services/StripeService.js | 42 ++++- .../PaidContentTab/PaidContentTab.js | 144 +++++++----------- .../PaidContentTab/PaidContentTab.store.js | 11 +- 6 files changed, 135 insertions(+), 128 deletions(-) diff --git a/apps/backend/api/controllers/StripeController.js b/apps/backend/api/controllers/StripeController.js index 107c65d11e..274d3c88c7 100644 --- a/apps/backend/api/controllers/StripeController.js +++ b/apps/backend/api/controllers/StripeController.js @@ -11,9 +11,11 @@ const StripeService = require('../services/StripeService') const Stripe = require('stripe') const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { - apiVersion: '2025-09-30.clover' + apiVersion: '2025-10-29.clover' }) +/* global StripeAccount */ + module.exports = { /** @@ -185,9 +187,21 @@ module.exports = { return } + // Find or create the StripeAccount record for this external account ID + let stripeAccountRecord = await StripeAccount.where({ + stripe_account_external_id: account.id + }).fetch() + + if (!stripeAccountRecord) { + stripeAccountRecord = await StripeAccount.forge({ + stripe_account_external_id: account.id + }).save() + } + // Update group's Stripe status + // Note: stripe_account_id should be the database ID, not the external account ID await group.save({ - stripe_account_id: account.id, // Store account ID if not already stored + stripe_account_id: stripeAccountRecord.id, // Store database ID, not external account ID stripe_charges_enabled: account.charges_enabled, stripe_payouts_enabled: account.payouts_enabled, stripe_details_submitted: account.details_submitted diff --git a/apps/backend/api/graphql/mutations/stripe.js b/apps/backend/api/graphql/mutations/stripe.js index 044a6fec25..34731d05e3 100644 --- a/apps/backend/api/graphql/mutations/stripe.js +++ b/apps/backend/api/graphql/mutations/stripe.js @@ -36,7 +36,7 @@ module.exports = { /** * Creates a Stripe Connected Account for a group */ - createStripeConnectedAccount: async (userId, { groupId, email, businessName, country, existingAccountId }) => { + createStripeConnectedAccount: async (userId, { groupId, email, businessName, country }) => { try { // Check if user is authenticated if (!userId) { @@ -56,32 +56,27 @@ module.exports = { } // Check if group already has a Stripe account - if (group.get('stripe_account_id')) { - throw new GraphQLError('This group already has a Stripe account connected') - } - - let account - - if (existingAccountId) { - // Validate the Stripe account ID format - if (!existingAccountId.startsWith('acct_')) { - throw new GraphQLError('Invalid Stripe account ID provided') + // If stripe_account_id exists, verify the database record still exists + // (in case DB was cleared but group still has the reference) + const existingStripeAccountId = group.get('stripe_account_id') + if (existingStripeAccountId) { + const existingStripeAccount = await StripeAccount.where({ id: existingStripeAccountId }).fetch() + if (existingStripeAccount) { + throw new GraphQLError('This group already has a Stripe account connected') } - - // Connect existing Stripe account - account = await StripeService.connectExistingAccount({ - accountId: existingAccountId, - groupId - }) - } else { - // Create new Stripe account - account = await StripeService.createConnectedAccount({ - email: email || `${group.get('name')}@hylo.com`, - country: country || 'US', - businessName: businessName || group.get('name'), - groupId - }) - } + // If the database record doesn't exist, we can proceed to reconnect + // This handles the edge case where DB was cleared but Stripe account still exists + } + + // Create new Stripe account + // Note: Stripe will handle the case where the user already has an account + // by prompting them to connect it during onboarding + const account = await StripeService.createConnectedAccount({ + email: email || `${group.get('name')}@hylo.com`, + country: country || 'US', + businessName: businessName || group.get('name'), + groupId + }) // Find or create a StripeAccount record with this external ID let stripeAccountRecord = await StripeAccount.where({ diff --git a/apps/backend/api/graphql/schema.graphql b/apps/backend/api/graphql/schema.graphql index 33f82a2ae6..c2172126bc 100644 --- a/apps/backend/api/graphql/schema.graphql +++ b/apps/backend/api/graphql/schema.graphql @@ -3331,7 +3331,6 @@ type Mutation { email: String! businessName: String! country: String - existingAccountId: String ): StripeConnectedAccountResult # Create an Account Link for onboarding diff --git a/apps/backend/api/services/StripeService.js b/apps/backend/api/services/StripeService.js index a15b049d7a..dde4c67ce4 100644 --- a/apps/backend/api/services/StripeService.js +++ b/apps/backend/api/services/StripeService.js @@ -23,8 +23,9 @@ if (!STRIPE_SECRET_KEY) { } // Initialize Stripe client with the latest API version +// Note: API version should match what Stripe expects - check Stripe dashboard for latest const stripe = new Stripe(STRIPE_SECRET_KEY, { - apiVersion: '2025-09-30.clover' // Using the latest Stripe API version + apiVersion: '2025-10-29.clover' // Updated to match Stripe's expected version }) module.exports = { @@ -104,8 +105,15 @@ module.exports = { * verified - verification status will be displayed in the UI and * prevent product publishing until the account is ready. * + * NOTE: This only works for Stripe accounts that were originally created + * through our platform (connected accounts). To connect accounts created + * outside our platform, we need to implement Stripe OAuth flow. + * + * Note: Stripe will handle the edge case where the user already has an account + * by prompting them to connect their existing account during onboarding. + * * @param {Object} params - Connection parameters - * @param {String} params.accountId - Existing Stripe account ID + * @param {String} params.accountId - Existing Stripe account ID (must be a connected account) * @param {String} params.groupId - Group ID for metadata correlation * @returns {Promise} The Stripe account object */ @@ -122,7 +130,17 @@ module.exports = { // Validate that the account exists by retrieving it // This will throw an error if the account ID is invalid - await stripe.accounts.retrieve(accountId) + // Note: For connected accounts, we retrieve using the account ID directly + // The accountId should be a string like 'acct_xxx' + if (process.env.NODE_ENV !== 'production') { + console.log('Attempting to retrieve Stripe account:', { + accountId, + accountIdType: typeof accountId, + accountIdLength: accountId?.length, + isString: typeof accountId === 'string' + }) + } + const retrievedAccount = await stripe.accounts.retrieve(accountId) // Account exists and is valid - we can proceed with connection // No need to check verification status here as UI will handle that @@ -138,9 +156,23 @@ module.exports = { return updatedAccount } catch (error) { - console.error('Error connecting existing account:', error) + console.error('Error connecting existing account:', { + accountId, + groupId, + errorType: error.type, + errorMessage: error.message, + errorCode: error.code, + errorParam: error.param, + errorDetail: error.detail, + rawError: error.raw, + fullError: error + }) if (error.type === 'StripeInvalidRequestError') { - throw new Error('Invalid Stripe account ID provided') + // Provide more specific error message based on the error details + if (error.message && error.message.includes('API version')) { + throw new Error(`Stripe API version error: ${error.message}. This may indicate an issue with the Stripe SDK configuration.`) + } + throw new Error(`Invalid Stripe account ID provided: ${error.message}`) } throw new Error(`Failed to connect existing Stripe account: ${error.message}`) } diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js index f17a08f0d4..d1c98c5857 100644 --- a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js @@ -30,7 +30,7 @@ import { fetchOfferings // updateOffering - TODO: Enable when database offering IDs are available } from './PaidContentTab.store' -import { updateGroupSettings, fetchGroupSettings } from '../GroupSettings.store' +import { fetchGroupSettings } from '../GroupSettings.store' import { getHost } from 'store/middleware/apiMiddleware' /** @@ -161,8 +161,10 @@ function PaidContentTab ({ group, currentUser }) { * * This is the first step in enabling payments. Once the account is created, * the group admin needs to complete onboarding via an Account Link. + * Note: Stripe will handle the case where the user already has an account + * by prompting them during onboarding. */ - const handleCreateAccount = useCallback(async ({ email, businessName, country, existingAccountId }) => { + const handleCreateAccount = useCallback(async ({ email, businessName, country }) => { if (!group) return setState(prev => ({ ...prev, loading: true, error: null })) @@ -172,8 +174,7 @@ function PaidContentTab ({ group, currentUser }) { group.id, email, businessName || group.name, - country || 'US', - existingAccountId || null + country || 'US' )) // Check for errors in the response @@ -204,22 +205,20 @@ function PaidContentTab ({ group, currentUser }) { throw new Error('Server response missing accountId field: ' + JSON.stringify(responseData)) } - // Save accountId to your group model in the database - await dispatch(updateGroupSettings(group.id, { stripeAccountId: accountId })) + // Refresh group data to get the updated stripe_account_id + if (group?.slug) { + await dispatch(fetchGroupSettings(group.slug)) + } + // Update local state with the external account ID for display purposes setState(prev => ({ ...prev, accountId, loading: false })) - // Automatically trigger onboarding after account creation (only for new accounts) - if (!existingAccountId) { - handleStartOnboarding(accountId) - } else { - // For existing accounts, just refresh status - loadAccountStatus() - } + // Automatically trigger onboarding after account creation + handleStartOnboarding(accountId) } catch (error) { console.error('Error creating/connecting account:', error) setState(prev => ({ @@ -294,7 +293,7 @@ function PaidContentTab ({ group, currentUser }) { setState(prev => ({ ...prev, loading: false, error: null })) // Reload live account status to reflect the updates - loadAccountStatus() + loadAccountStatus() } catch (error) { console.error('Error checking Stripe status:', error) setState(prev => ({ @@ -305,6 +304,17 @@ function PaidContentTab ({ group, currentUser }) { } }, [dispatch, group, loadAccountStatus]) + // Check for onboarding completion query parameter and trigger status check + useEffect(() => { + const searchParams = new URLSearchParams(location.search) + const onboarding = searchParams.get('onboarding') + + if (onboarding === 'complete' && group?.id && state.accountId) { + // Automatically check Stripe status when user returns from onboarding + handleCheckStripeStatus() + } + }, [location.search, group?.id, state.accountId, handleCheckStripeStatus]) + // Extract nested tab from pathname // URL structure: /groups/:groupSlug/settings/paid-content/:subTab const pathParts = location.pathname.split('/') @@ -367,7 +377,6 @@ function PaidContentTab ({ group, currentUser }) { accountId={accountId} loading={loading} onCreateAccount={handleCreateAccount} - onConnectAccount={handleCreateAccount} onCheckStatus={handleCheckStripeStatus} onStartOnboarding={handleStartOnboarding} /> @@ -384,7 +393,7 @@ function PaidContentTab ({ group, currentUser }) { * * Main tab showing account setup and status */ -function AccountTab ({ group, currentUser, accountId, loading, onCreateAccount, onConnectAccount, onCheckStatus, onStartOnboarding }) { +function AccountTab ({ group, currentUser, accountId, loading, onCreateAccount, onCheckStatus, onStartOnboarding }) { const { t } = useTranslation() return ( @@ -394,7 +403,6 @@ function AccountTab ({ group, currentUser, accountId, loading, onCreateAccount, {updating ? t('Updating...') : t('Update Offering')} - - + + )} @@ -642,22 +650,22 @@ function OfferingsTab ({ group, accountId, offerings, onRefreshOfferings }) {
    {offerings.length === 0 ? ( -
    - +
    +

    {t('No offerings yet')}

    {t('Create your first offering to start accepting payments')}

    - ) + ) : ( - offerings.map(offering => ( - - )) - )} + offerings.map(offering => ( + + )) + )}
    @@ -688,16 +696,15 @@ function ContentAccessTab ({ group }) { * Section for initial account setup * * Displayed when the group doesn't have a Stripe account yet. - * Provides a form to collect account information for creating or connecting accounts. + * Provides a form to collect account information for creating a new Stripe account. + * Note: If the user already has a Stripe account, Stripe will prompt them to connect it during onboarding. */ -function AccountSetupSection ({ loading, onCreateAccount, onConnectAccount, group, currentUser, t }) { +function AccountSetupSection ({ loading, onCreateAccount, group, currentUser, t }) { const [formData, setFormData] = useState({ email: currentUser?.email || '', businessName: group?.name || '', - country: 'US', - existingAccountId: '' + country: 'US' }) - const [isConnectingExisting, setIsConnectingExisting] = useState(false) const [submitting, setSubmitting] = useState(false) const handleSubmit = useCallback(async (e) => { @@ -716,40 +723,27 @@ function AccountSetupSection ({ loading, onCreateAccount, onConnectAccount, grou return } - // If connecting existing account, validate account ID - if (isConnectingExisting) { - if (!formData.existingAccountId.trim()) { - alert(t('Please enter a Stripe account ID')) - return - } - if (!formData.existingAccountId.startsWith('acct_')) { - alert(t('Stripe account IDs must start with "acct_"')) - return - } - } - setSubmitting(true) try { - await (isConnectingExisting ? onConnectAccount : onCreateAccount)({ + await onCreateAccount({ email: formData.email.trim(), businessName: formData.businessName.trim() || group.name, - country: formData.country, - existingAccountId: isConnectingExisting ? formData.existingAccountId.trim() : null + country: formData.country }) } catch (error) { - console.error('Error creating/connecting account:', error) + console.error('Error creating account:', error) // Error state is handled by parent component } finally { setSubmitting(false) } - }, [formData, isConnectingExisting, onCreateAccount, onConnectAccount, group, t]) + }, [formData, onCreateAccount, group, t]) return ( <>

    {t('Get started with payments')}

    - {t('Set up Stripe Connect to accept payments. You can create a new account or connect an existing Stripe account.')} + {t('Set up Stripe Connect to accept payments. If you already have a Stripe account, Stripe will prompt you to connect it during onboarding.')}

    @@ -797,39 +791,13 @@ function AccountSetupSection ({ loading, onCreateAccount, onConnectAccount, grou -
    - setIsConnectingExisting(e.target.checked)} - className='w-4 h-4' - /> - -
    - - {isConnectingExisting && ( - setFormData(prev => ({ ...prev, existingAccountId: e.target.value }))} - placeholder='acct_...' - required={isConnectingExisting} - helpText={t('Enter your existing Stripe Connect account ID (starts with "acct_")')} - /> - )} -
    @@ -911,13 +879,13 @@ function StripeStatusSection ({ group, loading, onCheckStatus, onStartOnboarding )} @@ -977,7 +945,7 @@ function OfferingListItem ({ offering, onEdit, t }) { return (
    -
    +

    {offering.name}

    {offering.active @@ -991,8 +959,8 @@ function OfferingListItem ({ offering, onEdit, t }) { {t('Stripe ID')}: {offering.id} {offering.defaultPriceId && ( {t('Price ID')}: {offering.defaultPriceId} - )} -
    + )} +
    {onEdit && ( +
    +
    -
    + setFormData(prev => ({ ...prev, duration: e.target.value }))} + renderControl={(props) => ( + + )} + /> + + setFormData(prev => ({ ...prev, publishStatus: e.target.value }))} + renderControl={(props) => ( + + )} + /> + + setFormData(prev => ({ ...prev, lineItems }))} + t={t} + /> + +
    @@ -626,6 +945,43 @@ function OfferingsTab ({ group, accountId, offerings, onRefreshOfferings }) { onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} placeholder={t('What does this offering include?')} /> + setFormData(prev => ({ ...prev, duration: e.target.value }))} + renderControl={(props) => ( + + )} + /> + + setFormData(prev => ({ ...prev, publishStatus: e.target.value }))} + renderControl={(props) => ( + + )} + /> + + setFormData(prev => ({ ...prev, lineItems }))} + t={t} + /> +
    -
    - +
    + )}
    -

    {t('Offerings')}

    +
    +

    {t('Offerings')}

    +
    + +
    +
    - {offerings.length === 0 - ? ( -
    - -

    {t('No offerings yet')}

    -

    {t('Create your first offering to start accepting payments')}

    -
    + {(() => { + const filteredOfferings = showArchived + ? offerings + : offerings.filter(offering => offering.publishStatus !== 'archived') + + if (filteredOfferings.length === 0) { + return ( +
    + +

    {t('No offerings yet')}

    +

    {t('Create your first offering to start accepting payments')}

    +
    ) - : ( - offerings.map(offering => ( - - )) - )} + } + + return filteredOfferings.map(offering => ( + + )) + })()}
    @@ -825,7 +1202,6 @@ function StripeStatusSection ({ group, loading, onCheckStatus, onStartOnboarding const chargesEnabled = group?.stripeChargesEnabled const payoutsEnabled = group?.stripePayoutsEnabled const detailsSubmitted = group?.stripeDetailsSubmitted - const paywall = group?.paywall const isFullyOnboarded = chargesEnabled && payoutsEnabled const needsOnboarding = !detailsSubmitted @@ -879,36 +1255,16 @@ function StripeStatusSection ({ group, loading, onCheckStatus, onStartOnboarding )} -
    - ( -
    - - - {paywall ? t('Yes') : t('No')} - -
    - )} - /> -
    - {needsOnboarding && ( + )} + + + ) +} + +/** + * Line Items Selector Component + * + * Allows selection of tracks, groups, and roles to attach to an offering. + * Selected items are displayed as removable chips. + */ +function LineItemsSelector ({ group, lineItems, onLineItemsChange, t }) { + const dispatch = useDispatch() + const [activeSelector, setActiveSelector] = useState(null) // 'track', 'group', or 'role' + const [searchTerm, setSearchTerm] = useState('') + const [items, setItems] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const debouncedSearch = useDebounce(searchTerm, 300) + + // Get roles (combine common roles and group roles, like TagInput does) + const commonRoles = useSelector(getCommonRoles) + const groupRoles = useMemo(() => group?.groupRoles?.items || [], [group?.groupRoles?.items]) + const allRoles = useMemo(() => [ + ...commonRoles.map(role => ({ ...role, type: 'common', label: `${role.emoji || ''} ${role.name}`.trim() })), + ...groupRoles.map(role => ({ ...role, type: 'group', label: `${role.emoji || ''} ${role.name}`.trim() })) + ], [commonRoles, groupRoles]) + + // Fetch tracks when track selector is active + useEffect(() => { + async function getTracks () { + if (activeSelector !== 'track') return + + setIsLoading(true) + try { + const response = await dispatch(fetchGroupTracks(group.id, { + autocomplete: debouncedSearch || '', + first: 20, + published: true + })) + setItems(response?.payload?.data?.group?.tracks?.items || []) + } catch (error) { + console.error('Error fetching tracks:', error) + } finally { + setIsLoading(false) + } + } + + getTracks() + }, [debouncedSearch, dispatch, activeSelector, group.id]) + + // Set current group when group selector is active + useEffect(() => { + if (activeSelector === 'group') { + setIsLoading(false) + // Only allow selecting the current group + setItems(group ? [group] : []) + } + }, [activeSelector, group]) + + // Filter roles when role selector is active + useEffect(() => { + if (activeSelector === 'role') { + setIsLoading(false) + // Filter out already selected roles + const unselectedRoles = allRoles.filter(role => + !lineItems.roles.some(selected => selected.id === role.id) + ) + + if (!debouncedSearch) { + setItems(unselectedRoles) + } else { + const searchLower = debouncedSearch.toLowerCase() + const filteredRoles = unselectedRoles.filter(role => + role.name.toLowerCase().includes(searchLower) + ) + setItems(filteredRoles) + } + } + }, [debouncedSearch, activeSelector, allRoles, lineItems.roles]) + + const handleSelectItem = useCallback((item) => { + const itemType = activeSelector === 'track' ? 'tracks' : activeSelector === 'group' ? 'groups' : 'roles' + const currentItems = lineItems[itemType] || [] + + // Check if item is already selected + if (currentItems.find(i => i.id === item.id)) { + return + } + + onLineItemsChange({ + ...lineItems, + [itemType]: [...currentItems, item] + }) + + // Reset selector + setActiveSelector(null) + setSearchTerm('') + }, [activeSelector, lineItems, onLineItemsChange]) + + const handleRemoveItem = useCallback((itemType, itemId) => { + onLineItemsChange({ + ...lineItems, + [itemType]: lineItems[itemType].filter(item => item.id !== itemId) + }) + }, [lineItems, onLineItemsChange]) + + const textOptions = { + track: { + searchPlaceholder: t('Search tracks...'), + noResults: t('No tracks found'), + heading: t('Tracks'), + buttonLabel: t('Add Track') + }, + group: { + searchPlaceholder: t('Search groups...'), + noResults: t('No groups found'), + heading: t('Groups'), + buttonLabel: t('Add Group') + }, + role: { + searchPlaceholder: t('Search roles...'), + noResults: t('No roles found'), + heading: t('Roles'), + buttonLabel: t('Add Role') + } + } + + return ( +
    +
    + +

    + {t('Select tracks, groups, and roles that this offering grants access to')} +

    + + {/* Selected Items Display */} +
    + {lineItems.tracks.length > 0 && ( +
    +

    {t('Tracks')}:

    +
    + {lineItems.tracks.map(track => ( + + {track.name} + + + ))} +
    +
    + )} + {lineItems.groups.length > 0 && ( +
    +

    {t('Groups')}:

    +
    + {lineItems.groups.map(selectedGroup => ( + + {selectedGroup.name} + + + ))} +
    +
    + )} + {lineItems.roles.length > 0 && ( +
    +

    {t('Roles')}:

    +
    + {lineItems.roles.map(role => ( + + {role.emoji && {role.emoji}} + {role.name} + + + ))} +
    +
    + )} +
    + + {/* Add Buttons */} +
    + + + +
    + + {/* Search/Select Interface */} + {activeSelector && ( +
    + + + + {isLoading + ? {t('Loading...')} + : items.length === 0 + ? {textOptions[activeSelector].noResults} + : ( + + {items.map((item) => { + const itemType = activeSelector === 'track' ? 'tracks' : activeSelector === 'group' ? 'groups' : 'roles' + const isSelected = lineItems[itemType].find(i => i.id === item.id) + return ( + handleSelectItem(item)} + className={isSelected ? 'opacity-50 cursor-not-allowed' : ''} + disabled={isSelected} + > + {activeSelector === 'role' && item.emoji && {item.emoji}} + {item.name} + {isSelected && ({t('Already selected')})} + + ) + })} + )} + + +
    )}
    diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js index c6d7ba54d0..00129a9c3a 100644 --- a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.store.js @@ -120,19 +120,12 @@ export function fetchAccountStatus (groupId, accountId) { * Offerings represent subscription tiers, content access, or other offerings * that the group wants to sell. */ -export function createOffering (groupId, accountId, name, description, priceInCents, currency = 'usd') { +export function createOffering (groupId, accountId, name, description, priceInCents, currency = 'usd', contentAccess = null, duration = null, publishStatus = 'unpublished') { return { type: CREATE_OFFERING, graphql: { - query: `mutation ($groupId: ID!, $accountId: String!, $name: String!, $description: String, $priceInCents: Int!, $currency: String) { - createStripeOffering( - groupId: $groupId - accountId: $accountId - name: $name - description: $description - priceInCents: $priceInCents - currency: $currency - ) { + query: `mutation ($input: StripeOfferingInput!) { + createStripeOffering(input: $input) { productId priceId name @@ -141,12 +134,17 @@ export function createOffering (groupId, accountId, name, description, priceInCe } }`, variables: { - groupId, - accountId, - name, - description, - priceInCents, - currency + input: { + groupId, + accountId, + name, + description, + priceInCents, + currency, + contentAccess, + duration, + publishStatus + } } } } @@ -196,9 +194,13 @@ export function fetchOfferings (groupId, accountId) { id name description - defaultPriceId - images - active + priceInCents + currency + stripeProductId + stripePriceId + contentAccess + publishStatus + duration } success } From e2fc7411ef142dbd3163c1647156684992f9ac67 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 10 Nov 2025 12:01:03 +1030 Subject: [PATCH 19/76] Allow filtering of offerings by type --- .../PaidContentTab/PaidContentTab.js | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js index 099e010e49..94c82c48bf 100644 --- a/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js +++ b/apps/web/src/routes/GroupSettings/PaidContentTab/PaidContentTab.js @@ -454,6 +454,7 @@ function OfferingsTab ({ group, accountId, offerings, onRefreshOfferings }) { const [updating, setUpdating] = useState(false) const [updatingPaywall, setUpdatingPaywall] = useState(false) const [showArchived, setShowArchived] = useState(false) + const [accessFilter, setAccessFilter] = useState('all') // Fetch tracks when needed for content access editing and display useEffect(() => { @@ -797,11 +798,11 @@ function OfferingsTab ({ group, accountId, offerings, onRefreshOfferings }) { {group?.paywall ? t('Yes') : t('No')} - +
    {isPaywallReady ? ({t('This group is ready to have a paywall added')}) - : ({t('To have a paywall to group access, the group needs to have a Stripe Connect account, have that account verified and then have at least ONE offering that allows access to the group')})} + : ({t('To have a paywall to group access, the group needs to have a Stripe Connect account, have that account verified and then have at least ONE published offering that allows access to the group')})}
    )} @@ -1004,7 +1005,20 @@ function OfferingsTab ({ group, accountId, offerings, onRefreshOfferings }) {

    {t('Offerings')}

    -
    +
    +
    + + +