File

apps/recallassess/recallassess-api/src/api/client/account/account.service.ts

Index

Properties
Methods

Constructor

constructor(stripeAccountService: StripeAccountService, stripeService: StripeService, prisma: BNestPrismaService, emailSender: BNestEmailSenderService, promoCodeService: CLPromoCodeService)
Parameters :
Name Type Optional
stripeAccountService StripeAccountService No
stripeService StripeService No
prisma BNestPrismaService No
emailSender BNestEmailSenderService No
promoCodeService CLPromoCodeService No

Methods

Async createAccountFromSignup
createAccountFromSignup(dto: CreateAccountFromSignupDto)

Create company account and admin user after signup/payment

Parameters :
Name Type Optional
dto CreateAccountFromSignupDto No
Private Async sendWelcomeEmail
sendWelcomeEmail(data: literal type)

Send welcome email

Parameters :
Name Type Optional
data literal type No
Returns : Promise<void>

Properties

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);
    }
  }
}

results matching ""

    No results matching ""