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 = { 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; constructor( private configService: ConfigService, private dataSource: DataSource, private tenantSchemaService: TenantSchemaService, private authService: AuthService, private emailService: EmailService, ) { const secretKey = this.configService.get('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('STRIPE_WEBHOOK_SECRET') || ''; this.priceMap = { starter: this.configService.get('STRIPE_STARTER_PRICE_ID') || '', professional: this.configService.get('STRIPE_PROFESSIONAL_PRICE_ID') || '', enterprise: this.configService.get('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 { 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 { 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 { 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 { 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 { 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 { // 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('APP_URL') || 'http://localhost'; } }