- Fix "Manage Billing" button error for trial orgs without Stripe customer; add fallback to retrieve customer from subscription, show helpful message for trial users, and surface real error messages in the UI - Add "Balance As-Of Date" field to onboarding wizard so opening balance journal entries use the correct statement date instead of today - Add "Total Unit Count" field to onboarding wizard assessment group step so cash flow projections work immediately - Remove broken budget upload step from onboarding wizard (was using legacy budgets endpoint); replace with guidance to use Budget Planning page - Replace bare "No budget plan lines" text with rich onboarding-style card featuring download template and upload CSV action buttons Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
679 lines
25 KiB
TypeScript
679 lines
25 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 },
|
|
};
|
|
|
|
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<string, { monthly: string; annual: string }>;
|
|
private requirePaymentForTrial: boolean;
|
|
|
|
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.requirePaymentForTrial =
|
|
this.configService.get<string>('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<string>('STRIPE_STARTER_MONTHLY_PRICE_ID')
|
|
|| this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '',
|
|
annual: this.configService.get<string>('STRIPE_STARTER_ANNUAL_PRICE_ID') || '',
|
|
},
|
|
professional: {
|
|
monthly: this.configService.get<string>('STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID')
|
|
|| this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '',
|
|
annual: this.configService.get<string>('STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID') || '',
|
|
},
|
|
enterprise: {
|
|
monthly: this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID')
|
|
|| this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '',
|
|
annual: this.configService.get<string>('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<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;
|
|
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<void> {
|
|
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<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';
|
|
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<void> {
|
|
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<void> {
|
|
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<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}`);
|
|
}
|
|
|
|
private async handleTrialWillEnd(subscription: Stripe.Subscription): Promise<void> {
|
|
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<void> {
|
|
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<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, 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<string>('APP_URL') || 'http://localhost';
|
|
}
|
|
}
|