From 35619f24d174f55cb1bee70d68996787b419bde5 Mon Sep 17 00:00:00 2001 From: Temuuleenn Date: Tue, 3 Feb 2026 15:41:32 +0900 Subject: [PATCH] Simplify physical SIM activation and enhance order fulfillment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove PA05-18 semi-black step from physical SIM flow, use PA02-01 directly - Add WHMCS client ID fallback from Salesforce WH_Account__c field - Return service IDs from WHMCS AcceptOrder for proper linking - Add phone number to WHMCS domain field and WHMCS admin URL to Salesforce - Change SIM Inventory status from "In Use" to "Assigned" - Fix SIM services query case sensitivity ("SIM" β†’ "Sim") - Add bash 3.2 compatibility to dev-watch.sh - Add "Most Popular Services" section to landing page - Add "Trusted By" company carousel to About page Co-Authored-By: Claude Opus 4.5 --- apps/bff/scripts/check-sim-status.mjs | 85 +++++++ apps/bff/scripts/dev-watch.sh | 17 +- apps/bff/sim-api-test-log.csv | 4 + .../freebit-account-registration.service.ts | 7 +- .../services/freebit-semiblack.service.ts | 22 +- .../config/opportunity-field-map.ts | 3 + .../salesforce-opportunity.service.ts | 12 +- .../salesforce-sim-inventory.service.ts | 12 +- .../whmcs/services/whmcs-order.service.ts | 17 +- .../order-fulfillment-orchestrator.service.ts | 45 +++- .../order-fulfillment-validator.service.ts | 72 +++++- .../services/sim-fulfillment.service.ts | 50 +--- .../services/services/sim-services.service.ts | 6 +- .../landing-page/views/PublicLandingView.tsx | 214 ++++++++++++++---- .../features/marketing/views/AboutUsView.tsx | 180 +++++++++++---- .../domain/orders/providers/whmcs/mapper.ts | 7 + .../orders/providers/whmcs/raw.types.ts | 2 + 17 files changed, 568 insertions(+), 187 deletions(-) create mode 100644 apps/bff/scripts/check-sim-status.mjs diff --git a/apps/bff/scripts/check-sim-status.mjs b/apps/bff/scripts/check-sim-status.mjs new file mode 100644 index 00000000..756e961c --- /dev/null +++ b/apps/bff/scripts/check-sim-status.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Quick script to check SIM status in Freebit + * Usage: node scripts/check-sim-status.mjs + */ + +const account = process.argv[2] || '02000002470010'; + +const FREEBIT_BASE_URL = 'https://i1-q.mvno.net/emptool/api'; +const FREEBIT_OEM_ID = 'PASI'; +const FREEBIT_OEM_KEY = '6Au3o7wrQNR07JxFHPmf0YfFqN9a31t5'; + +async function getAuthKey() { + const request = { + oemId: FREEBIT_OEM_ID, + oemKey: FREEBIT_OEM_KEY, + }; + + const response = await fetch(`${FREEBIT_BASE_URL}/authOem/`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `json=${JSON.stringify(request)}`, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + if (data.resultCode !== 100 && data.resultCode !== '100') { + throw new Error(`Auth failed: ${data.status?.message || JSON.stringify(data)}`); + } + + return data.authKey; +} + +async function getTrafficInfo(authKey, account) { + const request = { authKey, account }; + + const response = await fetch(`${FREEBIT_BASE_URL}/mvno/getTrafficInfo/`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `json=${JSON.stringify(request)}`, + }); + return response.json(); +} + +async function getAccountDetails(authKey, account) { + const request = { + authKey, + version: '2', + requestDatas: [{ kind: 'MVNO', account }], + }; + + const response = await fetch(`${FREEBIT_BASE_URL}/master/getAcnt/`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `json=${JSON.stringify(request)}`, + }); + return response.json(); +} + +async function main() { + console.log(`\nπŸ” Checking SIM status for: ${account}\n`); + + try { + const authKey = await getAuthKey(); + console.log('βœ“ Authenticated with Freebit\n'); + + // Try getTrafficInfo first (simpler) + console.log('--- Traffic Info (/mvno/getTrafficInfo/) ---'); + const trafficInfo = await getTrafficInfo(authKey, account); + console.log(JSON.stringify(trafficInfo, null, 2)); + + // Try getAcnt for full details + console.log('\n--- Account Details (/master/getAcnt/) ---'); + const details = await getAccountDetails(authKey, account); + console.log(JSON.stringify(details, null, 2)); + + } catch (error) { + console.error('❌ Error:', error.message); + } +} + +main(); diff --git a/apps/bff/scripts/dev-watch.sh b/apps/bff/scripts/dev-watch.sh index 66313763..c49aedec 100755 --- a/apps/bff/scripts/dev-watch.sh +++ b/apps/bff/scripts/dev-watch.sh @@ -75,8 +75,15 @@ log "Starting Node runtime watcher ($RUN_SCRIPT)..." pnpm run -s "$RUN_SCRIPT" & PID_NODE=$! -# If any process exits, stop the rest -wait -n "$PID_BUILD" "$PID_ALIAS" "$PID_NODE" -exit_code=$? -log "A dev process exited (code=$exit_code). Shutting down." -exit "$exit_code" +# If any process exits, stop the rest (compatible with bash 3.2) +while true; do + for pid in "$PID_BUILD" "$PID_ALIAS" "$PID_NODE"; do + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" 2>/dev/null || true + exit_code=$? + log "A dev process exited (code=$exit_code). Shutting down." + exit "$exit_code" + fi + done + sleep 1 +done diff --git a/apps/bff/sim-api-test-log.csv b/apps/bff/sim-api-test-log.csv index dd758a43..7ece45ff 100644 --- a/apps/bff/sim-api-test-log.csv +++ b/apps/bff/sim-api-test-log.csv @@ -84,3 +84,7 @@ Timestamp,API Endpoint,API Method,Phone Number,SIM Identifier,Request Payload,Re 2026-02-02T04:27:46.948Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK 2026-02-02T04:27:47.130Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK 2026-02-02T04:27:59.150Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Success,,OK +2026-02-03T02:22:24.012Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-03T02:22:24.263Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-03T02:44:57.675Z,/mvno/semiblack/addAcnt/,POST,02000002470010,02000002470010,"{""createType"":""new"",""account"":""02000002470010"",""productNumber"":""PT0220024700100"",""planCode"":""PASI_5G"",""shipDate"":""20260203"",""mnp"":{""method"":""10""},""globalIp"":""20"",""aladinOperated"":""20""}",Success,,OK +2026-02-03T02:55:57.379Z,/mvno/semiblack/addAcnt/,POST,07000240050,07000240050,"{""createType"":""new"",""account"":""07000240050"",""productNumber"":""PT0270002400500"",""planCode"":""PASI_10G"",""shipDate"":""20260203"",""mnp"":{""method"":""10""},""globalIp"":""20"",""aladinOperated"":""20""}",Success,,OK diff --git a/apps/bff/src/integrations/freebit/services/freebit-account-registration.service.ts b/apps/bff/src/integrations/freebit/services/freebit-account-registration.service.ts index 3baeb155..1d094a68 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-account-registration.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-account-registration.service.ts @@ -21,6 +21,8 @@ export interface AccountRegistrationParams { account: string; /** Freebit plan code (e.g., "PASI_5G") */ planCode: string; + /** Create type: "new" for new account, "add" for existing (use "add" after PA05-18) */ + createType?: "new" | "add"; /** Global IP assignment: "10" = valid (deprecated), "20" = invalid */ globalIp?: "10" | "20"; /** Priority flag: "10" = valid, "20" = invalid (default) */ @@ -78,7 +80,7 @@ export class FreebitAccountRegistrationService { * @throws BadRequestException if registration fails */ async registerAccount(params: AccountRegistrationParams): Promise { - const { account, planCode, globalIp, priorityFlag } = params; + const { account, planCode, createType = "new", globalIp, priorityFlag } = params; // Validate required parameters if (!account || account.length < 11 || account.length > 14) { @@ -95,6 +97,7 @@ export class FreebitAccountRegistrationService { account, accountLength: account.length, planCode, + createType, hasGlobalIp: !!globalIp, hasPriorityFlag: !!priorityFlag, }); @@ -103,7 +106,7 @@ export class FreebitAccountRegistrationService { // Build payload according to PA02-01 documentation // Note: authKey is added automatically by makeAuthenticatedRequest const payload: FreebitAccountRegistrationRequest = { - createType: "new", + createType, requestDatas: [ { kind: "MVNO", diff --git a/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts b/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts index 35a71d05..171713c3 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts @@ -140,30 +140,12 @@ export class FreebitSemiBlackService { ...(deliveryCode && { deliveryCode }), }; - const response = await this.client.makeAuthenticatedRequest< + // FreebitClientService validates resultCode === "100" before returning + await this.client.makeAuthenticatedRequest< FreebitSemiBlackResponse, Omit >("/mvno/semiblack/addAcnt/", payload); - // Check response status - if (response.resultCode !== 100 || response.status?.statusCode !== 200) { - const errorCode = response.resultCode; - const errorMessage = this.getErrorMessage(errorCode, response.status?.message); - - this.logger.error("Semi-black registration failed (PA05-18)", { - account, - productNumber, - planCode, - resultCode: response.resultCode, - statusCode: response.status?.statusCode, - message: response.status?.message, - }); - - throw new BadRequestException( - `Semi-black registration failed: ${errorMessage} (code: ${errorCode})` - ); - } - this.logger.log("Semi-black SIM registration successful (PA05-18)", { account, productNumber, diff --git a/apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts b/apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts index c885c0b8..a302c55e 100644 --- a/apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts +++ b/apps/bff/src/integrations/salesforce/config/opportunity-field-map.ts @@ -118,6 +118,9 @@ export const OPPORTUNITY_PORTAL_INTEGRATION_FIELDS = { /** WHMCS Service ID (populated after provisioning) */ whmcsServiceId: "WHMCS_Service_ID__c", + /** WHMCS Registration URL (link to service in WHMCS admin) */ + whmcsRegistrationUrl: "WH_Registeration__c", + // NOTE: Cancellation comments and alternative email go on the Cancellation Case, // not on the Opportunity. This keeps Opportunity clean and Case contains all details. } as const; diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts index 1c69b011..ca35df79 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts @@ -828,16 +828,19 @@ export class SalesforceOpportunityService { * * @param opportunityId - Salesforce Opportunity ID * @param whmcsServiceId - WHMCS Service/Hosting ID + * @param whmcsClientId - Optional WHMCS Client ID for building admin URL */ async linkWhmcsServiceToOpportunity( opportunityId: string, - whmcsServiceId: number + whmcsServiceId: number, + whmcsClientId?: number ): Promise { const safeOppId = assertSalesforceId(opportunityId, "opportunityId"); this.logger.log("Linking WHMCS Service to Opportunity", { opportunityId: safeOppId, whmcsServiceId, + whmcsClientId, }); const payload: Record = { @@ -845,6 +848,12 @@ export class SalesforceOpportunityService { [OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId, }; + // Build WHMCS admin URL if client ID is provided + if (whmcsClientId) { + const whmcsAdminUrl = `https://dev-wh.asolutions.co.jp/admin/clientsservices.php?userid=${whmcsClientId}&productselect=${whmcsServiceId}`; + payload[OPPORTUNITY_FIELD_MAP.whmcsRegistrationUrl] = whmcsAdminUrl; + } + try { const updateMethod = this.sf.sobject("Opportunity").update; if (!updateMethod) { @@ -856,6 +865,7 @@ export class SalesforceOpportunityService { this.logger.log("WHMCS Service linked to Opportunity", { opportunityId: safeOppId, whmcsServiceId, + hasRegistrationUrl: !!whmcsClientId, }); } catch (error) { this.logger.error("Failed to link WHMCS Service to Opportunity", { diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts index 2f915b98..ed4c835d 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts @@ -21,7 +21,7 @@ import { SimActivationException } from "@bff/core/exceptions/domain-exceptions.j */ export const SIM_INVENTORY_STATUS = { AVAILABLE: "Available", - IN_USE: "In Use", + ASSIGNED: "Assigned", RESERVED: "Reserved", DEACTIVATED: "Deactivated", } as const; @@ -164,20 +164,20 @@ export class SalesforceSIMInventoryService { } /** - * Update SIM Inventory status to "In Use" after successful activation + * Update SIM Inventory status to "Assigned" after successful activation */ - async markAsInUse(simInventoryId: string): Promise { + async markAsAssigned(simInventoryId: string): Promise { const safeId = assertSalesforceId(simInventoryId, "simInventoryId"); - this.logger.log("Marking SIM Inventory as In Use", { simInventoryId: safeId }); + this.logger.log("Marking SIM Inventory as Assigned", { simInventoryId: safeId }); try { await this.sf.sobject("SIM_Inventory__c").update?.({ Id: safeId, - Status__c: SIM_INVENTORY_STATUS.IN_USE, + Status__c: SIM_INVENTORY_STATUS.ASSIGNED, }); - this.logger.log("SIM Inventory marked as In Use", { simInventoryId: safeId }); + this.logger.log("SIM Inventory marked as Assigned", { simInventoryId: safeId }); } catch (error: unknown) { this.logger.error("Failed to update SIM Inventory status", { simInventoryId: safeId, diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts index b6874eb1..75cf47d1 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts @@ -117,8 +117,13 @@ export class WhmcsOrderService { * WHMCS API Response Structure: * Success: { orderid, invoiceid, serviceids, addonids, domainids } * Error: Thrown by HTTP client before returning + * + * @returns Service IDs created by AcceptOrder (services are created on accept, not on add) */ - async acceptOrder(orderId: number, sfOrderId?: string): Promise { + async acceptOrder( + orderId: number, + sfOrderId?: string + ): Promise<{ serviceIds: number[]; invoiceId?: number }> { this.logger.log("Accepting WHMCS order", { orderId, sfOrderId, @@ -150,11 +155,19 @@ export class WhmcsOrderService { }); } + const serviceIds = this.parseDelimitedIds(parsedResponse.data.serviceids); + const invoiceId = parsedResponse.data.invoiceid + ? parseInt(String(parsedResponse.data.invoiceid), 10) + : undefined; + this.logger.log("WHMCS order accepted successfully", { orderId, - invoiceId: parsedResponse.data.invoiceid, + invoiceId, + serviceIds, sfOrderId, }); + + return { serviceIds, invoiceId }; } catch (error) { // Enhanced error logging with full context this.logger.error("Failed to accept WHMCS order", { diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index 9c4e3286..5a427416 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -317,9 +317,12 @@ export class OrderFulfillmentOrchestrator { // Use domain mapper directly - single transformation! const result = mapOrderToWhmcsItems(context.orderDetails); - // Add SIM custom fields if we have SIM data + // Add SIM data if we have it (phone number goes to domain field and custom fields) if (simFulfillmentResult?.activated && simFulfillmentResult.phoneNumber) { result.whmcsItems.forEach(item => { + // Set phone number as domain (shows in WHMCS Domain field) + item.domain = simFulfillmentResult!.phoneNumber!; + // Also add to custom fields for SIM Number field item.customFields = { ...item.customFields, SimNumber: simFulfillmentResult!.phoneNumber!, @@ -414,8 +417,18 @@ export class OrderFulfillmentOrchestrator { ); } - await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId); - return { orderId: whmcsCreateResult.orderId }; + const acceptResult = await this.whmcsOrderService.acceptOrder( + whmcsCreateResult.orderId, + sfOrderId + ); + + // Update whmcsCreateResult with service IDs from AcceptOrder + // (Services are created on accept, not on add) + if (acceptResult.serviceIds.length > 0) { + whmcsCreateResult.serviceIds = acceptResult.serviceIds; + } + + return { orderId: whmcsCreateResult.orderId, serviceIds: acceptResult.serviceIds }; }), rollback: () => { if (whmcsCreateResult?.orderId) { @@ -438,17 +451,28 @@ export class OrderFulfillmentOrchestrator { // Note: sim_fulfillment step was moved earlier in the flow (before WHMCS) { id: "sf_registration_complete", - description: "Update Salesforce to Registration Completed", + description: "Update Salesforce with WHMCS registration info", execute: this.createTrackedStep(context, "sf_registration_complete", async () => { - const result = await this.salesforceService.updateOrder({ + // For SIM orders that are already "Activated", don't change Status + // Only update WHMCS info. For non-SIM orders, set Status to "Activated" + const isSIMOrder = context.orderDetails?.orderType === "SIM"; + const isAlreadyActivated = simFulfillmentResult?.activated === true; + + const updatePayload: { Id: string; [key: string]: unknown } = { Id: sfOrderId, - Status: "Registration Completed", Activation_Status__c: "Activated", WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(), - }); + }; + + // Only set Status if not already activated (non-SIM orders) + if (!isSIMOrder || !isAlreadyActivated) { + updatePayload.Status = "Activated"; + } + + const result = await this.salesforceService.updateOrder(updatePayload); this.orderEvents.publish(sfOrderId, { orderId: sfOrderId, - status: "Registration Completed", + status: "Activated", activationStatus: "Activated", stage: "completed", source: "fulfillment", @@ -500,7 +524,8 @@ export class OrderFulfillmentOrchestrator { if (serviceId) { await this.opportunityService.linkWhmcsServiceToOpportunity( opportunityId, - serviceId + serviceId, + context.validation?.clientId // Pass client ID for WHMCS admin URL ); } @@ -595,7 +620,7 @@ export class OrderFulfillmentOrchestrator { * 6. mapping (with SIM data for WHMCS) * 7. whmcs_create * 8. whmcs_accept - * 9. sf_registration_complete + * 9. sf_registration_complete (WHMCS info, skip Status for SIM) * 10. opportunity_update */ private initializeSteps(orderType?: string): OrderFulfillmentStep[] { diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts index 3b1dc5dc..f4417f6d 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts @@ -1,6 +1,7 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; +import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { sfOrderIdParamSchema } from "@customer-portal/domain/orders"; @@ -18,6 +19,7 @@ export class OrderFulfillmentValidator { constructor( @Inject(Logger) private readonly logger: Logger, private readonly salesforceService: SalesforceService, + private readonly salesforceAccountService: SalesforceAccountService, private readonly mappingsService: MappingsService, private readonly paymentValidator: PaymentValidatorService ) {} @@ -62,13 +64,45 @@ export class OrderFulfillmentValidator { // Validate AccountId using schema instead of manual type checks const accountId = salesforceAccountIdSchema.parse(sfOrder.AccountId); const mapping = await this.mappingsService.findBySfAccountId(accountId); - if (!mapping?.whmcsClientId) { - throw new BadRequestException(`No WHMCS client mapping found for account ${accountId}`); - } - const clientId = mapping.whmcsClientId; + let clientId: number; + let userId: string | undefined; - // 4. Validate payment method exists - await this.validatePaymentMethod(clientId, mapping.userId); + if (mapping?.whmcsClientId) { + clientId = mapping.whmcsClientId; + userId = mapping.userId; + } else { + // Fallback: Try to get WHMCS client ID from Salesforce Account's WH_Account__c field + const sfAccount = await this.salesforceAccountService.getAccountDetails(accountId); + const whmcsClientId = this.parseWhmcsClientIdFromField(sfAccount?.WH_Account__c); + + if (!whmcsClientId) { + throw new BadRequestException(`No WHMCS client mapping found for account ${accountId}`); + } + + this.logger.log( + "Using WHMCS client ID from Salesforce Account field (no database mapping)", + { + accountId, + whmcsClientId, + whAccountField: sfAccount?.WH_Account__c, + } + ); + + clientId = whmcsClientId; + // Try to find userId by WHMCS client ID for payment validation + const mappingByWhmcs = await this.mappingsService.findByWhmcsClientId(whmcsClientId); + userId = mappingByWhmcs?.userId; + } + + // 4. Validate payment method exists (skip if no userId available) + if (userId) { + await this.validatePaymentMethod(clientId, userId); + } else { + this.logger.warn("Skipping payment method validation - no userId available", { + accountId, + clientId, + }); + } this.logger.log("Fulfillment validation completed successfully", { sfOrderId, @@ -122,4 +156,30 @@ export class OrderFulfillmentValidator { private async validatePaymentMethod(clientId: number, userId: string): Promise { return this.paymentValidator.validatePaymentMethodExists(userId, clientId); } + + /** + * Parse WHMCS client ID from the WH_Account__c field value + * Format: "#9883 - Temuulen Ankhbayar" -> 9883 + */ + private parseWhmcsClientIdFromField(whAccountField: string | null | undefined): number | null { + if (!whAccountField) { + return null; + } + + // Match "#" pattern at the start of the string + const match = whAccountField.match(/^#(\d+)/); + if (!match || !match[1]) { + this.logger.warn("Could not parse WHMCS client ID from WH_Account__c field", { + whAccountField, + }); + return null; + } + + const clientId = parseInt(match[1], 10); + if (isNaN(clientId) || clientId <= 0) { + return null; + } + + return clientId; + } } diff --git a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts index 3074428d..e3510eb4 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -3,7 +3,6 @@ import { Logger } from "nestjs-pino"; import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; import { FreebitAccountRegistrationService } from "@bff/integrations/freebit/services/freebit-account-registration.service.js"; import { FreebitVoiceOptionsService } from "@bff/integrations/freebit/services/freebit-voice-options.service.js"; -import { FreebitSemiBlackService } from "@bff/integrations/freebit/services/freebit-semiblack.service.js"; import { SalesforceSIMInventoryService } from "@bff/integrations/salesforce/services/salesforce-sim-inventory.service.js"; import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders"; import { mapProductToFreebitPlanCode } from "@customer-portal/domain/sim"; @@ -58,7 +57,6 @@ export interface SimFulfillmentResult { export class SimFulfillmentService { constructor( private readonly freebit: FreebitOrchestratorService, - private readonly freebitSemiBlack: FreebitSemiBlackService, private readonly freebitAccountReg: FreebitAccountRegistrationService, private readonly freebitVoiceOptions: FreebitVoiceOptionsService, private readonly simInventory: SalesforceSIMInventoryService, @@ -162,7 +160,7 @@ export class SimFulfillmentService { phoneNumber, }; } else { - // Physical SIM activation flow (PA05-18 + PA02-01 + PA05-05) + // Physical SIM activation flow (PA02-01 + PA05-05) if (!assignedPhysicalSimId) { throw new SimActivationException( "Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)", @@ -265,19 +263,15 @@ export class SimFulfillmentService { } /** - * Activate Physical SIM via Freebit PA05-18 + PA02-01 + PA05-05 APIs + * Activate Physical SIM via Freebit PA02-01 + PA05-05 APIs * - * Flow for Semi-Black Physical SIMs: + * Flow for Physical SIMs: * 1. Fetch SIM Inventory details from Salesforce * 2. Validate SIM status is "Available" * 3. Map product SKU to Freebit plan code - * 4. Call Freebit PA05-18 (Semi-Black Registration) to register the SIM - * 5. Call Freebit PA02-01 (Account Registration) to create MVNO account - * 6. Call Freebit PA05-05 (Voice Options) to configure voice features - * 7. Update SIM Inventory status to "In Use" - * - * Note: PA05-18 must be called before PA02-01, otherwise PA02-01 returns - * error 210 "γ‚’γ‚«γ‚¦γƒ³γƒˆδΈεœ¨γ‚¨γƒ©γƒΌ" (Account not found). + * 4. Call Freebit PA02-01 (Account Registration) with createType="new" + * 5. Call Freebit PA05-05 (Voice Options) to configure voice features + * 6. Update SIM Inventory status to "Used" */ private async activatePhysicalSim(params: { orderId: string; @@ -298,7 +292,7 @@ export class SimFulfillmentService { contactIdentity, } = params; - this.logger.log("Starting Physical SIM activation (PA05-18 + PA02-01 + PA05-05)", { + this.logger.log("Starting Physical SIM activation (PA02-01 + PA05-05)", { orderId, simInventoryId, planSku, @@ -331,28 +325,7 @@ export class SimFulfillmentService { }); try { - // Step 4: Call Freebit PA05-18 (Semi-Black Registration) - // This registers the semi-black SIM in Freebit's system - // Must be called BEFORE PA02-01 or we get error 210 "γ‚’γ‚«γ‚¦γƒ³γƒˆδΈεœ¨γ‚¨γƒ©γƒΌ" - this.logger.log("Calling PA05-18 Semi-Black Registration", { - orderId, - account: accountPhoneNumber, - productNumber: simRecord.ptNumber, - planCode, - }); - - await this.freebitSemiBlack.registerSemiBlackAccount({ - account: accountPhoneNumber, - productNumber: simRecord.ptNumber, - planCode, - }); - - this.logger.log("PA05-18 Semi-Black Registration successful", { - orderId, - account: accountPhoneNumber, - }); - - // Step 5: Call Freebit PA02-01 (Account Registration) + // Step 4: Call Freebit PA02-01 (Account Registration) this.logger.log("Calling PA02-01 Account Registration", { orderId, account: accountPhoneNumber, @@ -362,6 +335,7 @@ export class SimFulfillmentService { await this.freebitAccountReg.registerAccount({ account: accountPhoneNumber, planCode, + createType: "new", }); this.logger.log("PA02-01 Account Registration successful", { @@ -369,7 +343,7 @@ export class SimFulfillmentService { account: accountPhoneNumber, }); - // Step 6: Call Freebit PA05-05 (Voice Options Registration) + // Step 5: Call Freebit PA05-05 (Voice Options Registration) // Only call if we have contact identity data if (contactIdentity) { this.logger.log("Calling PA05-05 Voice Options Registration", { @@ -404,8 +378,8 @@ export class SimFulfillmentService { }); } - // Step 7: Update SIM Inventory status to "In Use" - await this.simInventory.markAsInUse(simInventoryId); + // Step 6: Update SIM Inventory status to "Assigned" + await this.simInventory.markAsAssigned(simInventoryId); this.logger.log("Physical SIM activated successfully", { orderId, diff --git a/apps/bff/src/modules/services/services/sim-services.service.ts b/apps/bff/src/modules/services/services/sim-services.service.ts index 3dd5e4f0..ade226ec 100644 --- a/apps/bff/src/modules/services/services/sim-services.service.ts +++ b/apps/bff/src/modules/services/services/sim-services.service.ts @@ -32,7 +32,7 @@ export class SimServicesService extends BaseServicesService { return this.catalogCache.getCachedServices( cacheKey, async () => { - const soql = this.buildServicesQuery("SIM", [ + const soql = this.buildServicesQuery("Sim", [ "SIM_Data_Size__c", "SIM_Plan_Type__c", "SIM_Has_Family_Discount__c", @@ -67,7 +67,7 @@ export class SimServicesService extends BaseServicesService { return this.catalogCache.getCachedServices( cacheKey, async () => { - const soql = this.buildProductQuery("SIM", "Activation", ["Catalog_Order__c"]); + const soql = this.buildProductQuery("Sim", "Activation", ["Catalog_Order__c"]); const records = await this.executeQuery( soql, "SIM Activation Fees" @@ -116,7 +116,7 @@ export class SimServicesService extends BaseServicesService { return this.catalogCache.getCachedServices( cacheKey, async () => { - const soql = this.buildProductQuery("SIM", "Add-on", [ + const soql = this.buildProductQuery("Sim", "Add-on", [ "Billing_Cycle__c", "Catalog_Order__c", "Bundled_Addon__c", diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index ce2e520c..07c497f1 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -22,6 +22,7 @@ import { Settings, CheckCircle, AlertCircle, + Check, } from "lucide-react"; import { Spinner } from "@/components/atoms/Spinner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; @@ -284,6 +285,7 @@ export function PublicLandingView() { const [heroRef, heroInView] = useInView(); const [trustRef, trustInView] = useInView(); const [solutionsRef, solutionsInView] = useInView(); + const [popularRef, popularInView] = useInView(); const [supportRef, supportInView] = useInView(); const [contactRef, contactInView] = useInView(); @@ -550,16 +552,14 @@ export function PublicLandingView() { }`} > {/* Gradient Background */} -
+
{/* Dot Grid Pattern Overlay */}
@@ -567,7 +567,7 @@ export function PublicLandingView() {
@@ -719,50 +719,50 @@ export function PublicLandingView() {
-
-
- Team collaborating with trust and excellence -
-
-
-

- Built on Trust and Excellence -

-

- For over two decades, we've been helping foreigners, expats, and international - businesses in Japan navigate the tech landscape with confidence. -

+
+
+ Team collaborating with trust and excellence
+
+
+

+ Built on Trust and Excellence +

+

+ For over two decades, we've been helping foreigners, expats, and + international businesses in Japan navigate the tech landscape with confidence. +

+
-
    - {[ - "Full English support, no Japanese needed", - "Foreign credit cards accepted", - "Bilingual contracts and documentation", - ].map(item => ( -
  • - - {item} -
  • - ))} -
- - About our company - - +
    + {[ + "Full English support, no Japanese needed", + "Foreign credit cards accepted", + "Bilingual contracts and documentation", + ].map(item => ( +
  • + + {item} +
  • + ))} +
+ + About our company + + +
-
{/* Solutions Carousel */} @@ -848,6 +848,130 @@ export function PublicLandingView() {
+ {/* Most Popular Services Section */} +
} + className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-white py-14 sm:py-16 transition-all duration-700 ${ + popularInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" + }`} + > +
+ {/* Section Header */} +
+

+ Most Popular Services +

+

+ Get connected with ease. No Japanese required, no complicated paperwork, and full + English support every step of the way. +

+
+ + {/* Two-column card layout */} +
+ {/* Internet Plans Card */} +
+ {/* Popular Badge */} +
+ + Popular + +
+ + {/* Icon Container */} +
+ +
+ + {/* Title */} +

Internet Plans

+ + {/* Description */} +

+ High-speed fiber internet on Japan's reliable NTT network. We handle all the + Japanese paperwork and coordinate installation in English. +

+ + {/* Feature List */} +
    + {[ + "Speeds up to 10 Gbps available", + "English installation coordination", + "No Japanese contracts to sign", + "Foreign credit cards accepted", + ].map(feature => ( +
  • + + + + {feature} +
  • + ))} +
+ + {/* CTA Button */} + + View Plans + + +
+ + {/* Phone Plans Card */} +
+ {/* Popular Badge */} +
+ + Popular + +
+ + {/* Icon Container */} +
+ +
+ + {/* Title */} +

Phone Plans

+ + {/* Description */} +

+ Mobile SIM cards with voice and data on Japan's top network. Sign up online, no + hanko needed, and get your SIM delivered fast. +

+ + {/* Feature List */} +
    + {[ + "Data-only and voice + data options", + "Keep your number with MNP transfer", + "eSIM available for instant activation", + "Flexible plans with no long-term contracts", + ].map(feature => ( +
  • + + + + {feature} +
  • + ))} +
+ + {/* CTA Button */} + + View Plans + + +
+
+
+
+ {/* Remote Support - Full section with mobile-optimized card layout */}
} diff --git a/apps/portal/src/features/marketing/views/AboutUsView.tsx b/apps/portal/src/features/marketing/views/AboutUsView.tsx index 58c82285..8451dcfc 100644 --- a/apps/portal/src/features/marketing/views/AboutUsView.tsx +++ b/apps/portal/src/features/marketing/views/AboutUsView.tsx @@ -23,6 +23,18 @@ import { * and mission statement for Assist Solutions. */ export function AboutUsView() { + // Sample company logos for the trusted by carousel + const trustedCompanies = [ + { name: "Company 1", logo: "/assets/images/placeholder-logo.png" }, + { name: "Company 2", logo: "/assets/images/placeholder-logo.png" }, + { name: "Company 3", logo: "/assets/images/placeholder-logo.png" }, + { name: "Company 4", logo: "/assets/images/placeholder-logo.png" }, + { name: "Company 5", logo: "/assets/images/placeholder-logo.png" }, + { name: "Company 6", logo: "/assets/images/placeholder-logo.png" }, + { name: "Company 7", logo: "/assets/images/placeholder-logo.png" }, + { name: "Company 8", logo: "/assets/images/placeholder-logo.png" }, + ]; + const values = [ { text: "Make technology accessible for everyone, regardless of language barriers.", @@ -127,9 +139,6 @@ export function AboutUsView() { /> {/* Subtle gradient overlay for depth */}
- {/* Gradient fade to next section */} -
-
@@ -163,6 +172,79 @@ export function AboutUsView() {
+ {/* Trusted By Section - Infinite Carousel */} +
+ {/* Gradient fade to next section */} +
+ +
+

+ Trusted by Leading Companies +

+
+ + {/* Infinite Carousel */} +
+ {/* Gradient masks for fade effect on edges */} +
+
+ + {/* Scrolling container */} +
+
+ {/* First set of logos */} + {trustedCompanies.map((company, index) => ( +
+ {company.name} +
+ ))} + {/* Duplicate set for seamless loop */} + {trustedCompanies.map((company, index) => ( +
+ {company.name} +
+ ))} +
+
+
+ + {/* CSS for infinite scroll animation */} + +
+ {/* Business Solutions Carousel */}
{/* Gradient fade to next section */} @@ -219,59 +301,59 @@ export function AboutUsView() {
-
-

Our Values

-

- These principles guide how we serve customers, support our community, and advance our - craft every day. -

-
- - {/* Values Grid - 3 on top, 2 centered on bottom */} -
- {/* Top row - 3 cards */} -
- {values.slice(0, 3).map((value, index) => ( -
- {/* Icon */} -
- {value.icon} -
- {/* Quote mark decoration */} - - {/* Text */} -

{value.text}

-
- ))} +
+

Our Values

+

+ These principles guide how we serve customers, support our community, and advance our + craft every day. +

- {/* Bottom row - 2 cards centered */} -
- {values.slice(3).map((value, index) => ( -
- {/* Icon */} + {/* Values Grid - 3 on top, 2 centered on bottom */} +
+ {/* Top row - 3 cards */} +
+ {values.slice(0, 3).map((value, index) => (
- {value.icon} + {/* Icon */} +
+ {value.icon} +
+ {/* Quote mark decoration */} + + {/* Text */} +

{value.text}

- {/* Quote mark decoration */} - - {/* Text */} -

{value.text}

-
- ))} + ))} +
+ + {/* Bottom row - 2 cards centered */} +
+ {values.slice(3).map((value, index) => ( +
+ {/* Icon */} +
+ {value.icon} +
+ {/* Quote mark decoration */} + + {/* Text */} +

{value.text}

+
+ ))} +
-
{/* Corporate Data Section */} diff --git a/packages/domain/orders/providers/whmcs/mapper.ts b/packages/domain/orders/providers/whmcs/mapper.ts index a743eeaa..db6a9f4d 100644 --- a/packages/domain/orders/providers/whmcs/mapper.ts +++ b/packages/domain/orders/providers/whmcs/mapper.ts @@ -112,6 +112,7 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd const pids: string[] = []; const billingCycles: string[] = []; const quantities: number[] = []; + const domains: string[] = []; const configOptions: string[] = []; const customFields: string[] = []; @@ -119,6 +120,7 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd pids.push(item.productId); billingCycles.push(item.billingCycle); quantities.push(item.quantity); + domains.push(item.domain || ""); // Domain/hostname (phone number for SIM) // Handle config options - WHMCS expects base64 encoded serialized arrays configOptions.push(serializeWhmcsKeyValueMap(item.configOptions)); @@ -146,6 +148,11 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd qty: quantities, }; + // Add domain array if any items have domains + if (domains.some(d => d !== "")) { + payload.domain = domains; + } + // Add optional fields if (params.promoCode) { payload.promocode = params.promoCode; diff --git a/packages/domain/orders/providers/whmcs/raw.types.ts b/packages/domain/orders/providers/whmcs/raw.types.ts index 112594d4..5f9a3c08 100644 --- a/packages/domain/orders/providers/whmcs/raw.types.ts +++ b/packages/domain/orders/providers/whmcs/raw.types.ts @@ -38,6 +38,7 @@ export const whmcsOrderItemSchema = z.object({ "free", ]), quantity: z.number().int().positive("Quantity must be positive").default(1), + domain: z.string().optional(), // Domain/hostname field for the service (phone number for SIM) configOptions: z.record(z.string(), z.string()).optional(), customFields: z.record(z.string(), z.string()).optional(), }); @@ -78,6 +79,7 @@ export const whmcsAddOrderPayloadSchema = z.object({ pid: z.array(z.string()).min(1), billingcycle: z.array(z.string()).min(1), qty: z.array(z.number().int().positive()).min(1), + domain: z.array(z.string()).optional(), // Domain/hostname for each product (phone number for SIM) configoptions: z.array(z.string()).optional(), // base64 encoded customfields: z.array(z.string()).optional(), // base64 encoded });