Skip to content

Commit ac3c02e

Browse files
feat(billing): add checkout, portal, and subscription endpoints
Add authenticated billing endpoints behind JWT + organization middleware: - POST /api/billing/checkout — creates Stripe Checkout session - POST /api/billing/portal — creates Stripe Customer Portal session - GET /api/billing/subscription — returns org subscription Includes CASL policy, service layer with lazy Stripe init, and deriveSubjectType mappings for billing routes. Closes #3245
1 parent d5a66e2 commit ac3c02e

6 files changed

Lines changed: 222 additions & 2 deletions

File tree

lib/middlewares/policy.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ const USER_ROUTE_SUBJECTS = {
189189
* @returns {string|null} CASL subject type or null if not mappable
190190
*/
191191
const deriveSubjectType = (routePath) => {
192+
if (routePath === '/api/billing/checkout') return 'BillingCheckout';
193+
if (routePath === '/api/billing/portal') return 'BillingPortal';
194+
if (routePath === '/api/billing/subscription') return 'BillingSubscription';
195+
if (routePath === '/api/billing/plans') return 'BillingPlans';
192196
if (routePath.startsWith('/api/tasks')) return 'Task';
193197
if (routePath.startsWith('/api/uploads')) return 'Upload';
194198
if (routePath.startsWith('/api/home')) return 'Home';
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import responses from '../../../lib/helpers/responses.js';
5+
import BillingService from '../services/billing.service.js';
6+
7+
/**
8+
* @desc Endpoint to create a Stripe Checkout session
9+
* @param {Object} req - Express request object
10+
* @param {Object} res - Express response object
11+
* @returns {Promise<void>}
12+
*/
13+
const checkout = async (req, res) => {
14+
try {
15+
const { priceId, successUrl, cancelUrl } = req.body;
16+
const url = await BillingService.createCheckout(req.organization, priceId, successUrl, cancelUrl);
17+
responses.success(res, 'checkout session created')({ url });
18+
} catch (err) {
19+
responses.error(res, 422, 'Unprocessable Entity', 'Failed to create checkout session')(err);
20+
}
21+
};
22+
23+
/**
24+
* @desc Endpoint to create a Stripe Customer Portal session
25+
* @param {Object} req - Express request object
26+
* @param {Object} res - Express response object
27+
* @returns {Promise<void>}
28+
*/
29+
const portal = async (req, res) => {
30+
try {
31+
const url = await BillingService.createPortalSession(req.organization);
32+
responses.success(res, 'portal session created')({ url });
33+
} catch (err) {
34+
responses.error(res, 422, 'Unprocessable Entity', 'Failed to create portal session')(err);
35+
}
36+
};
37+
38+
/**
39+
* @desc Endpoint to get the subscription for the current organization
40+
* @param {Object} req - Express request object
41+
* @param {Object} res - Express response object
42+
* @returns {Promise<void>}
43+
*/
44+
const getSubscription = async (req, res) => {
45+
try {
46+
const subscription = await BillingService.getSubscription(req.organization._id);
47+
responses.success(res, 'subscription')(subscription);
48+
} catch (err) {
49+
responses.error(res, 500, 'Internal Server Error', 'Failed to retrieve subscription')(err);
50+
}
51+
};
52+
53+
export default {
54+
checkout,
55+
portal,
56+
getSubscription,
57+
};

modules/billing/policies/.gitkeep

Whitespace-only changes.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Billing ability definitions for CASL document-level authorization.
3+
*/
4+
5+
/**
6+
* Define billing-related abilities for an authenticated user.
7+
* Any authenticated user with an organization membership can manage billing.
8+
* @param {Object} user - The authenticated user
9+
* @param {Object|null} membership - Optional organization membership
10+
* @param {Object} builder - CASL AbilityBuilder helpers
11+
* @param {Function} builder.can - Grant an ability
12+
* @param {Function} builder.cannot - Deny an ability
13+
*/
14+
// eslint-disable-next-line no-unused-vars
15+
export function billingAbilities(user, membership, { can, cannot }) {
16+
if (Array.isArray(user?.roles) && user.roles.includes('admin')) {
17+
can('manage', 'all');
18+
return;
19+
}
20+
21+
if (!membership) return;
22+
23+
can('create', 'BillingCheckout');
24+
can('create', 'BillingPortal');
25+
can('read', 'BillingSubscription');
26+
}
27+
28+
/**
29+
* Define billing-related abilities for guest (unauthenticated) users.
30+
* Guests can read billing plans (public route).
31+
* @param {Object} builder - CASL AbilityBuilder helpers
32+
* @param {Function} builder.can - Grant an ability
33+
*/
34+
export function billingGuestAbilities({ can }) {
35+
can('read', 'BillingPlans');
36+
}
Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
/**
22
* Module dependencies
33
*/
4-
import billing from '../controllers/billing.plans.controller.js';
4+
import passport from 'passport';
5+
6+
import policy from '../../../lib/middlewares/policy.js';
7+
import organization from '../../organizations/middleware/organizations.middleware.js';
8+
import billingPlans from '../controllers/billing.plans.controller.js';
9+
import billing from '../controllers/billing.controller.js';
510

611
/**
712
* @desc Register billing routes
@@ -10,5 +15,23 @@ import billing from '../controllers/billing.plans.controller.js';
1015
*/
1116
export default (app) => {
1217
// plans (public)
13-
app.route('/api/billing/plans').get(billing.getPlans);
18+
app.route('/api/billing/plans').get(billingPlans.getPlans);
19+
20+
// checkout
21+
app
22+
.route('/api/billing/checkout')
23+
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
24+
.post(billing.checkout);
25+
26+
// portal
27+
app
28+
.route('/api/billing/portal')
29+
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
30+
.post(billing.portal);
31+
32+
// subscription
33+
app
34+
.route('/api/billing/subscription')
35+
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
36+
.get(billing.getSubscription);
1437
};
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import Stripe from 'stripe';
5+
6+
import config from '../../../config/index.js';
7+
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
8+
9+
/**
10+
* Lazily instantiated Stripe client
11+
*/
12+
let stripeClient = null;
13+
14+
/**
15+
* @desc Get or create the Stripe client instance
16+
* @returns {Object|null} Stripe client or null if not configured
17+
*/
18+
const getStripe = () => {
19+
if (stripeClient) return stripeClient;
20+
if (!config.stripe?.secretKey) return null;
21+
stripeClient = new Stripe(config.stripe.secretKey);
22+
return stripeClient;
23+
};
24+
25+
/**
26+
* @desc Create a Stripe Checkout Session for the given organization
27+
* @param {Object} organization - The organization document
28+
* @param {String} priceId - Stripe price ID
29+
* @param {String} successUrl - URL to redirect on success
30+
* @param {String} cancelUrl - URL to redirect on cancel
31+
* @returns {Promise<String>} Checkout session URL
32+
*/
33+
const createCheckout = async (organization, priceId, successUrl, cancelUrl) => {
34+
const stripe = getStripe();
35+
if (!stripe) throw new Error('Stripe is not configured');
36+
37+
// Find or create subscription record with Stripe customer
38+
let subscription = await SubscriptionRepository.findByOrganization(organization._id);
39+
40+
if (!subscription?.stripeCustomerId) {
41+
const customer = await stripe.customers.create({
42+
name: organization.name,
43+
metadata: { organizationId: String(organization._id) },
44+
});
45+
46+
if (subscription) {
47+
subscription = await SubscriptionRepository.update({
48+
...subscription.toObject(),
49+
stripeCustomerId: customer.id,
50+
});
51+
} else {
52+
subscription = await SubscriptionRepository.create({
53+
organization: organization._id,
54+
stripeCustomerId: customer.id,
55+
});
56+
}
57+
}
58+
59+
const session = await stripe.checkout.sessions.create({
60+
customer: subscription.stripeCustomerId,
61+
mode: 'subscription',
62+
line_items: [{ price: priceId, quantity: 1 }],
63+
success_url: successUrl,
64+
cancel_url: cancelUrl,
65+
});
66+
67+
return session.url;
68+
};
69+
70+
/**
71+
* @desc Create a Stripe Customer Portal session for the given organization
72+
* @param {Object} organization - The organization document
73+
* @returns {Promise<String>} Portal session URL
74+
*/
75+
const createPortalSession = async (organization) => {
76+
const stripe = getStripe();
77+
if (!stripe) throw new Error('Stripe is not configured');
78+
79+
const subscription = await SubscriptionRepository.findByOrganization(organization._id);
80+
if (!subscription?.stripeCustomerId) throw new Error('No Stripe customer found for this organization');
81+
82+
const session = await stripe.billingPortal.sessions.create({
83+
customer: subscription.stripeCustomerId,
84+
});
85+
86+
return session.url;
87+
};
88+
89+
/**
90+
* @desc Get subscription for the given organization
91+
* @param {String} organizationId - The organization ID
92+
* @returns {Promise<Object|null>} The subscription document or null
93+
*/
94+
const getSubscription = async (organizationId) => SubscriptionRepository.findByOrganization(organizationId);
95+
96+
export default {
97+
createCheckout,
98+
createPortalSession,
99+
getSubscription,
100+
};

0 commit comments

Comments
 (0)