Simplify physical SIM activation and enhance order fulfillment

- 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 <noreply@anthropic.com>
This commit is contained in:
Temuuleenn 2026-02-03 15:41:32 +09:00
parent 9fbb6ed61e
commit 35619f24d1
17 changed files with 568 additions and 187 deletions

View File

@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* Quick script to check SIM status in Freebit
* Usage: node scripts/check-sim-status.mjs <phone_number>
*/
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();

View File

@ -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

View File

@ -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

1 Timestamp API Endpoint API Method Phone Number SIM Identifier Request Payload Response Status Error Additional Info
84 2026-02-02T04:27:46.948Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
85 2026-02-02T04:27:47.130Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
86 2026-02-02T04:27:59.150Z /mvno/contractline/change/ POST 02000215161148 02000215161148 {"account":"02000215161148","contractLine":"5G","eid":"89033023426200000000006103081142"} Success OK
87 2026-02-03T02:22:24.012Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
88 2026-02-03T02:22:24.263Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
89 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
90 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

View File

@ -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<void> {
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",

View File

@ -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<FreebitSemiBlackRequest, "authKey">
>("/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,

View File

@ -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;

View File

@ -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<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Linking WHMCS Service to Opportunity", {
opportunityId: safeOppId,
whmcsServiceId,
whmcsClientId,
});
const payload: Record<string, unknown> = {
@ -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", {

View File

@ -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<void> {
async markAsAssigned(simInventoryId: string): Promise<void> {
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,

View File

@ -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<void> {
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", {

View File

@ -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[] {

View File

@ -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<void> {
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 "#<number>" 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;
}
}

View File

@ -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,

View File

@ -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<SalesforceProduct2WithPricebookEntries>(
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",

View File

@ -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 */}
<div
className="absolute inset-0 bg-gradient-to-br from-slate-50 via-white to-sky-50/80"
/>
<div className="absolute inset-0 bg-gradient-to-br from-slate-50 via-white to-sky-50/80" />
{/* Dot Grid Pattern Overlay */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `radial-gradient(circle at center, oklch(0.65 0.05 234.4 / 0.15) 1px, transparent 1px)`,
backgroundSize: '24px 24px',
backgroundSize: "24px 24px",
}}
/>
@ -567,7 +567,7 @@ export function PublicLandingView() {
<div
className="absolute -top-32 -right-32 w-96 h-96 rounded-full pointer-events-none"
style={{
background: 'radial-gradient(circle, oklch(0.85 0.08 200 / 0.3) 0%, transparent 70%)',
background: "radial-gradient(circle, oklch(0.85 0.08 200 / 0.3) 0%, transparent 70%)",
}}
/>
@ -719,50 +719,50 @@ export function PublicLandingView() {
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-[#e8f5ff] pointer-events-none" />
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 py-14 sm:py-16">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1.1fr] gap-10 lg:gap-14 items-center">
<div className="relative w-full overflow-hidden rounded-2xl shadow-md border border-border/60 bg-white aspect-[4/5]">
<Image
src="/assets/images/Why_us.png"
alt="Team collaborating with trust and excellence"
fill
priority
className="object-cover"
sizes="(max-width: 1024px) 100vw, 40vw"
/>
</div>
<div className="space-y-6">
<div>
<h2 className="text-2xl sm:text-3xl font-extrabold text-primary uppercase tracking-wide mb-3">
Built on Trust and Excellence
</h2>
<p className="text-xl sm:text-2xl font-semibold text-foreground leading-relaxed">
For over two decades, we&apos;ve been helping foreigners, expats, and international
businesses in Japan navigate the tech landscape with confidence.
</p>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1.1fr] gap-10 lg:gap-14 items-center">
<div className="relative w-full overflow-hidden rounded-2xl shadow-md border border-border/60 bg-white aspect-[4/5]">
<Image
src="/assets/images/Why_us.png"
alt="Team collaborating with trust and excellence"
fill
priority
className="object-cover"
sizes="(max-width: 1024px) 100vw, 40vw"
/>
</div>
<div className="space-y-6">
<div>
<h2 className="text-2xl sm:text-3xl font-extrabold text-primary uppercase tracking-wide mb-3">
Built on Trust and Excellence
</h2>
<p className="text-xl sm:text-2xl font-semibold text-foreground leading-relaxed">
For over two decades, we&apos;ve been helping foreigners, expats, and
international businesses in Japan navigate the tech landscape with confidence.
</p>
</div>
<ul className="space-y-3 text-foreground">
{[
"Full English support, no Japanese needed",
"Foreign credit cards accepted",
"Bilingual contracts and documentation",
].map(item => (
<li key={item} className="flex items-center gap-3 text-base font-semibold">
<BadgeCheck className="h-5 w-5 text-primary" />
<span>{item}</span>
</li>
))}
</ul>
<Link
href="/about"
className="inline-flex items-center gap-2 text-primary font-semibold hover:text-primary/80 transition-colors"
>
About our company
<ArrowRight className="h-4 w-4" />
</Link>
<ul className="space-y-3 text-foreground">
{[
"Full English support, no Japanese needed",
"Foreign credit cards accepted",
"Bilingual contracts and documentation",
].map(item => (
<li key={item} className="flex items-center gap-3 text-base font-semibold">
<BadgeCheck className="h-5 w-5 text-primary" />
<span>{item}</span>
</li>
))}
</ul>
<Link
href="/about"
className="inline-flex items-center gap-2 text-primary font-semibold hover:text-primary/80 transition-colors"
>
About our company
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</div>
</div>
</section>
{/* Solutions Carousel */}
@ -848,6 +848,130 @@ export function PublicLandingView() {
</div>
</section>
{/* Most Popular Services Section */}
<section
ref={popularRef as React.RefObject<HTMLElement>}
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"
}`}
>
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14">
{/* Section Header */}
<div className="text-center mb-10 sm:mb-12">
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground mb-4">
Most Popular Services
</h2>
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl mx-auto">
Get connected with ease. No Japanese required, no complicated paperwork, and full
English support every step of the way.
</p>
</div>
{/* Two-column card layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
{/* Internet Plans Card */}
<div className="relative rounded-2xl border border-sky-200 bg-white shadow-lg shadow-sky-100/50 p-6 sm:p-8 flex flex-col">
{/* Popular Badge */}
<div className="absolute top-4 right-4 sm:top-6 sm:right-6">
<span className="inline-flex items-center rounded-full bg-primary px-3 py-1 text-xs font-bold text-white uppercase tracking-wide">
Popular
</span>
</div>
{/* Icon Container */}
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-sky-50 flex items-center justify-center mb-5">
<Wifi className="h-8 w-8 sm:h-10 sm:w-10 text-primary" />
</div>
{/* Title */}
<h3 className="text-xl sm:text-2xl font-bold text-foreground mb-3">Internet Plans</h3>
{/* Description */}
<p className="text-muted-foreground mb-5 leading-relaxed">
High-speed fiber internet on Japan&apos;s reliable NTT network. We handle all the
Japanese paperwork and coordinate installation in English.
</p>
{/* Feature List */}
<ul className="space-y-3 mb-6 flex-grow">
{[
"Speeds up to 10 Gbps available",
"English installation coordination",
"No Japanese contracts to sign",
"Foreign credit cards accepted",
].map(feature => (
<li key={feature} className="flex items-start gap-3">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center mt-0.5">
<Check className="h-3 w-3 text-primary" />
</span>
<span className="text-sm text-foreground">{feature}</span>
</li>
))}
</ul>
{/* CTA Button */}
<Link
href="/services/internet"
className="w-full inline-flex items-center justify-center gap-2 rounded-full bg-primary px-6 py-3.5 text-base font-semibold text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5"
>
View Plans
<ArrowRight className="h-5 w-5" />
</Link>
</div>
{/* Phone Plans Card */}
<div className="relative rounded-2xl border border-sky-200 bg-white shadow-lg shadow-sky-100/50 p-6 sm:p-8 flex flex-col">
{/* Popular Badge */}
<div className="absolute top-4 right-4 sm:top-6 sm:right-6">
<span className="inline-flex items-center rounded-full bg-primary px-3 py-1 text-xs font-bold text-white uppercase tracking-wide">
Popular
</span>
</div>
{/* Icon Container */}
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-sky-50 flex items-center justify-center mb-5">
<Smartphone className="h-8 w-8 sm:h-10 sm:w-10 text-primary" />
</div>
{/* Title */}
<h3 className="text-xl sm:text-2xl font-bold text-foreground mb-3">Phone Plans</h3>
{/* Description */}
<p className="text-muted-foreground mb-5 leading-relaxed">
Mobile SIM cards with voice and data on Japan&apos;s top network. Sign up online, no
hanko needed, and get your SIM delivered fast.
</p>
{/* Feature List */}
<ul className="space-y-3 mb-6 flex-grow">
{[
"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 => (
<li key={feature} className="flex items-start gap-3">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center mt-0.5">
<Check className="h-3 w-3 text-primary" />
</span>
<span className="text-sm text-foreground">{feature}</span>
</li>
))}
</ul>
{/* CTA Button */}
<Link
href="/services/sim"
className="w-full inline-flex items-center justify-center gap-2 rounded-full bg-primary px-6 py-3.5 text-base font-semibold text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5"
>
View Plans
<ArrowRight className="h-5 w-5" />
</Link>
</div>
</div>
</div>
</section>
{/* Remote Support - Full section with mobile-optimized card layout */}
<section
ref={supportRef as React.RefObject<HTMLElement>}

View File

@ -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 */}
<div className="absolute inset-0 bg-gradient-to-t from-white/80 via-transparent to-white/40" />
{/* Gradient fade to next section */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-[#e8f5ff] pointer-events-none z-10" />
<div className="relative max-w-6xl mx-auto px-6 sm:px-8">
<div className="grid grid-cols-1 lg:grid-cols-[1.05fr_minmax(0,0.95fr)] gap-10 items-center">
<div className="space-y-5">
@ -163,6 +172,79 @@ export function AboutUsView() {
</div>
</section>
{/* Trusted By Section - Infinite Carousel */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-white py-10 sm:py-12 overflow-hidden">
{/* Gradient fade to next section */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-[#e8f5ff] pointer-events-none z-10" />
<div className="max-w-6xl mx-auto px-6 sm:px-8 mb-8">
<h2 className="text-center text-lg sm:text-xl font-semibold text-muted-foreground">
Trusted by Leading Companies
</h2>
</div>
{/* Infinite Carousel */}
<div className="relative">
{/* Gradient masks for fade effect on edges */}
<div className="absolute left-0 top-0 bottom-0 w-24 sm:w-32 bg-gradient-to-r from-white to-transparent z-10 pointer-events-none" />
<div className="absolute right-0 top-0 bottom-0 w-24 sm:w-32 bg-gradient-to-l from-white to-transparent z-10 pointer-events-none" />
{/* Scrolling container */}
<div className="flex overflow-hidden">
<div className="flex animate-scroll-infinite gap-12 sm:gap-16">
{/* First set of logos */}
{trustedCompanies.map((company, index) => (
<div
key={`logo-1-${index}`}
className="flex-shrink-0 w-28 h-16 sm:w-36 sm:h-20 flex items-center justify-center grayscale hover:grayscale-0 opacity-60 hover:opacity-100 transition-all duration-300"
>
<Image
src={company.logo}
alt={company.name}
width={120}
height={60}
className="object-contain max-h-full"
/>
</div>
))}
{/* Duplicate set for seamless loop */}
{trustedCompanies.map((company, index) => (
<div
key={`logo-2-${index}`}
className="flex-shrink-0 w-28 h-16 sm:w-36 sm:h-20 flex items-center justify-center grayscale hover:grayscale-0 opacity-60 hover:opacity-100 transition-all duration-300"
>
<Image
src={company.logo}
alt={company.name}
width={120}
height={60}
className="object-contain max-h-full"
/>
</div>
))}
</div>
</div>
</div>
{/* CSS for infinite scroll animation */}
<style jsx>{`
@keyframes scroll-infinite {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.animate-scroll-infinite {
animation: scroll-infinite 30s linear infinite;
}
.animate-scroll-infinite:hover {
animation-play-state: paused;
}
`}</style>
</section>
{/* Business Solutions Carousel */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#e8f5ff] py-10">
{/* Gradient fade to next section */}
@ -219,59 +301,59 @@ export function AboutUsView() {
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-[#f7f7f7] pointer-events-none" />
<div className="max-w-6xl mx-auto px-6 sm:px-8 space-y-8">
<div className="text-center max-w-2xl mx-auto">
<h2 className="text-2xl sm:text-3xl font-bold text-primary mb-3">Our Values</h2>
<p className="text-muted-foreground leading-relaxed">
These principles guide how we serve customers, support our community, and advance our
craft every day.
</p>
</div>
{/* Values Grid - 3 on top, 2 centered on bottom */}
<div className="space-y-4">
{/* Top row - 3 cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{values.slice(0, 3).map((value, index) => (
<div
key={index}
className="group relative bg-white rounded-2xl border border-border/60 p-6 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300"
>
{/* Icon */}
<div
className={`inline-flex items-center justify-center w-12 h-12 rounded-xl border mb-4 ${value.color}`}
>
{value.icon}
</div>
{/* Quote mark decoration */}
<Quote className="absolute top-4 right-4 h-8 w-8 text-muted-foreground/10 rotate-180" />
{/* Text */}
<p className="text-foreground font-medium leading-relaxed">{value.text}</p>
</div>
))}
<div className="text-center max-w-2xl mx-auto">
<h2 className="text-2xl sm:text-3xl font-bold text-primary mb-3">Our Values</h2>
<p className="text-muted-foreground leading-relaxed">
These principles guide how we serve customers, support our community, and advance our
craft every day.
</p>
</div>
{/* Bottom row - 2 cards centered */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl mx-auto lg:max-w-none lg:grid-cols-2 lg:px-[16.666%]">
{values.slice(3).map((value, index) => (
<div
key={index + 3}
className="group relative bg-white rounded-2xl border border-border/60 p-6 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300"
>
{/* Icon */}
{/* Values Grid - 3 on top, 2 centered on bottom */}
<div className="space-y-4">
{/* Top row - 3 cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{values.slice(0, 3).map((value, index) => (
<div
className={`inline-flex items-center justify-center w-12 h-12 rounded-xl border mb-4 ${value.color}`}
key={index}
className="group relative bg-white rounded-2xl border border-border/60 p-6 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300"
>
{value.icon}
{/* Icon */}
<div
className={`inline-flex items-center justify-center w-12 h-12 rounded-xl border mb-4 ${value.color}`}
>
{value.icon}
</div>
{/* Quote mark decoration */}
<Quote className="absolute top-4 right-4 h-8 w-8 text-muted-foreground/10 rotate-180" />
{/* Text */}
<p className="text-foreground font-medium leading-relaxed">{value.text}</p>
</div>
{/* Quote mark decoration */}
<Quote className="absolute top-4 right-4 h-8 w-8 text-muted-foreground/10 rotate-180" />
{/* Text */}
<p className="text-foreground font-medium leading-relaxed">{value.text}</p>
</div>
))}
))}
</div>
{/* Bottom row - 2 cards centered */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl mx-auto lg:max-w-none lg:grid-cols-2 lg:px-[16.666%]">
{values.slice(3).map((value, index) => (
<div
key={index + 3}
className="group relative bg-white rounded-2xl border border-border/60 p-6 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300"
>
{/* Icon */}
<div
className={`inline-flex items-center justify-center w-12 h-12 rounded-xl border mb-4 ${value.color}`}
>
{value.icon}
</div>
{/* Quote mark decoration */}
<Quote className="absolute top-4 right-4 h-8 w-8 text-muted-foreground/10 rotate-180" />
{/* Text */}
<p className="text-foreground font-medium leading-relaxed">{value.text}</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
{/* Corporate Data Section */}

View File

@ -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;

View File

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