Skip to content

Commit 13ab6fa

Browse files
feat(billing): add GET /api/billing/plans public endpoint
Fetches active products and prices from Stripe, maps them to a normalized plan format, and caches results in-memory for 1 hour. Returns a default free plan when Stripe is not configured. Closes #3244
1 parent 539b9bf commit 13ab6fa

6 files changed

Lines changed: 144 additions & 0 deletions

File tree

modules/billing/controllers/.gitkeep

Whitespace-only changes.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import errors from '../../../lib/helpers/errors.js';
5+
import responses from '../../../lib/helpers/responses.js';
6+
import BillingPlansService from '../services/billing.plans.service.js';
7+
8+
/**
9+
* @desc Endpoint to get billing plans
10+
* @param {Object} req - Express request object
11+
* @param {Object} res - Express response object
12+
*/
13+
const getPlans = async (req, res) => {
14+
try {
15+
const plans = await BillingPlansService.getPlans();
16+
responses.success(res, 'billing plans')(plans);
17+
} catch (err) {
18+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
19+
}
20+
};
21+
22+
export default {
23+
getPlans,
24+
};

modules/billing/routes/.gitkeep

Whitespace-only changes.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import billing from '../controllers/billing.plans.controller.js';
5+
6+
/**
7+
* Routes
8+
*/
9+
export default (app) => {
10+
// plans (public)
11+
app.route('/api/billing/plans').get(billing.getPlans);
12+
};

modules/billing/services/.gitkeep

Whitespace-only changes.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import Stripe from 'stripe';
5+
6+
import config from '../../../config/index.js';
7+
8+
/**
9+
* In-memory cache for plans
10+
*/
11+
let cachedPlans = null;
12+
let cacheTimestamp = 0;
13+
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
14+
15+
/**
16+
* Default free plan returned when Stripe is not configured
17+
*/
18+
const DEFAULT_FREE_PLAN = {
19+
planId: 'free',
20+
name: 'Free',
21+
monthlyPrice: 0,
22+
annualPrice: 0,
23+
stripePriceMonthly: null,
24+
stripePriceAnnual: null,
25+
};
26+
27+
/**
28+
* Lazily instantiated Stripe client
29+
*/
30+
let stripeClient = null;
31+
32+
/**
33+
* @desc Get or create the Stripe client instance
34+
* @returns {Object|null} Stripe client or null if not configured
35+
*/
36+
const getStripe = () => {
37+
if (stripeClient) return stripeClient;
38+
if (!config.stripe?.secretKey) return null;
39+
stripeClient = new Stripe(config.stripe.secretKey);
40+
return stripeClient;
41+
};
42+
43+
/**
44+
* @desc Fetch plans from Stripe and map to normalized format
45+
* @returns {Promise<Array>} sorted array of plan objects
46+
*/
47+
const fetchPlansFromStripe = async (stripe) => {
48+
const products = await stripe.products.list({
49+
active: true,
50+
expand: ['data.default_price'],
51+
});
52+
53+
const plans = await Promise.all(
54+
products.data.map(async (product) => {
55+
const prices = await stripe.prices.list({
56+
product: product.id,
57+
active: true,
58+
});
59+
60+
let monthlyPrice = 0;
61+
let annualPrice = 0;
62+
let stripePriceMonthly = null;
63+
let stripePriceAnnual = null;
64+
65+
for (const price of prices.data) {
66+
if (price.recurring?.interval === 'month') {
67+
monthlyPrice = price.unit_amount / 100;
68+
stripePriceMonthly = price.id;
69+
} else if (price.recurring?.interval === 'year') {
70+
annualPrice = price.unit_amount / 100;
71+
stripePriceAnnual = price.id;
72+
}
73+
}
74+
75+
return {
76+
planId: product.metadata?.planId || product.id,
77+
name: product.name,
78+
monthlyPrice,
79+
annualPrice,
80+
stripePriceMonthly,
81+
stripePriceAnnual,
82+
};
83+
}),
84+
);
85+
86+
return plans.sort((a, b) => a.monthlyPrice - b.monthlyPrice);
87+
};
88+
89+
/**
90+
* @desc Get billing plans with in-memory caching
91+
* @returns {Promise<Array>} array of plan objects sorted by monthlyPrice ascending
92+
*/
93+
const getPlans = async () => {
94+
const stripe = getStripe();
95+
if (!stripe) return [DEFAULT_FREE_PLAN];
96+
97+
const now = Date.now();
98+
if (cachedPlans && now - cacheTimestamp < CACHE_TTL) return cachedPlans;
99+
100+
const plans = await fetchPlansFromStripe(stripe);
101+
cachedPlans = plans;
102+
cacheTimestamp = Date.now();
103+
return plans;
104+
};
105+
106+
export default {
107+
getPlans,
108+
};

0 commit comments

Comments
 (0)