feat: SaaS onboarding, Stripe billing, MFA, SSO, passkeys, refresh tokens

Complete SaaS self-service onboarding sprint:

- Stripe-powered signup flow: pricing page → checkout → provisioning → activation
- Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh
- TOTP MFA with QR setup, recovery codes, and login challenge flow
- Google + Azure AD SSO (conditional on env vars) with account linking
- WebAuthn passkey registration and passwordless login
- Guided onboarding checklist with server-side progress tracking
- Stubbed email service (console + DB logging, ready for real provider)
- Settings page with tabbed security settings (MFA, passkeys, linked accounts)
- Login page enhanced with MFA verification, SSO buttons, passkey login
- Database migration 015 with all new tables and columns
- Version bump to 2026.03.17

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 21:12:35 -04:00
parent 17bdebfb52
commit dfcd172ef3
39 changed files with 4673 additions and 82 deletions

View File

@@ -0,0 +1,294 @@
import { Injectable, Logger, BadRequestException, RawBodyRequest } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import Stripe from 'stripe';
import { v4 as uuid } from 'uuid';
import * as bcrypt from 'bcryptjs';
import { TenantSchemaService } from '../../database/tenant-schema.service';
import { AuthService } from '../auth/auth.service';
import { EmailService } from '../email/email.service';
const PLAN_FEATURES: Record<string, { name: string; unitLimit: number }> = {
starter: { name: 'Starter', unitLimit: 50 },
professional: { name: 'Professional', unitLimit: 200 },
enterprise: { name: 'Enterprise', unitLimit: 999999 },
};
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
private stripe: Stripe | null = null;
private webhookSecret: string;
private priceMap: Record<string, string>;
constructor(
private configService: ConfigService,
private dataSource: DataSource,
private tenantSchemaService: TenantSchemaService,
private authService: AuthService,
private emailService: EmailService,
) {
const secretKey = this.configService.get<string>('STRIPE_SECRET_KEY');
if (secretKey && !secretKey.includes('placeholder')) {
this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' as any });
this.logger.log('Stripe initialized');
} else {
this.logger.warn('Stripe not configured — billing endpoints will return stubs');
}
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || '';
this.priceMap = {
starter: this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '',
professional: this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '',
enterprise: this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '',
};
}
/**
* Create a Stripe Checkout Session for a new subscription.
*/
async createCheckoutSession(planId: string, email?: string, businessName?: string): Promise<{ url: string }> {
if (!this.stripe) {
throw new BadRequestException('Stripe not configured');
}
const priceId = this.priceMap[planId];
if (!priceId || priceId.includes('placeholder')) {
throw new BadRequestException(`Invalid plan: ${planId}`);
}
const session = await this.stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${this.getAppUrl()}/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${this.getAppUrl()}/pricing`,
customer_email: email || undefined,
metadata: {
plan_id: planId,
business_name: businessName || '',
},
});
return { url: session.url! };
}
/**
* Handle a Stripe webhook event.
*/
async handleWebhook(rawBody: Buffer, signature: string): Promise<void> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret);
} catch (err: any) {
this.logger.error(`Webhook signature verification failed: ${err.message}`);
throw new BadRequestException('Invalid webhook signature');
}
// Idempotency check
const existing = await this.dataSource.query(
`SELECT id FROM shared.stripe_events WHERE id = $1`,
[event.id],
);
if (existing.length > 0) {
this.logger.log(`Duplicate Stripe event ${event.id}, skipping`);
return;
}
// Record event
await this.dataSource.query(
`INSERT INTO shared.stripe_events (id, type, payload) VALUES ($1, $2, $3)`,
[event.id, event.type, JSON.stringify(event.data)],
);
// Dispatch
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'invoice.payment_succeeded':
await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await this.handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
default:
this.logger.log(`Unhandled Stripe event: ${event.type}`);
}
}
/**
* Get provisioning status for a checkout session.
*/
async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> {
if (!this.stripe) return { status: 'not_configured' };
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
const customerId = session.customer as string;
if (!customerId) return { status: 'pending' };
const rows = await this.dataSource.query(
`SELECT id, status FROM shared.organizations WHERE stripe_customer_id = $1`,
[customerId],
);
if (rows.length === 0) return { status: 'provisioning' };
if (rows[0].status === 'active') return { status: 'active' };
return { status: 'provisioning' };
}
/**
* Create a Stripe Customer Portal session.
*/
async createPortalSession(customerId: string): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
const session = await this.stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${this.getAppUrl()}/settings`,
});
return { url: session.url };
}
// ─── Provisioning (inline, no BullMQ for now — add queue later) ─────
private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
const email = session.customer_email || session.customer_details?.email || '';
const planId = session.metadata?.plan_id || 'starter';
const businessName = session.metadata?.business_name || 'My HOA';
this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`);
try {
await this.provisionOrganization(customerId, subscriptionId, email, planId, businessName);
} catch (err: any) {
this.logger.error(`Provisioning failed: ${err.message}`, err.stack);
}
}
private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string;
// Activate tenant if it was pending
await this.dataSource.query(
`UPDATE shared.organizations SET status = 'active', updated_at = NOW()
WHERE stripe_customer_id = $1 AND status != 'active'`,
[customerId],
);
}
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string;
const rows = await this.dataSource.query(
`SELECT email FROM shared.organizations WHERE stripe_customer_id = $1`,
[customerId],
);
if (rows.length > 0 && rows[0].email) {
await this.emailService.sendPaymentFailedEmail(rows[0].email, rows[0].name || 'Your organization');
}
this.logger.warn(`Payment failed for customer ${customerId}`);
}
private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
const customerId = subscription.customer as string;
await this.dataSource.query(
`UPDATE shared.organizations SET status = 'archived', updated_at = NOW()
WHERE stripe_customer_id = $1`,
[customerId],
);
this.logger.log(`Subscription cancelled for customer ${customerId}`);
}
/**
* Full provisioning flow: create org, schema, user, invite token, email.
*/
async provisionOrganization(
customerId: string,
subscriptionId: string,
email: string,
planId: string,
businessName: string,
): Promise<void> {
// 1. Create or upsert organization
const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`;
const orgRows = await this.dataSource.query(
`INSERT INTO shared.organizations (name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email)
VALUES ($1, $2, 'active', $3, $4, $5, $6)
ON CONFLICT (stripe_customer_id) DO UPDATE SET
stripe_subscription_id = EXCLUDED.stripe_subscription_id,
plan_level = EXCLUDED.plan_level,
status = 'active',
updated_at = NOW()
RETURNING id, schema_name`,
[businessName, schemaName, planId, customerId, subscriptionId, email],
);
const orgId = orgRows[0].id;
const actualSchema = orgRows[0].schema_name;
// 2. Create tenant schema
try {
await this.tenantSchemaService.createTenantSchema(actualSchema);
this.logger.log(`Created tenant schema: ${actualSchema}`);
} catch (err: any) {
if (err.message?.includes('already exists')) {
this.logger.log(`Schema ${actualSchema} already exists, skipping creation`);
} else {
throw err;
}
}
// 3. Create or find user
let userRows = await this.dataSource.query(
`SELECT id FROM shared.users WHERE email = $1`,
[email],
);
let userId: string;
if (userRows.length === 0) {
const newUser = await this.dataSource.query(
`INSERT INTO shared.users (email, is_email_verified)
VALUES ($1, false)
RETURNING id`,
[email],
);
userId = newUser[0].id;
} else {
userId = userRows[0].id;
}
// 4. Create membership (president role)
await this.dataSource.query(
`INSERT INTO shared.user_organizations (user_id, organization_id, role)
VALUES ($1, $2, 'president')
ON CONFLICT (user_id, organization_id) DO NOTHING`,
[userId, orgId],
);
// 5. Generate invite token and "send" activation email
const inviteToken = await this.authService.generateInviteToken(userId, orgId, email);
const activationUrl = `${this.getAppUrl()}/activate?token=${inviteToken}`;
await this.emailService.sendActivationEmail(email, businessName, activationUrl);
// 6. Initialize onboarding progress
await this.dataSource.query(
`INSERT INTO shared.onboarding_progress (organization_id) VALUES ($1) ON CONFLICT DO NOTHING`,
[orgId],
);
this.logger.log(`✅ Provisioning complete for org=${orgId}, user=${userId}`);
}
private getAppUrl(): string {
return this.configService.get<string>('APP_URL') || 'http://localhost';
}
}