diff --git a/lib/app.js b/lib/app.js index e2455b63a..b5140657c 100644 --- a/lib/app.js +++ b/lib/app.js @@ -127,23 +127,38 @@ const start = async () => { } }; -// Shut down the server +const FORCE_SHUTDOWN_TIMEOUT_MS = 5000; + +/** + * Gracefully shut down the server, disconnecting services before exiting. + * If the server promise rejects (e.g. failed to start), logs the error and exits. + * A forced exit is triggered after {@link FORCE_SHUTDOWN_TIMEOUT_MS} ms if shutdown hangs. + * + * @param {Promise<{http: import('http').Server}>} server - Promise returned by {@link start} + * @returns {Promise} Resolves when shutdown is initiated (process exits via callback) + */ const shutdown = async (server) => { + // Force exit if graceful shutdown hangs + const forceTimeout = setTimeout(() => { + console.error(chalk.red('Forced shutdown (timeout)')); + process.exit(1); + }, FORCE_SHUTDOWN_TIMEOUT_MS); + forceTimeout.unref(); + try { - server.then(async (value) => { - await mongooseService.disconnect(); - // add sequelize - value.http.close((err) => { - console.info(chalk.yellow('Server closed')); - if (err) { - console.info(chalk.red('Error on server close.', err)); - process.exitCode = 1; - } - process.exit(); - }); + const value = await server; + await mongooseService.disconnect(); + value.http.close((err) => { + console.info(chalk.yellow('Server closed')); + if (err) { + console.info(chalk.red('Error on server close.', err)); + process.exitCode = 1; + } + process.exit(); }); - } catch (e) { - throw new Error(e); + } catch (err) { + console.error(chalk.red('Shutdown error: server never started or shutdown failed'), err); + process.exit(1); } }; diff --git a/modules/billing/README.md b/modules/billing/README.md new file mode 100644 index 000000000..08a709ef2 --- /dev/null +++ b/modules/billing/README.md @@ -0,0 +1,63 @@ +# Billing Module + +Stripe-based billing with per-plan quota management. + +## Quota System + +### Configuration + +Each downstream project defines its quotas in the billing config: + +```js +// config/defaults/development.config.js +billing: { + quotas: { + free: { documents: { create: 10, export: 50 } }, + starter: { documents: { create: 100, export: 500 } }, + pro: { documents: { create: Infinity, export: Infinity } }, + }, +} +``` + +### Middleware — `requireQuota(resource, action)` + +Enforces per-plan limits on routes. Returns 429 when quota exceeded. + +```js +import requireQuota from '../billing/middlewares/billing.requireQuota.js'; + +app.route('/api/documents') + .post(passport.authenticate('jwt', { session: false }), + organization.resolveOrganization, + requireQuota('documents', 'create'), + documents.create); +``` + +### Tracking Usage — `BillingUsageService` + +Increment counters after successful operations: + +```js +import BillingUsageService from '../billing/services/billing.usage.service.js'; + +// After creating a document +await BillingUsageService.increment(organizationId, 'documents.create', 1); +``` + +### Usage Endpoint + +`GET /api/billing/usage` — returns current usage, limits, and plan for the authenticated org. + +### Events + +Listen for plan changes in downstream modules: + +```js +import { billingEvents } from '../billing/lib/events.js'; + +billingEvents.on('plan.changed', ({ organizationId, previousPlan, newPlan, isDowngrade }) => { + if (isDowngrade) { + // Handle downgrade (e.g. disable premium features) + } +}); +``` diff --git a/modules/billing/config/billing.development.config.js b/modules/billing/config/billing.development.config.js index f7e3ac0a1..3451eaf79 100644 --- a/modules/billing/config/billing.development.config.js +++ b/modules/billing/config/billing.development.config.js @@ -2,6 +2,12 @@ const config = { billing: { // Plans available for subscriptions — extend as needed plans: ['free', 'starter', 'pro', 'enterprise'], + // Quotas — downstream projects override these per plan: + // quotas: { + // free: { documents: { create: 10, export: 50 } }, + // starter: { documents: { create: 100, export: 500 } }, + // pro: { documents: { create: Infinity, export: Infinity } }, + // }, // Stripe subscription statuses — see https://docs.stripe.com/api/subscriptions/object#subscription_object-status statuses: [ 'incomplete',