fix: billing portal error, onboarding wizard improvements, budget empty state
- 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>
This commit is contained in:
@@ -74,9 +74,9 @@ export class AccountsService {
|
||||
|
||||
// Create opening balance journal entry if initialBalance is provided and non-zero
|
||||
if (dto.initialBalance && dto.initialBalance !== 0) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const balanceDate = dto.initialBalanceDate ? new Date(dto.initialBalanceDate) : new Date();
|
||||
const year = balanceDate.getFullYear();
|
||||
const month = balanceDate.getMonth() + 1;
|
||||
|
||||
// Find the current fiscal period
|
||||
const periods = await this.tenant.query(
|
||||
@@ -111,12 +111,14 @@ export class AccountsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Create the journal entry
|
||||
// Create the journal entry (use provided balance date or today)
|
||||
const entryDate = dto.initialBalanceDate || new Date().toISOString().split('T')[0];
|
||||
const jeInsert = await this.tenant.query(
|
||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||
VALUES (CURRENT_DATE, $1, 'opening_balance', $2, true, NOW(), $3)
|
||||
VALUES ($1::date, $2, 'opening_balance', $3, true, NOW(), $4)
|
||||
RETURNING id`,
|
||||
[
|
||||
entryDate,
|
||||
`Opening balance for ${dto.name}`,
|
||||
fiscalPeriodId,
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
|
||||
@@ -37,6 +37,11 @@ export class CreateAccountDto {
|
||||
@IsOptional()
|
||||
initialBalance?: number;
|
||||
|
||||
@ApiProperty({ required: false, description: 'ISO date string (YYYY-MM-DD) for when the initial balance was accurate' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
initialBalanceDate?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
|
||||
@IsOptional()
|
||||
interestRate?: number;
|
||||
|
||||
@@ -279,18 +279,49 @@ export class BillingService {
|
||||
* Create a Stripe Customer Portal session for managing subscription.
|
||||
*/
|
||||
async createPortalSession(orgId: string): Promise<{ url: string }> {
|
||||
if (!this.stripe) throw new BadRequestException('Stripe not configured');
|
||||
if (!this.stripe) throw new BadRequestException('Stripe is not configured');
|
||||
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT stripe_customer_id FROM shared.organizations WHERE id = $1`,
|
||||
`SELECT stripe_customer_id, stripe_subscription_id, status
|
||||
FROM shared.organizations WHERE id = $1`,
|
||||
[orgId],
|
||||
);
|
||||
if (rows.length === 0 || !rows[0].stripe_customer_id) {
|
||||
throw new BadRequestException('No Stripe customer found for this organization');
|
||||
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: rows[0].stripe_customer_id,
|
||||
customer: customerId,
|
||||
return_url: `${this.getAppUrl()}/settings`,
|
||||
});
|
||||
|
||||
@@ -311,10 +342,11 @@ export class BillingService {
|
||||
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
|
||||
trial_ends_at, stripe_subscription_id, stripe_customer_id
|
||||
FROM shared.organizations WHERE id = $1`,
|
||||
[orgId],
|
||||
);
|
||||
@@ -351,6 +383,7 @@ export class BillingService {
|
||||
trialEndsAt: org.trial_ends_at ? new Date(org.trial_ends_at).toISOString() : null,
|
||||
currentPeriodEnd,
|
||||
cancelAtPeriodEnd,
|
||||
hasStripeCustomer: !!org.stripe_customer_id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user