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>
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
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';
|
|
}
|
|
}
|