Files
HOA_Financial_Platform/backend/src/database/tenant.middleware.ts
olsch01 a996208cb8 feat: add annual billing, free trial, upgrade/downgrade, and ACH invoice support
- 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>
2026-03-18 08:04:51 -04:00

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;
}
}