- Add monthly/annual billing toggle with 25% annual discount on pricing page - Implement 14-day no-card free trial (server-side Stripe subscription creation) - Enable upgrade/downgrade via Stripe Customer Portal - Add admin-initiated ACH/invoice billing for enterprise customers - Add billing card to Settings page with plan info and Manage Billing button - Handle past_due status with read-only grace period access - Add trial ending and trial expired email templates - Add DB migration for billing_interval and collection_method columns - Update ONBOARDING-AND-AUTH.md documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
89 lines
3.1 KiB
TypeScript
89 lines
3.1 KiB
TypeScript
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { DataSource } from 'typeorm';
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import * as jwt from 'jsonwebtoken';
|
|
|
|
export interface TenantRequest extends Request {
|
|
tenantSchema?: string;
|
|
orgId?: string;
|
|
userId?: string;
|
|
userRole?: string;
|
|
orgPastDue?: boolean;
|
|
}
|
|
|
|
@Injectable()
|
|
export class TenantMiddleware implements NestMiddleware {
|
|
// In-memory cache for org info to avoid DB hit per request
|
|
private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
|
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
|
|
|
constructor(
|
|
private configService: ConfigService,
|
|
private dataSource: DataSource,
|
|
) {}
|
|
|
|
async use(req: TenantRequest, res: Response, next: NextFunction) {
|
|
// Try to extract tenant info from Authorization header JWT
|
|
const authHeader = req.headers.authorization;
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
try {
|
|
const token = authHeader.substring(7);
|
|
const secret = this.configService.get<string>('JWT_SECRET');
|
|
const decoded = jwt.verify(token, secret!) as any;
|
|
if (decoded?.orgId) {
|
|
// Look up org info (status + schema) from orgId with caching
|
|
const orgInfo = await this.getOrgInfo(decoded.orgId);
|
|
if (orgInfo) {
|
|
if (['suspended', 'archived'].includes(orgInfo.status)) {
|
|
res.status(403).json({
|
|
statusCode: 403,
|
|
message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
|
|
});
|
|
return;
|
|
}
|
|
// past_due: allow through with read-only flag (WriteAccessGuard enforces)
|
|
if (orgInfo.status === 'past_due') {
|
|
req.orgPastDue = true;
|
|
}
|
|
req.tenantSchema = orgInfo.schemaName;
|
|
}
|
|
req.orgId = decoded.orgId;
|
|
req.userId = decoded.sub;
|
|
req.userRole = decoded.role;
|
|
} else if (decoded?.sub) {
|
|
// Superadmin or user without org — still set userId
|
|
req.userId = decoded.sub;
|
|
}
|
|
} catch {
|
|
// Token invalid or expired - let Passport handle the auth error
|
|
}
|
|
}
|
|
next();
|
|
}
|
|
|
|
private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
|
|
const cached = this.orgCache.get(orgId);
|
|
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
|
return { status: cached.status, schemaName: cached.schemaName };
|
|
}
|
|
try {
|
|
const result = await this.dataSource.query(
|
|
`SELECT status, schema_name as "schemaName" FROM shared.organizations WHERE id = $1`,
|
|
[orgId],
|
|
);
|
|
if (result.length > 0) {
|
|
this.orgCache.set(orgId, {
|
|
status: result[0].status,
|
|
schemaName: result[0].schemaName,
|
|
cachedAt: Date.now(),
|
|
});
|
|
return { status: result[0].status, schemaName: result[0].schemaName };
|
|
}
|
|
} catch {
|
|
// Non-critical — don't block requests on cache miss errors
|
|
}
|
|
return null;
|
|
}
|
|
}
|