Assist_Design/apps/bff/src/modules/checkout-registration/services/checkout-registration.service.ts
barsa 2b183272cf Implement Notifications Feature and Enhance BFF Modules
- Introduced a new Notification model in the Prisma schema to manage in-app notifications for users.
- Integrated the NotificationsModule into the BFF application, allowing for the handling of notifications related to user actions and events.
- Updated the CatalogCdcSubscriber to create notifications for account eligibility and verification status changes, improving user engagement.
- Enhanced the CheckoutRegistrationService to create opportunities for SIM orders, integrating with the new notifications system.
- Refactored various modules to include the NotificationsModule, ensuring seamless interaction and notification handling across the application.
- Updated the frontend to display notification alerts in the AppShell header, enhancing user experience and accessibility.
2025-12-23 11:36:44 +09:00

360 lines
12 KiB
TypeScript

import { BadRequestException, Inject, Injectable, Optional } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import * as argon2 from "argon2";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { AuthTokenService } from "@bff/modules/auth/infra/token/token.service.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
import { OpportunityMatchingService } from "@bff/modules/orders/services/opportunity-matching.service.js";
import type { OrderTypeValue } from "@customer-portal/domain/orders";
/**
* Request type for checkout registration
*/
interface CheckoutRegisterData {
email: string;
firstName: string;
lastName: string;
phone: string;
phoneCountryCode: string;
password: string;
address: {
address1: string;
address2?: string;
city: string;
state: string;
postcode: string;
country: string;
};
/** Optional order type for Opportunity creation */
orderType?: OrderTypeValue;
}
/**
* Checkout Registration Service
*
* Orchestrates the multi-step registration flow during checkout:
* 1. Create Salesforce Account (generates SF_Account_No__c)
* 2. Create Salesforce Contact (linked to Account)
* 3. Create WHMCS Client (for billing)
* 4. Update SF Account with WH_Account__c
* 5. Create Portal User (with password hash)
* 6. Create ID Mapping (links all system IDs)
* 7. Generate auth tokens (auto-login user)
*/
@Injectable()
export class CheckoutRegistrationService {
constructor(
private readonly prisma: PrismaService,
private readonly tokenService: AuthTokenService,
private readonly salesforceAccountService: SalesforceAccountService,
private readonly whmcsClientService: WhmcsClientService,
private readonly whmcsPaymentService: WhmcsPaymentService,
private readonly mappingsService: MappingsService,
@Optional() private readonly opportunityMatchingService: OpportunityMatchingService | null,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Register a new customer during checkout
*
* CRITICAL: This creates accounts in ALL systems synchronously.
* If any step fails, we attempt rollback of previous steps.
*/
async registerForCheckout(data: CheckoutRegisterData): Promise<{
user: { id: string; email: string; firstname: string; lastname: string };
session: { expiresAt: string; refreshExpiresAt: string };
tokens?: { accessToken: string; refreshToken: string };
sfAccountNumber: string;
}> {
this.logger.log("Starting checkout registration", { email: data.email });
// Track created resources for rollback
let sfAccountId: string | null = null;
let sfContactId: string | null = null;
let sfAccountNumber: string | null = null;
let whmcsClientId: number | null = null;
let portalUserId: string | null = null;
try {
// Check for existing account by email
const existingAccount = await this.salesforceAccountService.findByEmail(data.email);
if (existingAccount) {
throw new BadRequestException(
"An account with this email already exists. Please sign in instead."
);
}
// Check for existing portal user
const existingUser = await this.prisma.user.findUnique({
where: { email: data.email.toLowerCase() },
});
if (existingUser) {
throw new BadRequestException(
"An account with this email already exists. Please sign in instead."
);
}
// Step 1: Create Salesforce Account
this.logger.log("Step 1: Creating Salesforce Account");
const sfAccount = await this.salesforceAccountService.createAccount({
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: this.formatPhone(data.phoneCountryCode, data.phone),
address: {
address1: data.address.address1,
address2: data.address.address2,
city: data.address.city,
state: data.address.state,
postcode: data.address.postcode,
country: data.address.country,
},
});
sfAccountId = sfAccount.accountId;
sfAccountNumber = sfAccount.accountNumber;
// Step 2: Create Salesforce Contact
this.logger.log("Step 2: Creating Salesforce Contact");
const sfContact = await this.salesforceAccountService.createContact({
accountId: sfAccountId,
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: this.formatPhone(data.phoneCountryCode, data.phone),
address: {
address1: data.address.address1,
address2: data.address.address2,
city: data.address.city,
state: data.address.state,
postcode: data.address.postcode,
country: data.address.country,
},
});
sfContactId = sfContact.contactId;
// Step 3: Create WHMCS Client
this.logger.log("Step 3: Creating WHMCS Client");
const whmcsResult = await this.whmcsClientService.addClient({
firstname: data.firstName,
lastname: data.lastName,
email: data.email,
phonenumber: this.formatPhone(data.phoneCountryCode, data.phone),
address1: data.address.address1,
city: data.address.city,
state: data.address.state,
postcode: data.address.postcode,
country: this.mapCountryToCode(data.address.country),
password2: data.password,
});
whmcsClientId = whmcsResult.clientId;
// Step 4: Update Salesforce Account with WHMCS ID
this.logger.log("Step 4: Linking Salesforce to WHMCS");
await this.salesforceAccountService.updatePortalFields(sfAccountId, {
whmcsAccountId: whmcsClientId,
status: "Active",
source: "Portal Checkout",
});
// Step 5 & 6: Create Portal User and ID Mapping in transaction
this.logger.log("Step 5: Creating Portal User");
const user = await this.prisma.$transaction(async tx => {
const passwordHash = await argon2.hash(data.password);
const newUser = await tx.user.create({
data: {
email: data.email.toLowerCase(),
passwordHash,
emailVerified: false,
},
});
// Step 6: Create ID Mapping
this.logger.log("Step 6: Creating ID Mapping");
await tx.idMapping.create({
data: {
userId: newUser.id,
whmcsClientId: whmcsClientId!,
sfAccountId: sfAccountId!,
// Note: sfContactId is not in schema, stored in Salesforce Contact record
},
});
return newUser;
});
portalUserId = user.id;
// Step 7: Create Opportunity for SIM orders
// Note: Internet orders create Opportunity during eligibility request, not registration
let opportunityId: string | null = null;
if (data.orderType === "SIM" && this.opportunityMatchingService && sfAccountId) {
this.logger.log("Step 7: Creating Opportunity for SIM checkout registration");
try {
opportunityId =
await this.opportunityMatchingService.createOpportunityForCheckoutRegistration(
sfAccountId
);
} catch (error) {
// Log but don't fail registration - Opportunity can be created later during order
this.logger.warn(
"Failed to create Opportunity during registration, will create during order",
{
error: getErrorMessage(error),
sfAccountId,
}
);
}
}
// Step 8: Generate auth tokens
this.logger.log("Step 8: Generating auth tokens");
const tokens = await this.tokenService.generateTokenPair({
id: user.id,
email: user.email,
});
this.logger.log("Checkout registration completed successfully", {
userId: user.id,
sfAccountId,
sfContactId,
sfAccountNumber,
whmcsClientId,
opportunityId,
});
return {
user: {
id: user.id,
email: user.email,
firstname: data.firstName,
lastname: data.lastName,
},
session: {
expiresAt: tokens.expiresAt,
refreshExpiresAt: tokens.refreshExpiresAt,
},
tokens: {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
sfAccountNumber: sfAccountNumber,
};
} catch (error) {
this.logger.error("Checkout registration failed, initiating rollback", {
error: getErrorMessage(error),
sfAccountId,
sfContactId,
whmcsClientId,
portalUserId,
});
// Rollback in reverse order
await this.rollbackRegistration({
portalUserId,
whmcsClientId,
sfAccountId,
});
// Re-throw the original error
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException("Registration failed. Please try again or contact support.");
}
}
/**
* Check if user has a valid payment method
*/
async getPaymentStatus(userId: string): Promise<{ hasPaymentMethod: boolean }> {
try {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
return { hasPaymentMethod: false };
}
const paymentMethodList = await this.whmcsPaymentService.getPaymentMethods(
mapping.whmcsClientId,
userId
);
return {
hasPaymentMethod: paymentMethodList.totalCount > 0,
};
} catch (error) {
this.logger.error("Failed to check payment status", {
error: getErrorMessage(error),
userId,
});
return { hasPaymentMethod: false };
}
}
/**
* Rollback registration - best effort cleanup
*/
private async rollbackRegistration(resources: {
portalUserId: string | null;
whmcsClientId: number | null;
sfAccountId: string | null;
}): Promise<void> {
// Portal user - can delete
if (resources.portalUserId) {
try {
await this.prisma.idMapping.deleteMany({
where: { userId: resources.portalUserId },
});
await this.prisma.user.delete({
where: { id: resources.portalUserId },
});
this.logger.log("Rollback: Deleted portal user", { userId: resources.portalUserId });
} catch (e) {
this.logger.error("Rollback failed: Portal user", { error: getErrorMessage(e) });
}
}
// WHMCS client - log for manual cleanup (WHMCS doesn't support API deletion)
if (resources.whmcsClientId) {
this.logger.warn("Rollback: WHMCS client created but not deleted (requires manual cleanup)", {
clientId: resources.whmcsClientId,
});
}
// Salesforce Account - intentionally NOT deleted
// It's better to have an orphaned SF Account that can be cleaned up
// than to lose potential customer data
if (resources.sfAccountId) {
this.logger.warn("Rollback: Salesforce Account not deleted (intentional)", {
sfAccountId: resources.sfAccountId,
action: "Manual cleanup may be required",
});
}
}
/**
* Format phone number with country code
*/
private formatPhone(countryCode: string, phone: string): string {
const cc = countryCode.replace(/\D/g, "");
const num = phone.replace(/\D/g, "");
return `+${cc}.${num}`;
}
/**
* Map country name to ISO code
*/
private mapCountryToCode(country: string): string {
const countryMap: Record<string, string> = {
japan: "JP",
"united states": "US",
"united kingdom": "GB",
// Add more as needed
};
return countryMap[country.toLowerCase()] || country.slice(0, 2).toUpperCase();
}
}