apps/recallassess/recallassess-api/src/api/client/account/account.service.ts
Properties |
|
Methods |
|
constructor(stripeAccountService: StripeAccountService, stripeService: StripeService, prisma: BNestPrismaService, emailSender: BNestEmailSenderService, promoCodeService: CLPromoCodeService)
|
||||||||||||||||||
|
Parameters :
|
| Async createAccountFromSignup | ||||||
createAccountFromSignup(dto: CreateAccountFromSignupDto)
|
||||||
|
Create company account and admin user after signup/payment
Parameters :
Returns :
Promise<AccountCreationResponseDto>
|
| Private Async sendWelcomeEmail | ||||||
sendWelcomeEmail(data: literal type)
|
||||||
|
Send welcome email
Parameters :
Returns :
Promise<void>
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(CLAccountService.name)
|
import { CLPromoCodeService } from "@api/client/promo-code/promo-code.service";
import { StripeService } from "@api/shared/stripe/services/stripe.service";
import { StripeAccountService } from "@api/shared/stripe/services/stripe-account.service";
import { BNestEmailSenderService, requireEnv } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services/database/prisma/prisma.service";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { PackageType } from "@prisma/client";
import { buildInvoiceBillingAmounts } from "../../../config/billing.config";
import { AccountCreationResponseDto, CreateAccountFromSignupDto } from "./dto/account.dto";
/** Stripe may return `customer` as an id string or an expanded object. */
function extractStripeCustomerIdFromStripeField(customer: unknown): string | undefined {
if (customer == null) {
return undefined;
}
if (typeof customer === "string" && customer.length > 0) {
return customer;
}
if (typeof customer === "object" && customer !== null && "id" in customer) {
const id = (customer as { id?: unknown }).id;
if (typeof id === "string" && id.startsWith("cus_")) {
return id;
}
}
return undefined;
}
@Injectable()
export class CLAccountService {
private readonly logger = new Logger(CLAccountService.name);
constructor(
private stripeAccountService: StripeAccountService,
private stripeService: StripeService,
private prisma: BNestPrismaService,
private emailSender: BNestEmailSenderService,
private promoCodeService: CLPromoCodeService,
) {}
/**
* Create company account and admin user after signup/payment
*/
async createAccountFromSignup(dto: CreateAccountFromSignupDto): Promise<AccountCreationResponseDto> {
this.logger.log(`Creating account for: ${dto.company_email}`);
this.logger.log(
`Account creation request: package_id=${dto.package_id}, license_count=${dto.license_count}, unit_price=${dto.unit_price}, total_amount=${dto.total_amount}, billing_cycle=${dto.billing_cycle}, payment_intent_id=${dto.payment_intent_id}, setup_intent_id=${dto.setup_intent_id}`,
);
try {
// 1. Check if email is already registered
const isRegistered = await this.stripeAccountService.isEmailRegistered(dto.admin_email);
if (isRegistered) {
throw new BadRequestException("Email is already registered");
}
if (!dto.payment_intent_id && !dto.setup_intent_id) {
throw new BadRequestException(
"Payment or card setup is required to create an account. Complete the card step and try again.",
);
}
// 2. For paid plans, verify payment intent was successful
let stripeInvoiceId: string | undefined;
if (dto.payment_intent_id) {
const paymentIntent = await this.stripeService.retrievePaymentIntent(dto.payment_intent_id, [
"payment_method",
"customer",
]);
if (paymentIntent.status !== "succeeded") {
throw new BadRequestException("Payment has not been completed yet");
}
// PaymentIntent.amount (integer cents) is authoritative — local/frontend totals often differ by $0.01
// from fee/VAT rounding; Stripe invoice + DB row must match what was actually captured.
const chargedCents = Math.round(Number(paymentIntent.amount));
const actualAmount = chargedCents / 100;
if (Math.abs(dto.total_amount - actualAmount) >= 0.005) {
this.logger.warn(
`Payment amount sync: frontend sent $${dto.total_amount}, PI captured ${chargedCents}c ($${actualAmount.toFixed(2)}). Using PI amount.`,
);
}
dto.total_amount = actualAmount;
const fromPi = extractStripeCustomerIdFromStripeField(paymentIntent.customer);
if (!dto.stripe_customer_id && fromPi) {
dto.stripe_customer_id = fromPi;
}
// Ensure the paid card is saved + set as invoice default for renewals/subscriptions.
// (Trial signups use SetupIntent; paid signups use PaymentIntent and may not persist card by default.)
const cid = dto.stripe_customer_id?.trim();
const pm = paymentIntent.payment_method;
const pmId = typeof pm === "string" ? pm : pm && pm.object === "payment_method" ? pm.id : null;
if (this.stripeService.isConfigured() && cid && pmId) {
try {
await this.stripeService.attachPaymentMethodToCustomer(pmId, cid);
await this.stripeService.setCustomerDefaultPaymentMethod(cid, pmId);
} catch (e) {
this.logger.warn(
`Signup: could not persist default card from payment intent for customer ${cid}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
// Create a Stripe Invoice record with the correct amount for Billing → Invoices.
// We already charged via PaymentIntent, so mark the invoice paid_out_of_band to avoid double charge.
if (this.stripeService.isConfigured() && cid) {
try {
const inv = await this.stripeService.createFinalizeAndMarkInvoicePaidOutOfBand({
customerId: cid,
amountCents: paymentIntent.amount,
currency: paymentIntent.currency ?? "usd",
description: `RecallAssess signup (${dto.company_email})`,
metadata: {
source: "signup_payment_intent",
payment_intent_id: paymentIntent.id,
company_email: dto.company_email ?? "",
admin_email: dto.admin_email ?? "",
},
});
stripeInvoiceId = inv.id;
} catch (e) {
this.logger.warn(
`Signup: could not create Stripe invoice record for payment intent ${paymentIntent.id}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
}
// 2b. For trial packages ($0), verify setup intent was successful
if (dto.setup_intent_id) {
const setupIntent = await this.stripeService.retrieveSetupIntent(dto.setup_intent_id);
if (setupIntent.status !== "succeeded") {
throw new BadRequestException("Payment method setup has not been completed yet");
}
const fromSi = extractStripeCustomerIdFromStripeField(setupIntent.customer);
if (!dto.stripe_customer_id && fromSi) {
dto.stripe_customer_id = fromSi;
}
// Persist the saved card as the invoice default so future subscription billing can charge.
const cid = dto.stripe_customer_id?.trim();
const pm = setupIntent.payment_method;
const pmId = typeof pm === "string" ? pm : null;
if (this.stripeService.isConfigured() && cid && pmId) {
try {
await this.stripeService.attachPaymentMethodToCustomer(pmId, cid);
await this.stripeService.setCustomerDefaultPaymentMethod(cid, pmId);
} catch (e) {
this.logger.warn(
`Signup: could not persist default card from setup intent for customer ${cid}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
}
if (this.stripeService.isConfigured()) {
const cid = dto.stripe_customer_id?.trim();
if (!cid) {
this.logger.error(
`Signup rejected: missing stripe_customer_id after PI/SI verification (payment_intent_id=${dto.payment_intent_id ?? "none"}, setup_intent_id=${dto.setup_intent_id ?? "none"})`,
);
throw new BadRequestException(
"Could not link this account to Stripe (missing customer id). Please try signing up again or contact support.",
);
}
}
// 3. Get package details
const packageObj = await this.prisma.client.package.findUnique({
where: {
id: dto.package_id,
},
});
if (!packageObj) {
throw new BadRequestException("Invalid package ID");
}
// 3.1. Validate license count against package limits
if (packageObj.minimum_license_required && dto.license_count < packageObj.minimum_license_required) {
this.logger.warn(
`License count validation failed: ${dto.license_count} < ${packageObj.minimum_license_required} (minimum)`,
);
throw new BadRequestException(
`The minimum recommended license count for this plan is ${packageObj.minimum_license_required}. Please increase the number of licenses or contact support if you need a lower count.`,
);
}
// Validate maximum license count (check if license_count_end is set and > 0)
// Note: license_count_end = 0 means no maximum limit (unlimited)
if (
packageObj.license_count_end != null &&
packageObj.license_count_end > 0 &&
packageObj.license_count_end < 999999 &&
dto.license_count > packageObj.license_count_end
) {
this.logger.warn(
`License count validation failed: ${dto.license_count} > ${packageObj.license_count_end} (maximum) for package ${packageObj.id}`,
);
throw new BadRequestException(
`The maximum license count for this plan is ${packageObj.license_count_end}. Please reduce the number of licenses.`,
);
}
const billingCycle = (dto.billing_cycle || "QUARTERLY").toUpperCase();
const isTrialSignup =
!!packageObj.is_trial_package ||
packageObj.package_type === PackageType.FREE_TRIAL ||
packageObj.package_type === PackageType.PRIVATE_VIP_TRIAL;
let unitPrice = 0;
let discountAmount = 0;
let couponCode: string | undefined;
let totalAmount = 0;
let vatFee = 0;
let vatFeePercentage = 0;
if (!isTrialSignup) {
// 3.2. Use provided unit_price and total_amount from frontend (already calculated with billing cycle multiplier)
unitPrice =
dto.unit_price && dto.unit_price > 0
? dto.unit_price
: packageObj.price_per_licence
? Number(packageObj.price_per_licence)
: 0;
// Safety check: If billing cycle doesn't match monthly package price, adjust accordingly
const packageMonthlyPrice = packageObj.price_per_licence ? Number(packageObj.price_per_licence) : 0;
if (Math.abs(unitPrice - packageMonthlyPrice) < 0.01 && packageMonthlyPrice > 0) {
if (billingCycle === "QUARTERLY") {
this.logger.warn(
`Unit price matches monthly price for QUARTERLY billing cycle. Multiplying by 3. Original: ${unitPrice}, Adjusted: ${unitPrice * 3}`,
);
unitPrice = unitPrice * 3;
} else if (billingCycle === "HALF_YEARLY") {
this.logger.warn(
`Unit price matches monthly price for HALF_YEARLY billing cycle. Multiplying by 6. Original: ${unitPrice}, Adjusted: ${unitPrice * 6}`,
);
unitPrice = unitPrice * 6;
} else if (billingCycle === "ANNUAL") {
this.logger.warn(
`Unit price matches monthly price for ANNUAL billing cycle. Multiplying by 10 (2 months free). Original: ${unitPrice}, Adjusted: ${unitPrice * 10}`,
);
unitPrice = unitPrice * 10;
}
}
if (dto.promo_code) {
const promoValidation = await this.promoCodeService.validatePromoCode(dto.promo_code);
if (!promoValidation.is_valid) {
throw new BadRequestException(promoValidation.message || "Invalid promo code");
}
if (promoValidation.discount_percentage) {
const subtotal = unitPrice * dto.license_count;
discountAmount = (subtotal * promoValidation.discount_percentage) / 100;
couponCode = dto.promo_code.toUpperCase();
this.logger.log(
`Promo code applied: ${couponCode}, discount: ${promoValidation.discount_percentage}%, discount amount: ${discountAmount}`,
);
}
}
const originalSubtotal = unitPrice * dto.license_count;
const billingAmounts = buildInvoiceBillingAmounts({
grossLicenseAmount: originalSubtotal,
discountAmount,
adminCountry: dto.admin_country,
processingFeeOnGrossLicense: true,
});
vatFee = billingAmounts.vat_fee ?? 0;
vatFeePercentage = billingAmounts.vat_fee_percentage ?? 0;
const calculatedTotal = billingAmounts.total_amount;
totalAmount =
dto.total_amount && Math.abs(dto.total_amount - calculatedTotal) < 0.01
? dto.total_amount
: calculatedTotal;
} else {
this.logger.log(
`Trial signup pricing forced to $0 (package_type=${packageObj.package_type}); post-trial STARTUP pricing is configured on Stripe subscription sync only.`,
);
}
this.logger.log(
`Account creation pricing - unit_price: ${unitPrice}, discount: ${discountAmount}, vat_amount: ${vatFee}, total_amount: ${totalAmount}, billing_cycle: ${billingCycle}, license_count: ${dto.license_count}, trial_signup: ${isTrialSignup}, admin_country: ${
dto.admin_country || "N/A"
}`,
);
// 4. Create account
const result = await this.stripeAccountService.createAccountFromPayment({
company_name: dto.company_name,
company_email: dto.company_email,
company_phone: dto.company_phone,
admin_first_name: dto.admin_first_name,
admin_last_name: dto.admin_last_name,
admin_email: dto.admin_email,
admin_password: dto.admin_password,
admin_phone: dto.admin_phone,
admin_address: dto.admin_address,
admin_city: dto.admin_city,
admin_country: dto.admin_country,
package_type: packageObj.package_type,
license_count: dto.license_count,
unit_price: unitPrice,
total_amount: totalAmount,
billing_cycle: billingCycle,
discount_amount: discountAmount,
vat_fee_percentage: vatFeePercentage || undefined,
vat_fee: vatFee || undefined,
coupon_code: couponCode,
stripe_payment_intent_id: dto.payment_intent_id,
stripe_setup_intent_id: dto.setup_intent_id,
stripe_invoice_id: stripeInvoiceId,
stripe_customer_id: dto.stripe_customer_id,
package_id: dto.package_id,
});
// 5. Increment promo code usage if promo code was used
if (couponCode && result.success) {
try {
await this.promoCodeService.incrementUsage(couponCode);
this.logger.log(`Promo code usage incremented: ${couponCode}`);
} catch (error) {
// Log error but don't fail account creation if usage increment fails
this.logger.error(`Failed to increment promo code usage for ${couponCode}:`, error);
}
}
if (!result.success) {
throw new BadRequestException(result.error || "Failed to create account");
}
// 5. Send welcome email and email verification email
const frontendUrl = requireEnv("FRONTEND_URL");
const verificationUrl = result.email_verification_token
? `${frontendUrl}/verify-email?token=${result.email_verification_token}`
: null;
// Send welcome email (includes verification link and button)
await this.sendWelcomeEmail({
email: dto.admin_email,
firstName: dto.admin_first_name,
lastName: dto.admin_last_name,
companyName: dto.company_name,
loginUrl: `${frontendUrl}/sign-in`,
verificationUrl: verificationUrl,
participantId: result.participant_id,
companyId: result.company_id,
});
// Note: Email verification link is included in welcome email
// Separate verification email is only sent when user requests resend
this.logger.log(`Account created successfully: Company ID ${result.company_id}`);
return {
success: true,
company_id: result.company_id,
participant_id: result.participant_id,
message: "Account created successfully. Please check your email for login credentials.",
};
} catch (error) {
this.logger.error("Failed to create account:", error);
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException("Failed to create account. Please contact support.");
}
}
/**
* Send welcome email
*/
private async sendWelcomeEmail(data: {
email: string;
firstName: string;
lastName: string;
companyName: string;
loginUrl: string;
verificationUrl: string | null;
participantId?: number;
companyId?: number;
}): Promise<void> {
try {
const metadata: Record<string, unknown> = {
companyName: data.companyName,
triggeredBy: "account_creation",
};
if (typeof data.participantId === "number") {
metadata["participant_id"] = data.participantId;
}
if (typeof data.companyId === "number") {
metadata["company_id"] = data.companyId;
}
await this.emailSender.sendTemplatedEmail({
to: data.email,
templateKey: "account.welcome.email",
variables: {
"user.name": `${data.firstName} ${data.lastName}`,
"user.email": data.email,
"company.name": data.companyName,
"system.loginUrl": data.loginUrl,
"verification.url": data.verificationUrl || "",
},
metadata,
});
this.logger.log(`Welcome email sent to: ${data.email}`);
} catch (emailError) {
// Log email error but don't fail account creation
this.logger.error("Failed to send welcome email:", emailError);
}
}
}