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 }, }; type BillingInterval = 'month' | 'year'; @Injectable() export class BillingService { private readonly logger = new Logger(BillingService.name); private stripe: Stripe | null = null; private webhookSecret: string; private priceMap: Record; private requirePaymentForTrial: boolean; 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.requirePaymentForTrial = this.configService.get('REQUIRE_PAYMENT_METHOD_FOR_TRIAL') === 'true'; // Build price map with backward-compat: new monthly vars fall back to old single vars this.priceMap = { starter: { monthly: this.configService.get('STRIPE_STARTER_MONTHLY_PRICE_ID') || this.configService.get('STRIPE_STARTER_PRICE_ID') || '', annual: this.configService.get('STRIPE_STARTER_ANNUAL_PRICE_ID') || '', }, professional: { monthly: this.configService.get('STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID') || this.configService.get('STRIPE_PROFESSIONAL_PRICE_ID') || '', annual: this.configService.get('STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID') || '', }, enterprise: { monthly: this.configService.get('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID') || this.configService.get('STRIPE_ENTERPRISE_PRICE_ID') || '', annual: this.configService.get('STRIPE_ENTERPRISE_ANNUAL_PRICE_ID') || '', }, }; } // ─── Price Resolution ──────────────────────────────────────── private getPriceId(planId: string, interval: BillingInterval): string { const plan = this.priceMap[planId]; if (!plan) throw new BadRequestException(`Invalid plan: ${planId}`); const priceId = interval === 'year' ? plan.annual : plan.monthly; if (!priceId || priceId.includes('placeholder')) { throw new BadRequestException(`Price not configured for ${planId} (${interval})`); } return priceId; } // ─── Trial Signup (No Card Required) ──────────────────────── /** * Start a free trial without collecting payment. * Creates a Stripe customer + subscription with trial_period_days, * then provisions the organization immediately. */ async startTrial( planId: string, billingInterval: BillingInterval, email: string, businessName: string, ): Promise<{ success: boolean; subscriptionId: string }> { if (!this.stripe) throw new BadRequestException('Stripe not configured'); if (!email) throw new BadRequestException('Email is required'); if (!businessName) throw new BadRequestException('Business name is required'); const priceId = this.getPriceId(planId, billingInterval); // 1. Create Stripe customer const customer = await this.stripe.customers.create({ email, metadata: { plan_id: planId, business_name: businessName, billing_interval: billingInterval }, }); // 2. Create subscription with 14-day trial (no payment method) const subscription = await this.stripe.subscriptions.create({ customer: customer.id, items: [{ price: priceId }], trial_period_days: 14, payment_settings: { save_default_payment_method: 'on_subscription', }, trial_settings: { end_behavior: { missing_payment_method: 'cancel' }, }, metadata: { plan_id: planId, business_name: businessName, billing_interval: billingInterval }, }); const trialEnd = subscription.trial_end ? new Date(subscription.trial_end * 1000) : new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); // 3. Provision organization immediately with trial status await this.provisionOrganization( customer.id, subscription.id, email, planId, businessName, 'trial', billingInterval, trialEnd, ); this.logger.log(`Trial started for ${email}, plan=${planId}, interval=${billingInterval}`); return { success: true, subscriptionId: subscription.id }; } // ─── Checkout Session (Card-required flow / post-trial) ───── /** * Create a Stripe Checkout Session for a new subscription. * Used when REQUIRE_PAYMENT_METHOD_FOR_TRIAL=true, or for * post-trial conversion where the user adds a payment method. */ async createCheckoutSession( planId: string, billingInterval: BillingInterval = 'month', email?: string, businessName?: string, ): Promise<{ url: string }> { if (!this.stripe) throw new BadRequestException('Stripe not configured'); const priceId = this.getPriceId(planId, billingInterval); const sessionConfig: Stripe.Checkout.SessionCreateParams = { 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 || '', billing_interval: billingInterval, }, }; // If trial is card-required, add trial period to checkout if (this.requirePaymentForTrial) { sessionConfig.subscription_data = { trial_period_days: 14, metadata: { plan_id: planId, business_name: businessName || '', billing_interval: billingInterval, }, }; } const session = await this.stripe.checkout.sessions.create(sessionConfig); return { url: session.url! }; } // ─── Webhook Handling ─────────────────────────────────────── /** * 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; case 'customer.subscription.trial_will_end': await this.handleTrialWillEnd(event.data.object as Stripe.Subscription); break; case 'customer.subscription.updated': await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription); break; default: this.logger.log(`Unhandled Stripe event: ${event.type}`); } } // ─── Provisioning Status ──────────────────────────────────── /** * Get provisioning status for a checkout session OR subscription ID. */ async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> { if (!this.stripe) return { status: 'not_configured' }; // Try as checkout session first let customerId: string | null = null; try { const session = await this.stripe.checkout.sessions.retrieve(sessionId); customerId = session.customer as string; } catch { // Not a checkout session — try looking up by subscription ID try { const subscription = await this.stripe.subscriptions.retrieve(sessionId); customerId = subscription.customer as string; } catch { return { status: 'pending' }; } } 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 (['active', 'trial'].includes(rows[0].status)) return { status: 'active' }; return { status: 'provisioning' }; } // ─── Stripe Customer Portal ───────────────────────────────── /** * Create a Stripe Customer Portal session for managing subscription. */ async createPortalSession(orgId: string): Promise<{ url: string }> { if (!this.stripe) throw new BadRequestException('Stripe is not configured'); const rows = await this.dataSource.query( `SELECT stripe_customer_id, stripe_subscription_id, status FROM shared.organizations WHERE id = $1`, [orgId], ); if (rows.length === 0) { throw new BadRequestException('Organization not found'); } let customerId = rows[0].stripe_customer_id; // Fallback: if customer ID is missing but subscription exists, retrieve customer from subscription if (!customerId && rows[0].stripe_subscription_id) { try { const sub = await this.stripe.subscriptions.retrieve(rows[0].stripe_subscription_id) as Stripe.Subscription; customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; if (customerId) { // Backfill the customer ID for future calls await this.dataSource.query( `UPDATE shared.organizations SET stripe_customer_id = $1 WHERE id = $2`, [customerId, orgId], ); this.logger.log(`Backfilled stripe_customer_id=${customerId} for org=${orgId}`); } } catch (err) { this.logger.warn(`Failed to retrieve customer from subscription: ${(err as Error).message}`); } } if (!customerId) { const status = rows[0].status; if (status === 'trial') { throw new BadRequestException( 'Billing portal is not available during your free trial. Add a payment method when your trial ends to manage your subscription.', ); } throw new BadRequestException('No Stripe customer found for this organization. Please contact support.'); } const session = await this.stripe.billingPortal.sessions.create({ customer: customerId, return_url: `${this.getAppUrl()}/settings`, }); return { url: session.url }; } // ─── Subscription Info ────────────────────────────────────── /** * Get current subscription details for the Settings billing tab. */ async getSubscriptionInfo(orgId: string): Promise<{ plan: string; planName: string; billingInterval: string; status: string; collectionMethod: string; trialEndsAt: string | null; currentPeriodEnd: string | null; cancelAtPeriodEnd: boolean; hasStripeCustomer: boolean; }> { const rows = await this.dataSource.query( `SELECT plan_level, billing_interval, status, collection_method, trial_ends_at, stripe_subscription_id, stripe_customer_id FROM shared.organizations WHERE id = $1`, [orgId], ); if (rows.length === 0) throw new BadRequestException('Organization not found'); const org = rows[0]; let currentPeriodEnd: string | null = null; let cancelAtPeriodEnd = false; // Fetch live data from Stripe if available if (this.stripe && org.stripe_subscription_id) { try { const sub = await this.stripe.subscriptions.retrieve(org.stripe_subscription_id, { expand: ['items.data'], }) as Stripe.Subscription; // current_period_end is on the subscription item in newer Stripe API versions const firstItem = sub.items?.data?.[0]; if (firstItem?.current_period_end) { currentPeriodEnd = new Date(firstItem.current_period_end * 1000).toISOString(); } cancelAtPeriodEnd = sub.cancel_at_period_end; } catch { // Non-critical — use DB data only } } return { plan: org.plan_level || 'starter', planName: PLAN_FEATURES[org.plan_level]?.name || org.plan_level || 'Starter', billingInterval: org.billing_interval || 'month', status: org.status || 'active', collectionMethod: org.collection_method || 'charge_automatically', trialEndsAt: org.trial_ends_at ? new Date(org.trial_ends_at).toISOString() : null, currentPeriodEnd, cancelAtPeriodEnd, hasStripeCustomer: !!org.stripe_customer_id, }; } // ─── Invoice / ACH Billing (Admin) ────────────────────────── /** * Switch a customer's subscription to invoice collection (ACH/wire). * Admin-only operation for enterprise customers. */ async switchToInvoiceBilling( orgId: string, collectionMethod: 'charge_automatically' | 'send_invoice', daysUntilDue: number = 30, ): Promise { if (!this.stripe) throw new BadRequestException('Stripe not configured'); const rows = await this.dataSource.query( `SELECT stripe_subscription_id, stripe_customer_id FROM shared.organizations WHERE id = $1`, [orgId], ); if (rows.length === 0 || !rows[0].stripe_subscription_id) { throw new BadRequestException('No Stripe subscription found for this organization'); } const updateParams: Stripe.SubscriptionUpdateParams = { collection_method: collectionMethod, }; if (collectionMethod === 'send_invoice') { updateParams.days_until_due = daysUntilDue; } await this.stripe.subscriptions.update(rows[0].stripe_subscription_id, updateParams); // Update DB await this.dataSource.query( `UPDATE shared.organizations SET collection_method = $1, updated_at = NOW() WHERE id = $2`, [collectionMethod, orgId], ); this.logger.log(`Billing method updated for org ${orgId}: ${collectionMethod}`); } // ─── Webhook Handlers ────────────────────────────────────── 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'; const billingInterval = (session.metadata?.billing_interval || 'month') as BillingInterval; this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`); try { // Determine if this is a trial checkout (card required for trial) let status: 'active' | 'trial' = 'active'; let trialEnd: Date | undefined; if (this.stripe && subscriptionId) { const sub = await this.stripe.subscriptions.retrieve(subscriptionId); if (sub.status === 'trialing' && sub.trial_end) { status = 'trial'; trialEnd = new Date(sub.trial_end * 1000); } } await this.provisionOrganization( customerId, subscriptionId, email, planId, businessName, status, billingInterval, trialEnd, ); } 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/trial await this.dataSource.query( `UPDATE shared.organizations SET status = 'active', updated_at = NOW() WHERE stripe_customer_id = $1 AND status IN ('trial', 'past_due')`, [customerId], ); } private async handlePaymentFailed(invoice: Stripe.Invoice): Promise { const customerId = invoice.customer as string; const rows = await this.dataSource.query( `SELECT email, name FROM shared.organizations WHERE stripe_customer_id = $1`, [customerId], ); // Set org to past_due for grace period (read-only access) await this.dataSource.query( `UPDATE shared.organizations SET status = 'past_due', updated_at = NOW() WHERE stripe_customer_id = $1 AND status = 'active'`, [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}`); } private async handleTrialWillEnd(subscription: Stripe.Subscription): Promise { const customerId = subscription.customer as string; const rows = await this.dataSource.query( `SELECT id, email, name FROM shared.organizations WHERE stripe_customer_id = $1`, [customerId], ); if (rows.length === 0) return; const org = rows[0]; const daysRemaining = 3; // This webhook fires 3 days before trial end const settingsUrl = `${this.getAppUrl()}/settings`; if (org.email) { await this.emailService.sendTrialEndingEmail( org.email, org.name || 'Your organization', daysRemaining, settingsUrl, ); } this.logger.log(`Trial ending soon for customer ${customerId}, org ${org.id}`); } private async handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise { const customerId = subscription.customer as string; // Determine new status let newStatus: string; switch (subscription.status) { case 'trialing': newStatus = 'trial'; break; case 'active': newStatus = 'active'; break; case 'past_due': newStatus = 'past_due'; break; case 'canceled': case 'unpaid': newStatus = 'archived'; break; default: return; // Don't update for other statuses } // Determine billing interval from the subscription items let billingInterval: BillingInterval = 'month'; if (subscription.items?.data?.[0]?.price?.recurring?.interval === 'year') { billingInterval = 'year'; } // Determine plan from price metadata or existing mapping let planId: string | null = null; const activePriceId = subscription.items?.data?.[0]?.price?.id; if (activePriceId) { for (const [plan, prices] of Object.entries(this.priceMap)) { if (prices.monthly === activePriceId || prices.annual === activePriceId) { planId = plan; break; } } } // Build update query dynamically const updates: string[] = [`status = '${newStatus}'`, `billing_interval = '${billingInterval}'`, `updated_at = NOW()`]; if (planId) { updates.push(`plan_level = '${planId}'`); } if (subscription.collection_method) { updates.push(`collection_method = '${subscription.collection_method}'`); } await this.dataSource.query( `UPDATE shared.organizations SET ${updates.join(', ')} WHERE stripe_customer_id = $1`, [customerId], ); this.logger.log(`Subscription updated for customer ${customerId}: status=${newStatus}, interval=${billingInterval}`); } // ─── Provisioning ────────────────────────────────────────── /** * Full provisioning flow: create org, schema, user, invite token, email. */ async provisionOrganization( customerId: string, subscriptionId: string, email: string, planId: string, businessName: string, status: 'active' | 'trial' = 'active', billingInterval: BillingInterval = 'month', trialEndsAt?: Date, ): 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, billing_interval, trial_ends_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (stripe_customer_id) DO UPDATE SET stripe_subscription_id = EXCLUDED.stripe_subscription_id, plan_level = EXCLUDED.plan_level, status = EXCLUDED.status, billing_interval = EXCLUDED.billing_interval, trial_ends_at = EXCLUDED.trial_ends_at, updated_at = NOW() RETURNING id, schema_name`, [businessName, schemaName, status, planId, customerId, subscriptionId, email, billingInterval, trialEndsAt || null], ); 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}, status=${status}`); } private getAppUrl(): string { return this.configService.get('APP_URL') || 'http://localhost'; } }