Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion lib/middlewares/policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ const resolveSubject = (req) => {
// For user admin routes, the loaded user is stored in req.model by userByID param middleware
if (req.model) return { subjectType: 'UserAdmin', document: normalizeForCasl(req.model) };
if (req.membershipDoc) return { subjectType: 'Membership', document: normalizeForCasl(req.membershipDoc) };
if (req.organization) return { subjectType: 'Organization', document: normalizeForCasl(req.organization) };
// Billing routes use req.organization for context but authorize via route-derived subjects
if (req.organization && !req.route?.path?.startsWith('/api/billing')) {
return { subjectType: 'Organization', document: normalizeForCasl(req.organization) };
}
return null;
};

Expand Down Expand Up @@ -189,6 +192,10 @@ const USER_ROUTE_SUBJECTS = {
* @returns {string|null} CASL subject type or null if not mappable
*/
const deriveSubjectType = (routePath) => {
if (routePath === '/api/billing/checkout') return 'BillingCheckout';
if (routePath === '/api/billing/portal') return 'BillingPortal';
if (routePath === '/api/billing/subscription') return 'BillingSubscription';
if (routePath === '/api/billing/plans') return 'BillingPlans';
Comment thread
PierreBrisorgueil marked this conversation as resolved.
if (routePath.startsWith('/api/tasks')) return 'Task';
if (routePath.startsWith('/api/uploads')) return 'Upload';
if (routePath.startsWith('/api/home')) return 'Home';
Expand Down
62 changes: 62 additions & 0 deletions modules/billing/controllers/billing.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Module dependencies
*/
import responses from '../../../lib/helpers/responses.js';
import BillingService from '../services/billing.service.js';

/**
* @desc Endpoint to create a Stripe Checkout session
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<void>}
*/
const checkout = async (req, res) => {
try {
const { priceId, successUrl, cancelUrl } = req.body;
const url = await BillingService.createCheckout(req.organization, priceId, successUrl, cancelUrl);
responses.success(res, 'checkout session created')({ url });
Comment thread
PierreBrisorgueil marked this conversation as resolved.
} catch (err) {
const status = err.message?.startsWith('Invalid') || err.message?.includes('not found') ? 422 : 502;
const title = status === 422 ? 'Unprocessable Entity' : 'Bad Gateway';
responses.error(res, status, title, 'Failed to create checkout session')(err);
}
Comment thread
PierreBrisorgueil marked this conversation as resolved.
};

/**
* @desc Endpoint to create a Stripe Customer Portal session
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<void>}
*/
const portal = async (req, res) => {
try {
const { returnUrl } = req.body;
const url = await BillingService.createPortalSession(req.organization, returnUrl);
responses.success(res, 'portal session created')({ url });
} catch (err) {
const status = err.message?.startsWith('Invalid') || err.message?.includes('not found') ? 422 : 502;
const title = status === 422 ? 'Unprocessable Entity' : 'Bad Gateway';
responses.error(res, status, title, 'Failed to create portal session')(err);
}
};

/**
* @desc Endpoint to get the subscription for the current organization
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<void>}
*/
const getSubscription = async (req, res) => {
try {
const subscription = await BillingService.getSubscription(req.organization._id);
responses.success(res, 'subscription')(subscription);
} catch (err) {
responses.error(res, 500, 'Internal Server Error', 'Failed to retrieve subscription')(err);
}
};

export default {
checkout,
portal,
getSubscription,
};
22 changes: 22 additions & 0 deletions modules/billing/models/billing.subscription.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,29 @@ const SubscriptionCore = z.object({

const SubscriptionUpdate = SubscriptionCore.partial();

/**
* Checkout request body schema
*/
const CheckoutRequest = z
.object({
priceId: z.string().trim().min(1, 'priceId is required'),
successUrl: z.string().url('successUrl must be a valid URL'),
cancelUrl: z.string().url('cancelUrl must be a valid URL'),
})
.strict();

/**
* Portal request body schema
*/
const PortalRequest = z
.object({
returnUrl: z.string().url('returnUrl must be a valid URL').optional(),
})
.strict();

export default {
Subscription,
SubscriptionUpdate,
CheckoutRequest,
PortalRequest,
};
Empty file removed modules/billing/policies/.gitkeep
Empty file.
38 changes: 38 additions & 0 deletions modules/billing/policies/billing.policy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Billing ability definitions for CASL document-level authorization.
*/

/**
* Define billing-related abilities for an authenticated user.
* Any authenticated user with an organization membership can manage billing.
* @param {Object} user - The authenticated user
* @param {Object|null} membership - Optional organization membership
* @param {Object} builder - CASL AbilityBuilder helpers
* @param {Function} builder.can - Grant an ability
* @param {Function} builder.cannot - Deny an ability
* @returns {void}
*/
// eslint-disable-next-line no-unused-vars
export function billingAbilities(user, membership, { can, cannot }) {
if (Array.isArray(user?.roles) && user.roles.includes('admin')) {
can('manage', 'all');
return;
}

if (!membership) return;

can('create', 'BillingCheckout');
can('create', 'BillingPortal');
can('read', 'BillingSubscription');
}

/**
* Define billing-related abilities for guest (unauthenticated) users.
* Guests can read billing plans (public route).
* @param {Object} builder - CASL AbilityBuilder helpers
* @param {Function} builder.can - Grant an ability
* @returns {void}
*/
export function billingGuestAbilities({ can }) {
can('read', 'BillingPlans');
Comment thread
PierreBrisorgueil marked this conversation as resolved.
}
29 changes: 27 additions & 2 deletions modules/billing/routes/billing.routes.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
/**
* Module dependencies
*/
import billing from '../controllers/billing.plans.controller.js';
import passport from 'passport';

import model from '../../../lib/middlewares/model.js';
import policy from '../../../lib/middlewares/policy.js';
import organization from '../../organizations/middleware/organizations.middleware.js';
import billingSchema from '../models/billing.subscription.schema.js';
import billingPlans from '../controllers/billing.plans.controller.js';
import billing from '../controllers/billing.controller.js';

/**
* @desc Register billing routes
Expand All @@ -10,5 +17,23 @@ import billing from '../controllers/billing.plans.controller.js';
*/
export default (app) => {
// plans (public)
app.route('/api/billing/plans').get(billing.getPlans);
app.route('/api/billing/plans').get(billingPlans.getPlans);

// checkout
app
.route('/api/billing/checkout')
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
.post(model.isValid(billingSchema.CheckoutRequest), billing.checkout);

// portal
app
.route('/api/billing/portal')
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
.post(model.isValid(billingSchema.PortalRequest), billing.portal);

// subscription
app
.route('/api/billing/subscription')
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
.get(billing.getSubscription);
};
143 changes: 143 additions & 0 deletions modules/billing/services/billing.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Module dependencies
*/
import Stripe from 'stripe';

import config from '../../../config/index.js';
import BillingPlansService from './billing.plans.service.js';
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';

/**
* Lazily instantiated Stripe client
*/
let stripeClient = null;

/**
* @desc Get or create the Stripe client instance
* @returns {Object|null} Stripe client or null if not configured
*/
const getStripe = () => {
if (stripeClient) return stripeClient;
if (!config.stripe?.secretKey) return null;
stripeClient = new Stripe(config.stripe.secretKey);
return stripeClient;
};

/**
* @desc Validate that a URL uses https (or http in dev/test) and belongs to the app domain
* @param {String} url - URL to validate
* @returns {Boolean} true if valid
*/
const isAllowedUrl = (url) => {
try {
const parsed = new URL(url);
const allowHttp = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
if (!allowHttp && parsed.protocol !== 'https:') return false;
if (config.domain) {
const configHost = new URL(config.domain.startsWith('http') ? config.domain : `https://${config.domain}`).hostname;
if (parsed.hostname !== configHost) return false;
}
return true;
} catch {
return false;
}
};

/**
* @desc Create a Stripe Checkout Session for the given organization
* @param {Object} organization - The organization document
* @param {String} priceId - Stripe price ID
* @param {String} successUrl - URL to redirect on success
* @param {String} cancelUrl - URL to redirect on cancel
* @returns {Promise<String>} Checkout session URL
*/
const createCheckout = async (organization, priceId, successUrl, cancelUrl) => {
const stripe = getStripe();
if (!stripe) throw new Error('Stripe is not configured');

if (!isAllowedUrl(successUrl) || !isAllowedUrl(cancelUrl)) {
throw new Error('Invalid redirect URL: must use HTTPS and match the application domain');
}

// Validate priceId against known active Stripe prices
const plans = await BillingPlansService.getPlans();
const allowedPriceIds = plans.flatMap((p) => [p.stripePriceMonthly, p.stripePriceAnnual].filter(Boolean));
if (!allowedPriceIds.includes(priceId)) {
throw new Error('Invalid priceId: must be an active published price');
}

// Find or create subscription record with Stripe customer
let subscription = await SubscriptionRepository.findByOrganization(organization._id);

if (!subscription?.stripeCustomerId) {
const customer = await stripe.customers.create(
{
name: organization.name,
metadata: { organizationId: String(organization._id) },
},
{ idempotencyKey: `cus_create_${String(organization._id)}` },
);

if (subscription) {
subscription = await SubscriptionRepository.update({
_id: subscription._id,
stripeCustomerId: customer.id,
});
} else {
subscription = await SubscriptionRepository.create({
organization: organization._id,
stripeCustomerId: customer.id,
});
}
// Re-read to handle race: if another request already set stripeCustomerId, use that
const latest = await SubscriptionRepository.findByOrganization(organization._id);
if (latest?.stripeCustomerId) subscription = latest;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const session = await stripe.checkout.sessions.create({
customer: subscription.stripeCustomerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return session.url;
};

/**
* @desc Create a Stripe Customer Portal session for the given organization
* @param {Object} organization - The organization document
* @param {String} returnUrl - Optional URL to redirect back to after portal
* @returns {Promise<String>} Portal session URL
*/
const createPortalSession = async (organization, returnUrl) => {
const stripe = getStripe();
if (!stripe) throw new Error('Stripe is not configured');

const subscription = await SubscriptionRepository.findByOrganization(organization._id);
if (!subscription?.stripeCustomerId) throw new Error('No Stripe customer found for this organization');

const params = { customer: subscription.stripeCustomerId };
if (returnUrl) {
if (!isAllowedUrl(returnUrl)) throw new Error('Invalid return URL: must use HTTPS and match the application domain');
params.return_url = returnUrl;
}

const session = await stripe.billingPortal.sessions.create(params);

return session.url;
};

/**
* @desc Get subscription for the given organization
* @param {String} organizationId - The organization ID
* @returns {Promise<Object|null>} The subscription document or null
*/
const getSubscription = async (organizationId) => SubscriptionRepository.findByOrganization(organizationId);

export default {
createCheckout,
createPortalSession,
getSubscription,
};
Loading
Loading