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 new file mode 100644 index 00000000..7ece45ff --- /dev/null +++ b/apps/bff/sim-api-test-log.csv @@ -0,0 +1,90 @@ +Timestamp,API Endpoint,API Method,Phone Number,SIM Identifier,Request Payload,Response Status,Error,Additional Info +2026-01-31T02:21:03.485Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T02:21:07.599Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T04:11:11.315Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T04:11:15.556Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T04:11:53.182Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T04:32:18.526Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T04:32:22.394Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T04:32:37.351Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T04:32:41.487Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T04:48:41.057Z,/mvno/changePlan/,POST,02000331144508,02000331144508,"{""account"":""02000331144508"",""planCode"":""PASI_5G"",""runTime"":""20260301""}",Error: 211,API Error: NG,API Error: NG +2026-01-31T04:49:40.396Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T04:49:44.170Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T04:50:51.053Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T04:50:56.134Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T05:04:11.957Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T05:04:16.274Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T05:11:55.749Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T05:11:59.557Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T05:18:00.675Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T05:18:06.042Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T08:37:08.201Z,/master/addSpec/,POST,02000331144508,02000331144508,"{""account"":""02000331144508"",""quota"":1024000}",Error: 200,API Error: Bad Request,API Error: Bad Request +2026-01-31T08:45:14.336Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T08:45:18.452Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T08:45:40.760Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T08:45:47.572Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG +2026-01-31T08:49:32.767Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T08:49:32.948Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T08:50:04.739Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T08:50:05.899Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T08:55:27.913Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T08:55:28.280Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T08:55:39.246Z,/master/addSpec/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""quota"":1024000}",Error: 200,API Error: Bad Request,API Error: Bad Request +2026-01-31T09:03:45.084Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:03:45.276Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:04:02.612Z,/master/addSpec/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""quota"":1000,""kind"":""MVNO""}",Success,,OK +2026-01-31T09:12:19.280Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:12:19.508Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:12:25.347Z,/mvno/changePlan/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""planCode"":""PASI_10G"",""runTime"":""20260301""}",Success,,OK +2026-01-31T09:13:15.309Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:13:15.522Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:21:56.856Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:21:57.041Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:23:40.211Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:24:26.592Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:24:26.830Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:24:49.713Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:24:49.910Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:25:40.613Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:25:53.426Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:26:05.126Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:26:18.482Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-01-31T09:26:57.215Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T01:48:36.804Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T01:48:37.013Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T01:49:41.283Z,/mvno/changePlan/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""planCode"":""PASI_10G"",""runTime"":""20260301""}",Success,,OK +2026-02-02T01:50:58.940Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T01:50:59.121Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T01:51:07.911Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T02:49:01.626Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T02:49:01.781Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T02:49:04.551Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request +2026-02-02T02:49:04.804Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T02:52:39.440Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T02:52:39.696Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T02:52:43.402Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request +2026-02-02T02:52:43.557Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T02:52:50.419Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request +2026-02-02T02:52:50.595Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T02:52:58.616Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T02:52:58.762Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T02:53:01.434Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request +2026-02-02T02:53:01.580Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T03:00:20.821Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T03:00:21.068Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T03:00:25.799Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request +2026-02-02T03:00:26.012Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T04:14:20.988Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T04:14:21.197Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T04:14:23.599Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request +2026-02-02T04:14:23.805Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T04:17:24.519Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +2026-02-02T04:17:24.698Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK +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/freebit.module.ts b/apps/bff/src/integrations/freebit/freebit.module.ts index 4f89f8c2..51871227 100644 --- a/apps/bff/src/integrations/freebit/freebit.module.ts +++ b/apps/bff/src/integrations/freebit/freebit.module.ts @@ -11,10 +11,13 @@ import { FreebitVoiceService } from "./services/freebit-voice.service.js"; import { FreebitCancellationService } from "./services/freebit-cancellation.service.js"; import { FreebitEsimService } from "./services/freebit-esim.service.js"; import { FreebitErrorHandlerService } from "./services/freebit-error-handler.service.js"; -import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.module.js"; +import { FreebitTestTrackerService } from "./services/freebit-test-tracker.service.js"; +import { FreebitAccountRegistrationService } from "./services/freebit-account-registration.service.js"; +import { FreebitVoiceOptionsService } from "./services/freebit-voice-options.service.js"; +import { FreebitSemiBlackService } from "./services/freebit-semiblack.service.js"; @Module({ - imports: [VoiceOptionsModule], + imports: [], providers: [ // Core services FreebitErrorHandlerService, @@ -22,6 +25,7 @@ import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.mo FreebitAuthService, FreebitMapperService, FreebitRateLimiterService, + FreebitTestTrackerService, // Specialized operation services FreebitAccountService, FreebitUsageService, @@ -29,6 +33,10 @@ import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.mo FreebitVoiceService, FreebitCancellationService, FreebitEsimService, + // Physical SIM activation services (PA05-18 + PA02-01 + PA05-05) + FreebitSemiBlackService, + FreebitAccountRegistrationService, + FreebitVoiceOptionsService, // Facade (delegates to specialized services, handles account normalization) FreebitFacade, ], @@ -44,6 +52,12 @@ import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.mo FreebitVoiceService, FreebitCancellationService, FreebitEsimService, + // Physical SIM activation services (PA05-18 + PA02-01 + PA05-05) + FreebitSemiBlackService, + FreebitAccountRegistrationService, + FreebitVoiceOptionsService, + // Rate limiter (needed by SimController) + FreebitRateLimiterService, ], }) export class FreebitModule {} 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 new file mode 100644 index 00000000..1d094a68 --- /dev/null +++ b/apps/bff/src/integrations/freebit/services/freebit-account-registration.service.ts @@ -0,0 +1,151 @@ +/** + * Freebit Account Registration Service (PA02-01) + * + * Handles MVNO account registration via the Freebit PA02-01 API. + * This is the first step in Physical SIM activation - registering + * the phone number (account) in Freebit's system. + * + * @see docs/freebit-apis/PA02-01-account-registration.md + */ + +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { FreebitClientService } from "./freebit-client.service.js"; + +/** + * PA02-01 Account Registration parameters + */ +export interface AccountRegistrationParams { + /** MSISDN (phone number) - 11-14 digits */ + 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) */ + priorityFlag?: "10" | "20"; +} + +/** + * PA02-01 Request payload structure + */ +interface FreebitAccountRegistrationRequest { + createType: "new" | "add"; + requestDatas: Array<{ + kind: "MVNO"; + account: string; + planCode: string; + globalIp?: string; + priorityFlag?: string; + }>; +} + +/** + * PA02-01 Response structure + */ +interface FreebitAccountRegistrationResponse { + resultCode: string; + status?: { + message?: string; + statusCode?: string | number; + }; + responseDatas?: Array<{ + kind: string; + account: string; + ipv4?: string; + ipv6?: string; + resultCode: string; + }>; +} + +@Injectable() +export class FreebitAccountRegistrationService { + constructor( + private readonly client: FreebitClientService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Register a new MVNO account (PA02-01) + * + * This creates a new account in Freebit's system with the specified + * phone number and plan. This must be called before PA05-05 voice options. + * + * Note: Account creation is asynchronous and may take up to 10 minutes. + * + * @param params - Account registration parameters + * @throws BadRequestException if registration fails + */ + async registerAccount(params: AccountRegistrationParams): Promise { + const { account, planCode, createType = "new", globalIp, priorityFlag } = params; + + // Validate required parameters + if (!account || account.length < 11 || account.length > 14) { + throw new BadRequestException( + "Invalid phone number (account) for registration - must be 11-14 digits" + ); + } + + if (!planCode) { + throw new BadRequestException("Plan code is required for account registration"); + } + + this.logger.log("Starting MVNO account registration (PA02-01)", { + account, + accountLength: account.length, + planCode, + createType, + hasGlobalIp: !!globalIp, + hasPriorityFlag: !!priorityFlag, + }); + + try { + // Build payload according to PA02-01 documentation + // Note: authKey is added automatically by makeAuthenticatedRequest + const payload: FreebitAccountRegistrationRequest = { + createType, + requestDatas: [ + { + kind: "MVNO", + account, + planCode, + ...(globalIp && { globalIp }), + ...(priorityFlag && { priorityFlag }), + }, + ], + }; + + // PA02-01 uses form-urlencoded format with json= parameter + const response = await this.client.makeAuthenticatedRequest< + FreebitAccountRegistrationResponse, + FreebitAccountRegistrationRequest + >("/master/addAcnt/", payload); + + // Check response for individual account results + if (response.responseDatas && response.responseDatas.length > 0) { + const accountResult = response.responseDatas[0]; + if (accountResult.resultCode !== "100") { + throw new BadRequestException( + `Account registration failed for ${account}: result code ${accountResult.resultCode}` + ); + } + } + + this.logger.log("MVNO account registration successful (PA02-01)", { + account, + planCode, + }); + } catch (error: unknown) { + const message = extractErrorMessage(error); + this.logger.error("MVNO account registration failed (PA02-01)", { + account, + planCode, + error: message, + }); + throw new BadRequestException(`Account registration failed: ${message}`); + } + } +} diff --git a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts index 0dfd2318..5bd5a31a 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -5,6 +5,7 @@ import { RetryableErrors, withRetry } from "@bff/core/utils/retry.util.js"; import { redactForLogs } from "@bff/core/logging/redaction.util.js"; import { FreebitAuthService } from "./freebit-auth.service.js"; import { FreebitError } from "./freebit-error.service.js"; +import { FreebitTestTrackerService } from "./freebit-test-tracker.service.js"; interface FreebitResponseBase { resultCode?: string | number; @@ -18,6 +19,7 @@ interface FreebitResponseBase { export class FreebitClientService { constructor( private readonly authService: FreebitAuthService, + private readonly testTracker: FreebitTestTrackerService, @Inject(Logger) private readonly logger: Logger ) {} @@ -35,179 +37,243 @@ export class FreebitClientService { const requestPayload = { ...payload, authKey }; let attempt = 0; - return withRetry( - async () => { - attempt += 1; + try { + const responseData = await withRetry( + async () => { + attempt += 1; - this.logger.debug(`Freebit API request`, { - url, - attempt, - maxAttempts: config.retryAttempts, - payload: redactForLogs(requestPayload), - }); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), config.timeout); - try { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `json=${JSON.stringify(requestPayload)}`, - signal: controller.signal, + this.logger.debug(`Freebit API request`, { + url, + attempt, + maxAttempts: config.retryAttempts, + payload: redactForLogs(requestPayload), }); - if (!response.ok) { - const isProd = process.env["NODE_ENV"] === "production"; - const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response); - this.logger.error("Freebit API HTTP error", { - url, - status: response.status, - statusText: response.statusText, - ...(bodySnippet ? { responseBodySnippet: bodySnippet } : {}), - attempt, - }); - throw new FreebitError( - `HTTP ${response.status}: ${response.statusText}`, - response.status.toString() - ); - } - - const responseData = (await response.json()) as TResponse; - - const resultCode = this.normalizeResultCode(responseData.resultCode); - const statusCode = this.normalizeResultCode(responseData.status?.statusCode); - - if (resultCode && resultCode !== "100") { - const isProd = process.env["NODE_ENV"] === "production"; - this.logger.warn("Freebit API returned error response", { - url, - resultCode, - statusCode, - statusMessage: responseData.status?.message, - ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.timeout); + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `json=${JSON.stringify(requestPayload)}`, + signal: controller.signal, }); - throw new FreebitError( - `API Error: ${responseData.status?.message || "Unknown error"}`, - resultCode, - statusCode, - responseData.status?.message - ); - } - - this.logger.debug("Freebit API request successful", { url, resultCode }); - return responseData; - } finally { - clearTimeout(timeoutId); - } - }, - { - maxAttempts: config.retryAttempts, - baseDelayMs: 1000, - maxDelayMs: 10000, - isRetryable: error => { - if (error instanceof FreebitError) { - if (error.isAuthError() && attempt === 1) { - this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url }); - this.authService.clearAuthCache(); - return true; + if (!response.ok) { + const isProd = process.env.NODE_ENV === "production"; + const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response); + this.logger.error("Freebit API HTTP error", { + url, + status: response.status, + statusText: response.statusText, + ...(bodySnippet ? { responseBodySnippet: bodySnippet } : {}), + attempt, + }); + throw new FreebitError( + `HTTP ${response.status}: ${response.statusText}`, + response.status.toString() + ); } - return error.isRetryable(); + + const responseData = (await response.json()) as TResponse; + + const resultCode = this.normalizeResultCode(responseData.resultCode); + const statusCode = this.normalizeResultCode(responseData.status?.statusCode); + + if (resultCode && resultCode !== "100") { + const isProd = process.env.NODE_ENV === "production"; + const errorDetails = { + url, + resultCode, + statusCode, + statusMessage: responseData.status?.message, + ...(isProd ? {} : { fullResponse: JSON.stringify(responseData) }), + }; + this.logger.error("Freebit API returned error response", errorDetails); + // Also log to console for visibility in dev + if (!isProd) { + console.error("[FREEBIT ERROR]", JSON.stringify(errorDetails, null, 2)); + } + + throw new FreebitError( + `API Error: ${responseData.status?.message || "Unknown error"}`, + resultCode, + statusCode, + responseData.status?.message + ); + } + + this.logger.debug("Freebit API request successful", { url, resultCode }); + return responseData; + } finally { + clearTimeout(timeoutId); } - return RetryableErrors.isTransientError(error); }, - logger: this.logger, - logContext: "Freebit API request", - } - ); + { + maxAttempts: config.retryAttempts, + baseDelayMs: 1000, + maxDelayMs: 10000, + isRetryable: error => { + if (error instanceof FreebitError) { + if (error.isAuthError() && attempt === 1) { + this.logger.warn("Freebit auth error detected, clearing cache and retrying", { + url, + }); + this.authService.clearAuthCache(); + return true; + } + return error.isRetryable(); + } + return RetryableErrors.isTransientError(error); + }, + logger: this.logger, + logContext: "Freebit API request", + } + ); + + // Track successful API call + this.trackApiCall(endpoint, payload, responseData, null).catch((error: unknown) => { + this.logger.debug("Failed to track API call", { + error: error instanceof Error ? error.message : String(error), + }); + }); + + return responseData; + } catch (error: unknown) { + // Track failed API call + this.trackApiCall(endpoint, payload, null, error).catch((trackError: unknown) => { + this.logger.debug("Failed to track API call error", { + error: trackError instanceof Error ? trackError.message : String(trackError), + }); + }); + throw error; + } } /** - * Make an authenticated JSON request to Freebit API (for PA05-41) + * Make an authenticated JSON request to Freebit API (for PA05-38, PA05-41, etc.) */ async makeAuthenticatedJsonRequest< TResponse extends FreebitResponseBase, TPayload extends object, >(endpoint: string, payload: TPayload): Promise { const config = this.authService.getConfig(); + const authKey = await this.authService.getAuthKey(); const url = this.buildUrl(config.baseUrl, endpoint); - let attempt = 0; - return withRetry( - async () => { - attempt += 1; - this.logger.debug("Freebit JSON API request", { - url, - attempt, - maxAttempts: config.retryAttempts, - payload: redactForLogs(payload), - }); + // Add authKey to the payload for authentication + const requestPayload = { ...payload, authKey }; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), config.timeout); - try { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - signal: controller.signal, + let attempt = 0; + // Log request details in dev for debugging + const isProd = process.env.NODE_ENV === "production"; + if (!isProd) { + console.log("[FREEBIT JSON API REQUEST]", JSON.stringify({ + url, + payload: redactForLogs(requestPayload), + }, null, 2)); + } + try { + const responseData = await withRetry( + async () => { + attempt += 1; + this.logger.debug("Freebit JSON API request", { + url, + attempt, + maxAttempts: config.retryAttempts, + payload: redactForLogs(requestPayload), }); - if (!response.ok) { - throw new FreebitError( - `HTTP ${response.status}: ${response.statusText}`, - response.status.toString() - ); - } - - const responseData = (await response.json()) as TResponse; - - const resultCode = this.normalizeResultCode(responseData.resultCode); - const statusCode = this.normalizeResultCode(responseData.status?.statusCode); - - if (resultCode && resultCode !== "100") { - const isProd = process.env["NODE_ENV"] === "production"; - this.logger.error("Freebit API returned error result code", { - url, - resultCode, - statusCode, - message: responseData.status?.message, - ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), - attempt, + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.timeout); + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestPayload), + signal: controller.signal, }); - throw new FreebitError( - `API Error: ${responseData.status?.message || "Unknown error"}`, - resultCode, - statusCode, - responseData.status?.message - ); - } - this.logger.debug("Freebit JSON API request successful", { url, resultCode }); - return responseData; - } finally { - clearTimeout(timeoutId); - } - }, - { - maxAttempts: config.retryAttempts, - baseDelayMs: 1000, - maxDelayMs: 10000, - isRetryable: error => { - if (error instanceof FreebitError) { - if (error.isAuthError() && attempt === 1) { - this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url }); - this.authService.clearAuthCache(); - return true; + if (!response.ok) { + throw new FreebitError( + `HTTP ${response.status}: ${response.statusText}`, + response.status.toString() + ); } - return error.isRetryable(); + + const responseData = (await response.json()) as TResponse; + + const resultCode = this.normalizeResultCode(responseData.resultCode); + const statusCode = this.normalizeResultCode(responseData.status?.statusCode); + + if (resultCode && resultCode !== "100") { + const isProd = process.env.NODE_ENV === "production"; + const errorDetails = { + url, + resultCode, + statusCode, + message: responseData.status?.message, + ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), + attempt, + }; + this.logger.error("Freebit JSON API returned error result code", errorDetails); + // Always log to console in dev for visibility + if (!isProd) { + console.error("[FREEBIT JSON API ERROR]", JSON.stringify(errorDetails, null, 2)); + } + throw new FreebitError( + `API Error: ${responseData.status?.message || "Unknown error"}`, + resultCode, + statusCode, + responseData.status?.message + ); + } + + this.logger.debug("Freebit JSON API request successful", { url, resultCode }); + return responseData; + } finally { + clearTimeout(timeoutId); } - return RetryableErrors.isTransientError(error); }, - logger: this.logger, - logContext: "Freebit JSON API request", - } - ); + { + maxAttempts: config.retryAttempts, + baseDelayMs: 1000, + maxDelayMs: 10000, + isRetryable: error => { + if (error instanceof FreebitError) { + if (error.isAuthError() && attempt === 1) { + this.logger.warn("Freebit auth error detected, clearing cache and retrying", { + url, + }); + this.authService.clearAuthCache(); + return true; + } + return error.isRetryable(); + } + return RetryableErrors.isTransientError(error); + }, + logger: this.logger, + logContext: "Freebit JSON API request", + } + ); + + // Track successful API call + this.trackApiCall(endpoint, payload, responseData, null).catch((error: unknown) => { + this.logger.debug("Failed to track API call", { + error: error instanceof Error ? error.message : String(error), + }); + }); + + return responseData; + } catch (error: unknown) { + // Track failed API call + this.trackApiCall(endpoint, payload, null, error).catch((trackError: unknown) => { + this.logger.debug("Failed to track API call error", { + error: trackError instanceof Error ? trackError.message : String(trackError), + }); + }); + throw error; + } } /** @@ -264,4 +330,52 @@ export class FreebitClientService { const normalized = String(code).trim(); return normalized.length > 0 ? normalized : undefined; } + + /** + * Track API call for testing purposes + */ + private async trackApiCall( + endpoint: string, + payload: unknown, + response: FreebitResponseBase | null, + error: unknown + ): Promise { + const payloadObj = payload as Record; + const phoneNumber = this.testTracker.extractPhoneNumber( + (payloadObj.account as string) || "", + payload + ); + + // Only track if we have a phone number (SIM-related calls) + if (!phoneNumber) { + return; + } + + const timestamp = this.testTracker.getCurrentTimestamp(); + const resultCode = response?.resultCode + ? String(response.resultCode) + : error instanceof FreebitError + ? String(error.resultCode || "ERROR") + : "ERROR"; + + const statusMessage = + response?.status?.message || + (error instanceof FreebitError + ? error.message + : error + ? extractErrorMessage(error) + : "Success"); + + await this.testTracker.logApiCall({ + timestamp, + apiEndpoint: endpoint, + apiMethod: "POST", + phoneNumber, + simIdentifier: (payloadObj.account as string) || phoneNumber, + requestPayload: JSON.stringify(redactForLogs(payload)), + responseStatus: resultCode === "100" ? "Success" : `Error: ${resultCode}`, + error: error ? extractErrorMessage(error) : undefined, + additionalInfo: statusMessage, + }); + } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-error.service.ts b/apps/bff/src/integrations/freebit/services/freebit-error.service.ts index 47ae08ee..1bf9559a 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-error.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-error.service.ts @@ -82,6 +82,14 @@ export class FreebitError extends Error { } // Specific error codes + if (this.resultCode === "200" || this.statusCode === "200") { + return "Invalid request parameters. The account number or quota value may be incorrect. Please verify the SIM account details."; + } + + if (this.resultCode === "210" || this.statusCode === "210") { + return "No traffic data available for this account. This is normal for new SIMs that haven't used any data yet."; + } + if (this.resultCode === "215" || this.statusCode === "215") { return "Plan change failed. This may be due to: (1) Account has existing scheduled operations, (2) Invalid plan code for this account, (3) Account restrictions. Please check the Freebit Partner Tools for account status or contact support."; } diff --git a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts index 422a6ef8..9268070b 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts @@ -75,6 +75,19 @@ export class FreebitMapperService { throw new Error("No account data in response"); } + // Debug: Log raw voice option fields from API response + this.logger.debug("[FreebitMapper] Raw API voice option fields", { + account: account.account, + voicemail: account.voicemail, + voiceMail: account.voiceMail, + callwaiting: account.callwaiting, + callWaiting: account.callWaiting, + worldwing: account.worldwing, + worldWing: account.worldWing, + talk: account.talk, + sms: account.sms, + }); + let simType: "standard" | "nano" | "micro" | "esim" = "standard"; if (account.eid) { simType = "esim"; @@ -83,49 +96,19 @@ export class FreebitMapperService { } // Try to get voice options from database first - let voiceMailEnabled = true; - let callWaitingEnabled = true; - let internationalRoamingEnabled = true; + // Default to false - show as disabled unless API confirms enabled + let voiceMailEnabled = false; + let callWaitingEnabled = false; + let internationalRoamingEnabled = false; let networkType = String(account.networkType ?? account.contractLine ?? "4G"); + // Try to load stored options from database first + let storedOptions = null; if (this.voiceOptionsService) { try { - const storedOptions = await this.voiceOptionsService.getVoiceOptions( + storedOptions = await this.voiceOptionsService.getVoiceOptions( String(account.account ?? "") ); - - if (storedOptions) { - voiceMailEnabled = storedOptions.voiceMailEnabled; - callWaitingEnabled = storedOptions.callWaitingEnabled; - internationalRoamingEnabled = storedOptions.internationalRoamingEnabled; - networkType = storedOptions.networkType; - - this.logger.debug("[FreebitMapper] Loaded voice options from database", { - account: account.account, - options: storedOptions, - }); - } else { - // No stored options, check API response - voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, true); - callWaitingEnabled = this.parseOptionFlag( - account.callwaiting ?? account.callWaiting, - true - ); - internationalRoamingEnabled = this.parseOptionFlag( - account.worldwing ?? account.worldWing, - true - ); - - this.logger.debug( - "[FreebitMapper] No stored options found, using defaults or API values", - { - account: account.account, - voiceMailEnabled, - callWaitingEnabled, - internationalRoamingEnabled, - } - ); - } } catch (error) { this.logger.warn("[FreebitMapper] Failed to load voice options from database", { account: account.account, @@ -134,6 +117,44 @@ export class FreebitMapperService { } } + if (storedOptions) { + // Use stored options from database + voiceMailEnabled = storedOptions.voiceMailEnabled; + callWaitingEnabled = storedOptions.callWaitingEnabled; + internationalRoamingEnabled = storedOptions.internationalRoamingEnabled; + networkType = storedOptions.networkType; + + this.logger.debug("[FreebitMapper] Loaded voice options from database", { + account: account.account, + options: storedOptions, + }); + } else { + // No stored options, parse from API response + // Default to false - disabled unless API explicitly returns 10 (enabled) + voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, false); + callWaitingEnabled = this.parseOptionFlag( + account.callwaiting ?? account.callWaiting, + false + ); + internationalRoamingEnabled = this.parseOptionFlag( + account.worldwing ?? account.worldWing, + false + ); + + this.logger.debug( + "[FreebitMapper] No stored options found, using API values (default: disabled)", + { + account: account.account, + rawVoiceMail: account.voicemail ?? account.voiceMail ?? "(not in API response)", + rawCallWaiting: account.callwaiting ?? account.callWaiting ?? "(not in API response)", + rawWorldWing: account.worldwing ?? account.worldWing ?? "(not in API response)", + parsedVoiceMailEnabled: voiceMailEnabled, + parsedCallWaitingEnabled: callWaitingEnabled, + parsedInternationalRoamingEnabled: internationalRoamingEnabled, + } + ); + } + // Convert quota from KB to MB if needed // Freebit API returns quota in KB, but remainingQuotaMb should be in MB let remainingQuotaMb = 0; @@ -153,6 +174,16 @@ export class FreebitMapperService { remainingQuotaMb = remainingQuotaKb / 1000; } + // Log raw account data in dev to debug MSISDN availability + if (process.env.NODE_ENV !== "production") { + console.log("[FREEBIT ACCOUNT DATA]", JSON.stringify({ + account: account.account, + msisdn: account.msisdn, + eid: account.eid, + iccid: account.iccid, + }, null, 2)); + } + return { account: String(account.account ?? ""), status: this.mapSimStatus(String(account.state ?? account.status ?? "pending")), diff --git a/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts b/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts index 09ddd612..0c1fa625 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts @@ -222,6 +222,45 @@ export class FreebitRateLimiterService { } } + /** + * Clear all rate limit timestamps for an account (for testing/debugging) + */ + async clearRateLimitForAccount(account: string): Promise { + const key = this.buildKey(account); + try { + await this.redis.del(key); + this.operationTimestamps.delete(account); + this.logger.log(`Cleared rate limit state for account ${account}`); + } catch (error) { + this.logger.warn("Failed to clear rate limit state", { + account, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Get remaining wait time for a specific operation type (in seconds) + */ + async getRemainingWaitTime(account: string, op: OperationType): Promise { + const entry = await this.getOperationWindow(account); + const now = Date.now(); + + if (op === "network") { + const voiceWait = entry.voice ? Math.max(0, this.windowMs - (now - entry.voice)) : 0; + const planWait = entry.plan ? Math.max(0, this.windowMs - (now - entry.plan)) : 0; + return Math.ceil(Math.max(voiceWait, planWait) / 1000); + } + + if (op === "voice") { + const planWait = entry.plan ? Math.max(0, this.windowMs - (now - entry.plan)) : 0; + const networkWait = entry.network ? Math.max(0, this.windowMs - (now - entry.network)) : 0; + return Math.ceil(Math.max(planWait, networkWait) / 1000); + } + + return 0; + } + /** * Record that an operation was performed for an account. */ diff --git a/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts b/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts new file mode 100644 index 00000000..171713c3 --- /dev/null +++ b/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts @@ -0,0 +1,216 @@ +/** + * Freebit Semi-Black Account Registration Service (PA05-18) + * + * Handles MVNO semi-black (εŠι»’) SIM registration via the Freebit PA05-18 API. + * This must be called BEFORE PA02-01 for physical SIMs to create the account + * in Freebit's system. + * + * Semi-black SIMs are pre-provisioned SIMs that need to be registered + * to associate them with a customer account and plan. + * + * Error 210 "γ‚’γ‚«γ‚¦γƒ³γƒˆδΈεœ¨γ‚¨γƒ©γƒΌ" on PA02-01 indicates that PA05-18 + * was not called first. + */ + +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { FreebitClientService } from "./freebit-client.service.js"; + +/** + * PA05-18 Semi-Black Account Registration parameters + */ +export interface SemiBlackRegistrationParams { + /** MSISDN (phone number) - 11-14 digits */ + account: string; + /** Manufacturing number (productNumber) - 15 chars, e.g., AXxxxxxxxxxxxxx */ + productNumber: string; + /** Freebit plan code (e.g., "PASI_5G") */ + planCode: string; + /** Ship date in YYYYMMDD format (defaults to today) */ + shipDate?: string; + /** Create type: "new" for new master account, "add" for existing */ + createType?: "new" | "add"; + /** Global IP assignment: "20" = disabled (default) */ + globalIp?: "20"; + /** ALADIN operation flag: "10" = operated, "20" = not operated (default) */ + aladinOperated?: "10" | "20"; + /** Sales channel code (optional) */ + deliveryCode?: string; +} + +/** + * PA05-18 Request payload structure + */ +interface FreebitSemiBlackRequest { + authKey: string; + createType: "new" | "add"; + account: string; + productNumber: string; + planCode: string; + shipDate: string; + mnp: { + method: "10"; // "10" = Semi-black SIM + }; + globalIp?: string; + aladinOperated?: string; + deliveryCode?: string; +} + +/** + * PA05-18 Response structure + */ +interface FreebitSemiBlackResponse { + resultCode: number; + status: { + message: string; + statusCode: number; + }; +} + +@Injectable() +export class FreebitSemiBlackService { + constructor( + private readonly client: FreebitClientService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Register a semi-black SIM account (PA05-18) + * + * This registers a pre-provisioned (semi-black) physical SIM in Freebit's + * system. Must be called BEFORE PA02-01 account registration. + * + * @param params - Semi-black registration parameters + * @throws BadRequestException if registration fails + */ + async registerSemiBlackAccount(params: SemiBlackRegistrationParams): Promise { + const { + account, + productNumber, + planCode, + shipDate, + createType = "new", + globalIp = "20", + aladinOperated = "20", + deliveryCode, + } = params; + + // Validate phone number + if (!account || account.length < 11 || account.length > 14) { + throw new BadRequestException( + "Invalid phone number (account) for semi-black registration - must be 11-14 digits" + ); + } + + // Validate product number (manufacturing number) + if (!productNumber || productNumber.length !== 15) { + throw new BadRequestException( + "Invalid product number for semi-black registration - must be 15 characters (e.g., AXxxxxxxxxxxxxx)" + ); + } + + if (!planCode) { + throw new BadRequestException("Plan code is required for semi-black registration"); + } + + // Default to today's date if not provided + const effectiveShipDate = shipDate ?? this.formatTodayAsYYYYMMDD(); + + this.logger.log("Starting semi-black SIM registration (PA05-18)", { + account, + productNumber, + planCode, + shipDate: effectiveShipDate, + createType, + }); + + try { + const payload: Omit = { + createType, + account, + productNumber, + planCode, + shipDate: effectiveShipDate, + mnp: { + method: "10", // Semi-black SIM method + }, + globalIp, + aladinOperated, + ...(deliveryCode && { deliveryCode }), + }; + + // FreebitClientService validates resultCode === "100" before returning + await this.client.makeAuthenticatedRequest< + FreebitSemiBlackResponse, + Omit + >("/mvno/semiblack/addAcnt/", payload); + + this.logger.log("Semi-black SIM registration successful (PA05-18)", { + account, + productNumber, + planCode, + }); + } catch (error: unknown) { + // Re-throw BadRequestException as-is + if (error instanceof BadRequestException) { + throw error; + } + + const message = extractErrorMessage(error); + this.logger.error("Semi-black registration failed (PA05-18)", { + account, + productNumber, + planCode, + error: message, + }); + throw new BadRequestException(`Semi-black registration failed: ${message}`); + } + } + + /** + * Format today's date as YYYYMMDD + */ + private formatTodayAsYYYYMMDD(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${year}${month}${day}`; + } + + /** + * Get human-readable error message for PA05-18 error codes + */ + private getErrorMessage(code: number, defaultMessage?: string): string { + const errorMessages: Record = { + 201: "Invalid account/phone number parameter", + 202: "Invalid master password", + 204: "Invalid parameter", + 205: "Authentication key error", + 208: "Account already exists (duplicate)", + 210: "Master account not found", + 211: "Account status does not allow this operation", + 215: "Invalid plan code", + 228: "Invalid authentication key", + 230: "Account is in async processing queue", + 231: "Invalid global IP parameter", + 232: "Plan not found", + 266: "Invalid product number (manufacturing number)", + 269: "Invalid representative number", + 274: "Invalid delivery code", + 275: "No phone number stock for representative number", + 276: "Invalid ship date", + 279: "Invalid create type", + 284: "Representative number is locked", + 287: "Representative number does not exist or is unavailable", + 288: "Product number does not exist, is already used, or not stocked", + 289: "SIM type does not match representative number type", + 306: "Invalid MNP method", + 313: "MNP reservation expires within grace period", + 900: "Unexpected system error", + }; + + return errorMessages[code] ?? defaultMessage ?? "Unknown error"; + } +} diff --git a/apps/bff/src/integrations/freebit/services/freebit-test-tracker.service.ts b/apps/bff/src/integrations/freebit/services/freebit-test-tracker.service.ts new file mode 100644 index 00000000..a8ca2448 --- /dev/null +++ b/apps/bff/src/integrations/freebit/services/freebit-test-tracker.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { promises as fs } from "fs"; +import { join } from "path"; + +export interface TestTrackingRecord { + timestamp: string; + apiEndpoint: string; + apiMethod: string; + phoneNumber: string; + simIdentifier?: string; + requestPayload?: string; + responseStatus?: string; + error?: string; + additionalInfo?: string; +} + +/** + * Service for tracking physical SIM test API calls + * Logs all API calls to a CSV file for testing purposes + */ +@Injectable() +export class FreebitTestTrackerService { + private readonly logFilePath: string; + private readonly csvHeaders = [ + "Timestamp", + "API Endpoint", + "API Method", + "Phone Number", + "SIM Identifier", + "Request Payload", + "Response Status", + "Error", + "Additional Info", + ]; + + constructor(@Inject(Logger) private readonly logger: Logger) { + // Store log file in project root + const projectRoot = process.cwd(); + this.logFilePath = join(projectRoot, "sim-api-test-log.csv"); + + // Initialize CSV file with headers if it doesn't exist + this.initializeLogFile().catch((error: unknown) => { + this.logger.error("Failed to initialize test tracking log file", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + /** + * Initialize the CSV log file with headers if it doesn't exist + */ + private async initializeLogFile(): Promise { + try { + await fs.access(this.logFilePath); + // File exists, no need to write headers + } catch { + // File doesn't exist, create it with headers + const headers = this.csvHeaders.join(",") + "\n"; + await fs.writeFile(this.logFilePath, headers, "utf-8"); + this.logger.log("Created test tracking log file", { path: this.logFilePath }); + } + } + + /** + * Log an API call to the CSV file + */ + async logApiCall(record: TestTrackingRecord): Promise { + try { + await this.initializeLogFile(); + + // Format the record as CSV row + const row = + [ + record.timestamp, + this.escapeCsvField(record.apiEndpoint), + this.escapeCsvField(record.apiMethod), + this.escapeCsvField(record.phoneNumber), + this.escapeCsvField(record.simIdentifier || ""), + this.escapeCsvField(record.requestPayload || ""), + this.escapeCsvField(record.responseStatus || ""), + this.escapeCsvField(record.error || ""), + this.escapeCsvField(record.additionalInfo || ""), + ].join(",") + "\n"; + + // Append to file + await fs.appendFile(this.logFilePath, row, "utf-8"); + + this.logger.debug("Logged API call to test tracking file", { + endpoint: record.apiEndpoint, + phoneNumber: record.phoneNumber, + }); + } catch (error) { + // Don't throw - logging failures shouldn't break the API + this.logger.error("Failed to log API call to test tracking file", { + error: error instanceof Error ? error.message : String(error), + record, + }); + } + } + + /** + * Escape CSV field values (handle commas, quotes, newlines) + */ + private escapeCsvField(field: string): string { + if (!field) return ""; + + // If field contains comma, quote, or newline, wrap in quotes and escape quotes + if (field.includes(",") || field.includes('"') || field.includes("\n")) { + return `"${field.replace(/"/g, '""')}"`; + } + + return field; + } + + /** + * Extract phone number from account or payload + */ + extractPhoneNumber(account: string, payload?: unknown): string { + if (account) return account; + + // Try to extract from payload if it's an object + if (payload && typeof payload === "object") { + const obj = payload as Record; + return (obj.account as string) || (obj.msisdn as string) || (obj.phoneNumber as string) || ""; + } + + return ""; + } + + /** + * Format timestamp as ISO string + */ + getCurrentTimestamp(): string { + return new Date().toISOString(); + } +} diff --git a/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts b/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts index 8d36d3fb..e3cc5913 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts @@ -58,17 +58,36 @@ export class FreebitUsageService { options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {} ): Promise { try { - const quotaKb = Math.round(quotaMb * 1024); - const baseRequest: Omit = { + // Note: Freebit addSpec API expects quota in MB (not KB as previously thought) + // Also requires 'kind' field for MVNO operations + const baseRequest = { account, - quota: quotaKb, - ...(options.campaignCode !== undefined && { quotaCode: options.campaignCode }), - ...(options.expiryDate !== undefined && { expire: options.expiryDate }), + quota: quotaMb, // MB units for addSpec + kind: "MVNO", // Required for MVNO operations + quotaCode: options.campaignCode, + expire: options.expiryDate, }; const scheduled = !!options.scheduledAt; const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/"; - const request = scheduled ? { ...baseRequest, runTime: options.scheduledAt } : baseRequest; + + // For scheduled operations, use KB and add runTime + const request = scheduled + ? { ...baseRequest, quota: Math.round(quotaMb * 1024), runTime: options.scheduledAt } + : baseRequest; + + // Log the request details for debugging + this.logger.log(`Freebit addSpec request details`, { + endpoint, + account, + quotaMb, + quotaUnit: scheduled ? "KB" : "MB", + kind: "MVNO", + quotaCode: options.campaignCode || "(none)", + expire: options.expiryDate || "(none)", + scheduled, + requestPayload: JSON.stringify(request), + }); await this.client.makeAuthenticatedRequest( endpoint, @@ -79,7 +98,6 @@ export class FreebitUsageService { account, endpoint, quotaMb, - quotaKb, scheduled, }); } catch (error) { diff --git a/apps/bff/src/integrations/freebit/services/freebit-voice-options.service.ts b/apps/bff/src/integrations/freebit/services/freebit-voice-options.service.ts new file mode 100644 index 00000000..7b72e058 --- /dev/null +++ b/apps/bff/src/integrations/freebit/services/freebit-voice-options.service.ts @@ -0,0 +1,213 @@ +/** + * Freebit Voice Options Service (PA05-05) + * + * Handles MVNO voice option registration via the Freebit PA05-05 API. + * This is called after PA02-01 account registration to configure + * voice features like VoiceMail, CallWaiting, WorldCall, etc. + * + * @see docs/freebit-apis/PA05-05-mvno-voice-option-registration.md + */ + +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { FreebitClientService } from "./freebit-client.service.js"; + +/** + * Identity data required for voice option registration + */ +export interface VoiceOptionIdentityData { + /** Last name in Kanji (UTF-8, max 50 chars) */ + lastnameKanji: string; + /** First name in Kanji (UTF-8, max 50 chars) */ + firstnameKanji: string; + /** Last name in Katakana (full-width, max 50 chars) */ + lastnameKana: string; + /** First name in Katakana (full-width, max 50 chars) */ + firstnameKana: string; + /** Gender: "M" = Male, "F" = Female */ + gender: "M" | "F"; + /** Birthday in YYYYMMDD format */ + birthday: string; +} + +/** + * PA05-05 Voice Option Registration parameters + */ +export interface VoiceOptionRegistrationParams { + /** MSISDN (phone number) - must be already registered via PA02-01 */ + account: string; + /** Enable VoiceMail service */ + voiceMailEnabled: boolean; + /** Enable CallWaiting (Catch Phone) service */ + callWaitingEnabled: boolean; + /** Customer identity data (required) */ + identificationData: VoiceOptionIdentityData; + /** WorldCall credit limit in yen (default: 5000) */ + worldCallCreditLimit?: string; +} + +/** + * PA05-05 Request payload structure + */ +interface FreebitVoiceOptionRequest { + account: string; + userConfirmed: "10" | "20"; + aladinOperated: "10" | "20"; + talkOption: { + voiceMail: "10" | "20"; + callWaiting: "10" | "20"; + callTransfer?: "10" | "20"; + callTransferNoId?: "10" | "20"; + worldCall: "10" | "20"; + worldCallCreditLimit?: string; + worldWing: "10" | "20"; + worldWingCreditLimit?: string; + }; + identificationData: { + lastnameKanji: string; + firstnameKanji: string; + lastnameKana: string; + firstnameKana: string; + gender: string; + birthday: string; + }; +} + +/** + * PA05-05 Response structure + */ +interface FreebitVoiceOptionResponse { + resultCode: string; + status?: { + message?: string; + statusCode?: string | number; + }; +} + +@Injectable() +export class FreebitVoiceOptionsService { + constructor( + private readonly client: FreebitClientService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Register voice options for an MVNO account (PA05-05) + * + * This configures voice features for a phone number that was previously + * registered via PA02-01. Must be called after account registration. + * + * Default settings applied: + * - WorldCall: Always enabled + * - WorldWing: Always disabled + * + * @param params - Voice option registration parameters + * @throws BadRequestException if registration fails + */ + async registerVoiceOptions(params: VoiceOptionRegistrationParams): Promise { + const { + account, + voiceMailEnabled, + callWaitingEnabled, + identificationData, + worldCallCreditLimit = "5000", + } = params; + + // Validate required parameters + if (!account || account.length < 11) { + throw new BadRequestException("Invalid phone number (account) for voice option registration"); + } + + if (!identificationData) { + throw new BadRequestException("Identity data is required for voice option registration"); + } + + // Validate identity data + if (!identificationData.lastnameKanji || !identificationData.firstnameKanji) { + throw new BadRequestException("Name (Kanji) is required for voice option registration"); + } + + if (!identificationData.lastnameKana || !identificationData.firstnameKana) { + throw new BadRequestException("Name (Kana) is required for voice option registration"); + } + + if (!identificationData.gender || !["M", "F"].includes(identificationData.gender)) { + throw new BadRequestException("Valid gender (M/F) is required for voice option registration"); + } + + if (!identificationData.birthday || !/^\d{8}$/.test(identificationData.birthday)) { + throw new BadRequestException( + "Birthday in YYYYMMDD format is required for voice option registration" + ); + } + + this.logger.log("Starting voice option registration (PA05-05)", { + account, + voiceMailEnabled, + callWaitingEnabled, + worldCallCreditLimit, + hasIdentityData: !!identificationData, + }); + + try { + // Build payload according to PA05-05 documentation + // Note: authKey is added automatically by makeAuthenticatedRequest + const payload: FreebitVoiceOptionRequest = { + account, + userConfirmed: "10", // Always confirmed + aladinOperated: "10", // ALADIN operated + talkOption: { + voiceMail: voiceMailEnabled ? "10" : "20", + callWaiting: callWaitingEnabled ? "10" : "20", + worldCall: "10", // Always enabled per requirements + worldCallCreditLimit, + worldWing: "20", // Always disabled per requirements + }, + identificationData: { + lastnameKanji: identificationData.lastnameKanji, + firstnameKanji: identificationData.firstnameKanji, + lastnameKana: identificationData.lastnameKana, + firstnameKana: identificationData.firstnameKana, + gender: identificationData.gender, + birthday: identificationData.birthday, + }, + }; + + // PA05-05 uses form-urlencoded format with json= parameter + await this.client.makeAuthenticatedRequest< + FreebitVoiceOptionResponse, + FreebitVoiceOptionRequest + >("/mvno/talkoption/addOrder/", payload); + + this.logger.log("Voice option registration successful (PA05-05)", { + account, + voiceMailEnabled, + callWaitingEnabled, + }); + } catch (error: unknown) { + const message = extractErrorMessage(error); + this.logger.error("Voice option registration failed (PA05-05)", { + account, + error: message, + }); + throw new BadRequestException(`Voice option registration failed: ${message}`); + } + } + + /** + * Format birthday from Date or ISO string to YYYYMMDD + */ + formatBirthday(date: Date | string | undefined): string { + if (!date) return ""; + + const d = typeof date === "string" ? new Date(date) : date; + if (isNaN(d.getTime())) return ""; + + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + + return `${year}${month}${day}`; + } +} diff --git a/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts b/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts index dc2af5f8..2adc7abe 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts @@ -207,6 +207,8 @@ export class FreebitVoiceService { async updateNetworkType(account: string, networkType: "4G" | "5G"): Promise { let eid: string | undefined; let productNumber: string | undefined; + // PA05-38 may require MSISDN (phone number) instead of internal account ID + let apiAccount = account; try { try { @@ -216,10 +218,16 @@ export class FreebitVoiceService { } else if (details.iccid) { productNumber = details.iccid; } + // Use MSISDN if available, as PA05-38 expects phone number format + if (details.msisdn && details.msisdn.length >= 10) { + apiAccount = details.msisdn; + } this.logger.debug(`Resolved SIM identifiers for contract line change`, { - account, + originalAccount: account, + apiAccount, eid, productNumber, + msisdn: details.msisdn, currentNetworkType: details.networkType, }); if (details.networkType?.toUpperCase() === networkType.toUpperCase()) { @@ -241,19 +249,21 @@ export class FreebitVoiceService { await this.rateLimiter.executeWithSpacing(account, "network", async () => { const request: Omit = { - account, + account: apiAccount, contractLine: networkType, ...(eid ? { eid } : {}), ...(productNumber ? { productNumber } : {}), }; - this.logger.debug(`Updating network type via PA05-38 for account ${account}`, { - account, + this.logger.debug(`Updating network type via PA05-38`, { + originalAccount: account, + apiAccount, networkType, request, }); - const response = await this.client.makeAuthenticatedJsonRequest< + // PA05-38 uses form-urlencoded format (json={...}), not pure JSON + const response = await this.client.makeAuthenticatedRequest< FreebitContractLineChangeResponse, typeof request >("/mvno/contractline/change/", request); diff --git a/apps/bff/src/integrations/freebit/services/index.ts b/apps/bff/src/integrations/freebit/services/index.ts index 6822bbe7..734790be 100644 --- a/apps/bff/src/integrations/freebit/services/index.ts +++ b/apps/bff/src/integrations/freebit/services/index.ts @@ -8,3 +8,7 @@ export { FreebitPlanService } from "./freebit-plan.service.js"; export { FreebitVoiceService } from "./freebit-voice.service.js"; export { FreebitCancellationService } from "./freebit-cancellation.service.js"; export { FreebitEsimService, type EsimActivationParams } from "./freebit-esim.service.js"; +export { + FreebitSemiBlackService, + type SemiBlackRegistrationParams, +} from "./freebit-semiblack.service.js"; diff --git a/apps/bff/src/integrations/salesforce/constants/field-maps.ts b/apps/bff/src/integrations/salesforce/constants/field-maps.ts index f0c2bc77..8fb9c1f3 100644 --- a/apps/bff/src/integrations/salesforce/constants/field-maps.ts +++ b/apps/bff/src/integrations/salesforce/constants/field-maps.ts @@ -133,6 +133,7 @@ export const OPPORTUNITY_FIELDS = { // Portal integration fields source: "Opportunity_Source__c", whmcsServiceId: "WHMCS_Service_ID__c", + whmcsRegistrationUrl: "WH_Registeration__c", } as const; export type OpportunityFieldKey = keyof typeof OPPORTUNITY_FIELDS; diff --git a/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts index 93343ec1..42890b65 100644 --- a/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts @@ -134,6 +134,11 @@ export class OrderCdcSubscriber implements OnModuleInit { await this.handleActivationStatusChange(payload, orderId); } + // Check for provisioning trigger (Status change to "Approved") + if (payload && changedFields.has("Status")) { + await this.handleStatusApprovedChange(payload, orderId); + } + // Cache invalidation - only for customer-facing field changes const hasCustomerFacingChange = this.hasCustomerFacingChanges(changedFields); @@ -216,6 +221,60 @@ export class OrderCdcSubscriber implements OnModuleInit { } } + /** + * Handle order status changes to "Approved" + * + * Enqueues a provisioning job when Status changes to "Approved". + * The provisioning processor will fetch the full order from Salesforce + * and validate the conditions (SIM_Type__c, Assign_Physical_SIM__c, etc.) + * + * NOTE: We cannot check SIM_Type__c or Assign_Physical_SIM__c from the CDC payload + * because CDC only includes CHANGED fields. If only Status was updated, those fields + * will be null in the payload even though they have values on the record. + * + * The processor handles: + * - Physical SIM: Status="Approved" + SIM_Type="Physical SIM" + Assigned_Physical_SIM set + * - Standard: Activation_Status__c="Activating" + * - Idempotency via WHMCS_Order_ID__c check + */ + private async handleStatusApprovedChange( + payload: Record, + orderId: string + ): Promise { + const status = extractStringField(payload, ["Status"]); + + // Only trigger when status changes to "Approved" + if (status !== "Approved") { + return; + } + + // Note: We intentionally do NOT check SIM_Type__c or Assign_Physical_SIM__c here + // because CDC payloads only contain changed fields. The provisioning processor + // will fetch the full order and validate all conditions. + + this.logger.log("Enqueuing provisioning job for order status change to Approved", { + orderId, + status, + }); + + try { + await this.provisioningQueue.enqueue({ + sfOrderId: orderId, + idempotencyKey: `cdc-status-approved-${Date.now()}-${orderId}`, + correlationId: `cdc-status-approved-${orderId}`, + }); + + this.logger.log("Successfully enqueued provisioning job for Approved status", { + orderId, + }); + } catch (error) { + this.logger.error("Failed to enqueue provisioning job for Approved status", { + orderId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + // ───────────────────────────────────────────────────────────────────────────── // OrderItem CDC Handler // ───────────────────────────────────────────────────────────────────────────── diff --git a/apps/bff/src/integrations/salesforce/facades/salesforce.facade.ts b/apps/bff/src/integrations/salesforce/facades/salesforce.facade.ts index a3681f1a..525fb526 100644 --- a/apps/bff/src/integrations/salesforce/facades/salesforce.facade.ts +++ b/apps/bff/src/integrations/salesforce/facades/salesforce.facade.ts @@ -154,21 +154,30 @@ export class SalesforceFacade implements OnModuleInit { }); } - const result = (await this.connection.query( - `SELECT Id, Status, Type, Activation_Status__c, WHMCS_Order_ID__c, - Activation_Error_Code__c, Activation_Error_Message__c, - AccountId, Account.Name - FROM Order - WHERE Id = '${orderId}' - LIMIT 1`, - { label: "orders:integration:getOrder" } - )) as { records: SalesforceOrderRecord[]; totalSize: number }; + const soql = ` + SELECT Id, Status, Type, Activation_Status__c, WHMCS_Order_ID__c, + Activation_Error_Code__c, Activation_Error_Message__c, + AccountId, Account.Name, SIM_Type__c, Assign_Physical_SIM__c + FROM Order + WHERE Id = '${orderId}' + LIMIT 1 + `.trim(); + + const result = (await this.connection.query(soql, { + label: "orders:integration:getOrder", + })) as { records: SalesforceOrderRecord[]; totalSize: number }; return result.records?.[0] || null; } catch (error) { + // Temporary: Raw console log to see full error + console.error( + ">>> SALESFORCE getOrder ERROR >>>", + JSON.stringify(error, Object.getOwnPropertyNames(error as object), 2) + ); this.logger.error("Failed to get order from Salesforce", { orderId, error: extractErrorMessage(error), + errorDetails: error instanceof Error ? { name: error.name, stack: error.stack } : error, }); throw error; } diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index f547ccbf..2076bcf1 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -15,6 +15,7 @@ import { OpportunityQueryService } from "./services/opportunity/opportunity-quer import { OpportunityCancellationService } from "./services/opportunity/opportunity-cancellation.service.js"; import { OpportunityMutationService } from "./services/opportunity/opportunity-mutation.service.js"; import { SalesforceErrorHandlerService } from "./services/salesforce-error-handler.service.js"; +import { SalesforceSIMInventoryService } from "./services/salesforce-sim-inventory.service.js"; @Module({ imports: [QueueModule, ConfigModule, SalesforceOrderFieldConfigModule], @@ -31,6 +32,7 @@ import { SalesforceErrorHandlerService } from "./services/salesforce-error-handl // Opportunity facade (depends on decomposed services) SalesforceOpportunityService, OpportunityResolutionService, + SalesforceSIMInventoryService, SalesforceFacade, SalesforceReadThrottleGuard, SalesforceWriteThrottleGuard, @@ -45,6 +47,7 @@ import { SalesforceErrorHandlerService } from "./services/salesforce-error-handl SalesforceCaseService, SalesforceOpportunityService, OpportunityResolutionService, + SalesforceSIMInventoryService, SalesforceReadThrottleGuard, SalesforceWriteThrottleGuard, ], 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 new file mode 100644 index 00000000..ed4c835d --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts @@ -0,0 +1,238 @@ +/** + * Salesforce SIM Inventory Integration Service + * + * Manages Physical SIM inventory records in Salesforce. + * - Query SIM_Inventory__c by ID + * - Validate SIM availability status + * - Update SIM status after activation + * + * @see docs/integrations/salesforce/sim-inventory.md + */ + +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SalesforceConnection } from "./salesforce-connection.service.js"; +import { assertSalesforceId } from "../utils/soql.util.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { SimActivationException } from "@bff/core/exceptions/domain-exceptions.js"; + +/** + * SIM Inventory Status values in Salesforce + */ +export const SIM_INVENTORY_STATUS = { + AVAILABLE: "Available", + ASSIGNED: "Assigned", + RESERVED: "Reserved", + DEACTIVATED: "Deactivated", +} as const; + +export type SimInventoryStatus = (typeof SIM_INVENTORY_STATUS)[keyof typeof SIM_INVENTORY_STATUS]; + +/** + * SIM Inventory record from Salesforce + */ +export interface SimInventoryRecord { + id: string; + phoneNumber: string; + ptNumber: string; + oemId?: string; + status: SimInventoryStatus; + // Note: ICCID__c and IMSI__c fields don't exist on SIM_Inventory__c in this Salesforce org +} + +/** + * Raw Salesforce SIM_Inventory__c response + */ +interface SalesforceSIMInventoryResponse { + records: Array<{ + Id: string; + Phone_Number__c?: string | null; + PT_Number__c?: string | null; + OEM_ID__c?: string | null; + Status__c?: string | null; + }>; +} + +@Injectable() +export class SalesforceSIMInventoryService { + constructor( + private readonly sf: SalesforceConnection, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Get SIM Inventory record by ID + * + * @param simInventoryId - Salesforce ID of the SIM_Inventory__c record + * @returns SIM Inventory details or null if not found + */ + async getSimInventoryById(simInventoryId: string): Promise { + const safeId = assertSalesforceId(simInventoryId, "simInventoryId"); + this.logger.log("Fetching SIM Inventory record", { simInventoryId: safeId }); + + // Note: ICCID__c and IMSI__c fields don't exist on SIM_Inventory__c in this org + // Only query fields that actually exist + const soql = ` + SELECT Id, Phone_Number__c, PT_Number__c, OEM_ID__c, Status__c + FROM SIM_Inventory__c + WHERE Id = '${safeId}' + LIMIT 1 + `; + + try { + const result = (await this.sf.query(soql, { + label: "sim-inventory:getById", + })) as SalesforceSIMInventoryResponse; + + const record = result.records?.[0]; + + if (!record) { + this.logger.warn("SIM Inventory record not found", { simInventoryId: safeId }); + return null; + } + + this.logger.log("SIM Inventory record retrieved", { + simInventoryId: safeId, + status: record.Status__c, + hasPhoneNumber: !!record.Phone_Number__c, + hasPtNumber: !!record.PT_Number__c, + }); + + return this.mapToSimInventoryRecord(record); + } catch (error: unknown) { + this.logger.error("Failed to fetch SIM Inventory record", { + simInventoryId: safeId, + error: extractErrorMessage(error), + }); + throw error; + } + } + + /** + * Get and validate SIM Inventory for activation + * + * Fetches the SIM Inventory record and validates: + * - Record exists + * - Status is "Available" + * - PT_Number__c (productNumber) is set + * - Phone_Number__c (MSISDN) is set + * + * @throws SimActivationException if validation fails + */ + async getAndValidateForActivation(simInventoryId: string): Promise { + const record = await this.getSimInventoryById(simInventoryId); + + if (!record) { + throw new SimActivationException("SIM Inventory record not found", { + simInventoryId, + }); + } + + // Validate status is "Available" + if (record.status !== SIM_INVENTORY_STATUS.AVAILABLE) { + throw new SimActivationException( + `SIM is not available for activation. Current status: ${record.status}`, + { + simInventoryId, + currentStatus: record.status, + expectedStatus: SIM_INVENTORY_STATUS.AVAILABLE, + } + ); + } + + // Validate PT Number is set + if (!record.ptNumber) { + throw new SimActivationException("SIM Inventory record missing PT Number", { + simInventoryId, + }); + } + + // Validate Phone Number is set + if (!record.phoneNumber) { + throw new SimActivationException("SIM Inventory record missing Phone Number (MSISDN)", { + simInventoryId, + }); + } + + this.logger.log("SIM Inventory validated for activation", { + simInventoryId, + phoneNumber: record.phoneNumber, + ptNumber: record.ptNumber, + }); + + return record; + } + + /** + * Update SIM Inventory status to "Assigned" after successful activation + */ + async markAsAssigned(simInventoryId: string): Promise { + const safeId = assertSalesforceId(simInventoryId, "simInventoryId"); + + 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.ASSIGNED, + }); + + this.logger.log("SIM Inventory marked as Assigned", { simInventoryId: safeId }); + } catch (error: unknown) { + this.logger.error("Failed to update SIM Inventory status", { + simInventoryId: safeId, + error: extractErrorMessage(error), + }); + throw error; + } + } + + /** + * Update SIM Inventory status + * + * @param simInventoryId - Salesforce ID + * @param status - New status value + */ + async updateStatus(simInventoryId: string, status: SimInventoryStatus): Promise { + const safeId = assertSalesforceId(simInventoryId, "simInventoryId"); + + this.logger.log("Updating SIM Inventory status", { + simInventoryId: safeId, + newStatus: status, + }); + + try { + await this.sf.sobject("SIM_Inventory__c").update?.({ + Id: safeId, + Status__c: status, + }); + + this.logger.log("SIM Inventory status updated", { + simInventoryId: safeId, + newStatus: status, + }); + } catch (error: unknown) { + this.logger.error("Failed to update SIM Inventory status", { + simInventoryId: safeId, + newStatus: status, + error: extractErrorMessage(error), + }); + throw error; + } + } + + /** + * Map raw Salesforce record to domain type + */ + private mapToSimInventoryRecord( + raw: SalesforceSIMInventoryResponse["records"][0] + ): SimInventoryRecord { + return { + id: raw.Id, + phoneNumber: raw.Phone_Number__c ?? "", + ptNumber: raw.PT_Number__c ?? "", + oemId: raw.OEM_ID__c ?? undefined, + status: (raw.Status__c as SimInventoryStatus) ?? SIM_INVENTORY_STATUS.AVAILABLE, + }; + } +} diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts index 462f255c..d09d1ddf 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts @@ -1,4 +1,5 @@ -import { Injectable, HttpStatus } from "@nestjs/common"; +import { Injectable, HttpStatus, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common"; import type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers"; import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; @@ -11,6 +12,7 @@ import { matchCommonError } from "@bff/core/errors/index.js"; */ @Injectable() export class WhmcsErrorHandlerService { + constructor(@Inject(Logger) private readonly logger: Logger) {} /** * Handle WHMCS API error response */ @@ -27,16 +29,20 @@ export class WhmcsErrorHandlerService { } /** - * Handle general request errors (network, timeout, etc.) + * Handle general request errors (network, timeout, HTTP status errors, etc.) * @param error - The error to handle * @param _context - Context string for error messages (kept for signature consistency) */ handleRequestError(error: unknown, _context?: string): never { + const message = extractErrorMessage(error); + if (this.isTimeoutError(error)) { + this.logger.warn("WHMCS request timeout", { error: message }); throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); } if (this.isNetworkError(error)) { + this.logger.warn("WHMCS network error", { error: message }); throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY); } @@ -48,12 +54,33 @@ export class WhmcsErrorHandlerService { ); } + // Check for HTTP status errors (e.g., "HTTP 500: Internal Server Error") + const httpStatusError = this.parseHttpStatusError(message); + if (httpStatusError) { + this.logger.error("WHMCS HTTP status error", { + upstreamStatus: httpStatusError.status, + upstreamStatusText: httpStatusError.statusText, + originalError: message, + }); + + // Map upstream HTTP status to appropriate domain error + const mapped = this.mapHttpStatusToDomainError(httpStatusError.status); + throw new DomainHttpException(mapped.code, mapped.domainStatus, mapped.message); + } + // Re-throw if already a DomainHttpException if (error instanceof DomainHttpException) { throw error; } - // Wrap unknown errors + // Log unhandled errors for debugging + this.logger.error("WHMCS unhandled request error", { + error: message, + errorType: error instanceof Error ? error.constructor.name : typeof error, + stack: error instanceof Error ? error.stack : undefined, + }); + + // Wrap unknown errors with context throw new DomainHttpException( ErrorCode.EXTERNAL_SERVICE_ERROR, HttpStatus.BAD_GATEWAY, @@ -61,6 +88,89 @@ export class WhmcsErrorHandlerService { ); } + /** + * Parse HTTP status error from error message (e.g., "HTTP 500: Internal Server Error") + */ + private parseHttpStatusError( + message: string + ): { status: number; statusText: string } | null { + const match = message.match(/^HTTP (\d{3}):\s*(.*)$/i); + if (match) { + return { + status: parseInt(match[1], 10), + statusText: match[2] || "Unknown", + }; + } + return null; + } + + /** + * Map upstream HTTP status codes to domain errors + */ + private mapHttpStatusToDomainError(upstreamStatus: number): { + code: ErrorCodeType; + domainStatus: HttpStatus; + message: string; + } { + // 4xx errors from WHMCS typically indicate config/permission issues + if (upstreamStatus === 401 || upstreamStatus === 403) { + return { + code: ErrorCode.EXTERNAL_SERVICE_ERROR, + domainStatus: HttpStatus.SERVICE_UNAVAILABLE, + message: `WHMCS authentication/authorization failed (HTTP ${upstreamStatus}). Check API credentials and permissions.`, + }; + } + + if (upstreamStatus === 404) { + return { + code: ErrorCode.EXTERNAL_SERVICE_ERROR, + domainStatus: HttpStatus.BAD_GATEWAY, + message: `WHMCS endpoint not found (HTTP 404). Check WHMCS configuration.`, + }; + } + + if (upstreamStatus >= 400 && upstreamStatus < 500) { + return { + code: ErrorCode.EXTERNAL_SERVICE_ERROR, + domainStatus: HttpStatus.BAD_GATEWAY, + message: `WHMCS client error (HTTP ${upstreamStatus}). Request may be malformed.`, + }; + } + + // 5xx errors indicate WHMCS server issues + if (upstreamStatus === 500) { + return { + code: ErrorCode.EXTERNAL_SERVICE_ERROR, + domainStatus: HttpStatus.BAD_GATEWAY, + message: `WHMCS internal server error (HTTP 500). The WHMCS server encountered an error.`, + }; + } + + if (upstreamStatus === 502 || upstreamStatus === 503 || upstreamStatus === 504) { + return { + code: ErrorCode.SERVICE_UNAVAILABLE, + domainStatus: HttpStatus.SERVICE_UNAVAILABLE, + message: `WHMCS service unavailable (HTTP ${upstreamStatus}). The server may be down or overloaded.`, + }; + } + + // Default for other 5xx errors + if (upstreamStatus >= 500) { + return { + code: ErrorCode.EXTERNAL_SERVICE_ERROR, + domainStatus: HttpStatus.BAD_GATEWAY, + message: `WHMCS server error (HTTP ${upstreamStatus}).`, + }; + } + + // Fallback for unexpected status codes + return { + code: ErrorCode.EXTERNAL_SERVICE_ERROR, + domainStatus: HttpStatus.BAD_GATEWAY, + message: `Unexpected WHMCS response (HTTP ${upstreamStatus}).`, + }; + } + private mapProviderErrorToDomain( action: string, message: string, diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts index 4c1d6ce0..15d61e1b 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts @@ -288,12 +288,12 @@ export class WhmcsHttpClientService { // For successful responses, WHMCS API returns data directly at the root level // The response structure is: { "result": "success", ...actualData } - // We return the parsed response directly as T since it contains the actual data - const { result, message, ...rest } = parsedResponse; + // We include 'result' in the data so downstream services can verify success if needed + const { message, ...dataWithResult } = parsedResponse; return { - result, + result: parsedResponse.result, message: typeof message === "string" ? message : undefined, - data: rest as T, + data: dataWithResult as T, } satisfies WhmcsResponse; } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index d56b0b48..a2e9783a 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -323,7 +323,22 @@ export class WhmcsInvoiceService { await this.connectionService.createInvoice(whmcsParams); if (response.result !== "success") { - throw new WhmcsOperationException(`WHMCS invoice creation failed: ${response.message}`, { + // Log full response for debugging (WHMCS may return error info in different fields) + this.logger.error("WHMCS CreateInvoice returned non-success result", { + clientId: params.clientId, + result: response.result, + message: response.message, + status: response.status, + invoiceid: response.invoiceid, + fullResponse: JSON.stringify(response), + }); + + const errorMessage = + response.message || + (response as Record).error || + `Unknown error (result: ${response.result})`; + + throw new WhmcsOperationException(`WHMCS invoice creation failed: ${errorMessage}`, { clientId: params.clientId, }); } @@ -478,6 +493,71 @@ export class WhmcsInvoiceService { } } + /** + * Refund a payment by adding a credit transaction and marking invoice as refunded + * Used for testing to reverse charges immediately after capture + */ + async refundPayment(params: { + invoiceId: number; + transactionId: string; + amount: number; + reason?: string; + }): Promise<{ success: boolean; error?: string }> { + try { + this.logger.log(`Processing refund for invoice ${params.invoiceId}`, { + invoiceId: params.invoiceId, + transactionId: params.transactionId, + amount: params.amount, + reason: params.reason || "Test refund", + }); + + // Add a credit transaction to reverse the charge + const addTransactionResponse = await this.connectionService.makeRequest<{ + result: string; + transactionid?: number; + message?: string; + }>("AddTransaction", { + invoiceid: params.invoiceId, + transid: `REFUND-${params.transactionId}`, + gateway: "stripe", + date: new Date().toISOString().split("T")[0], + amountin: 0, + amountout: params.amount, // Outgoing amount = refund + description: params.reason || "Test refund - payment reversed", + }); + + if (addTransactionResponse.result !== "success") { + this.logger.warn(`Failed to add refund transaction for invoice ${params.invoiceId}`, { + response: addTransactionResponse, + }); + } + + // Mark invoice as refunded + await this.updateInvoice({ + invoiceId: params.invoiceId, + status: "Refunded", + notes: `Refunded: ${params.reason || "Test mode - automatic refund"}`, + }); + + this.logger.log(`Successfully refunded invoice ${params.invoiceId}`, { + invoiceId: params.invoiceId, + amount: params.amount, + }); + + return { success: true }; + } catch (error) { + this.logger.error(`Failed to refund invoice ${params.invoiceId}`, { + error: extractErrorMessage(error), + params, + }); + + return { + success: false, + error: extractErrorMessage(error), + }; + } + } + /** * Convert technical payment errors to user-friendly messages */ 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 32bcf133..97a1d765 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts @@ -119,8 +119,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, @@ -152,11 +157,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/queue/provisioning.processor.ts b/apps/bff/src/modules/orders/queue/provisioning.processor.ts index be8a29d3..778d6827 100644 --- a/apps/bff/src/modules/orders/queue/provisioning.processor.ts +++ b/apps/bff/src/modules/orders/queue/provisioning.processor.ts @@ -25,15 +25,27 @@ export class ProvisioningProcessor extends WorkerHost { correlationId: job.data.correlationId, }); - // Guard: Only process if Salesforce Order is currently 'Activating' - const order = await this.salesforceService.getOrder(sfOrderId); - const status = order?.Activation_Status__c ?? ""; + const activationStatus = order?.Activation_Status__c ?? ""; + const orderStatus = order?.Status ?? ""; + const simType = order?.SIM_Type__c ?? ""; + const assignedPhysicalSim = order?.Assign_Physical_SIM__c ?? ""; const lastErrorCode = order?.Activation_Error_Code__c ?? ""; - if (status !== "Activating") { - this.logger.log("Skipping provisioning job: Order not in Activating state", { + + // Guard: Determine if this is a valid provisioning request + // Case 1: Standard flow - Activation_Status__c = "Activating" + // Case 2: Physical SIM flow - Status = "Approved" with SIM_Type__c = "Physical SIM" + const isStandardActivation = activationStatus === "Activating"; + const isPhysicalSimApproval = + orderStatus === "Approved" && simType === "Physical SIM" && !!assignedPhysicalSim; + + if (!isStandardActivation && !isPhysicalSimApproval) { + this.logger.log("Skipping provisioning job: Order not in activatable state", { sfOrderId, - currentStatus: status, + activationStatus, + orderStatus, + simType, + hasAssignedPhysicalSim: !!assignedPhysicalSim, }); return; // Ack + no-op to safely handle duplicate/old events } @@ -42,12 +54,19 @@ export class ProvisioningProcessor extends WorkerHost { if (lastErrorCode === "PAYMENT_METHOD_MISSING") { this.logger.log("Skipping provisioning job: Awaiting payment method addition", { sfOrderId, - currentStatus: status, + activationStatus, lastErrorCode, }); return; } + this.logger.log("Proceeding with provisioning", { + sfOrderId, + isStandardActivation, + isPhysicalSimApproval, + simType, + }); + // Execute the same orchestration used by the webhook path, but without payload validation await this.orchestrator.executeFulfillment(sfOrderId, {}, idempotencyKey); this.logger.log("Provisioning job completed", { sfOrderId }); 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 ee5f05ba..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 @@ -1,20 +1,32 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; +import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; import { OrderOrchestrator } from "./order-orchestrator.service.js"; import { OrderFulfillmentValidator } from "./order-fulfillment-validator.service.js"; import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service.js"; -import { SimFulfillmentService } from "./sim-fulfillment.service.js"; -import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js"; +import { + SimFulfillmentService, + type SimFulfillmentResult, + type ContactIdentityData, +} from "./sim-fulfillment.service.js"; import { DistributedTransactionService } from "@bff/infra/database/services/distributed-transaction.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { OrderEventsService } from "./order-events.service.js"; +import { OrdersCacheService } from "./orders-cache.service.js"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; import type { OrderDetails } from "@customer-portal/domain/orders"; -import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers"; +import type { + OrderFulfillmentValidationResult, + SalesforceOrderRecord, +} from "@customer-portal/domain/orders/providers"; import { createOrderNotes, mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; +import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; +import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; import { OrderValidationException, FulfillmentException, @@ -26,18 +38,19 @@ type WhmcsOrderItemMappingResult = ReturnType; export interface OrderFulfillmentStep { step: string; status: "pending" | "in_progress" | "completed" | "failed"; - startedAt?: Date | undefined; - completedAt?: Date | undefined; - error?: string | undefined; + startedAt?: Date; + completedAt?: Date; + error?: string; } export interface OrderFulfillmentContext { sfOrderId: string; idempotencyKey: string; validation: OrderFulfillmentValidationResult | null; - orderDetails?: OrderDetails | undefined; - mappingResult?: WhmcsOrderItemMappingResult | undefined; - whmcsResult?: WhmcsOrderResult | undefined; + orderDetails?: OrderDetails; + simFulfillmentResult?: SimFulfillmentResult; + mappingResult?: WhmcsOrderItemMappingResult; + whmcsResult?: WhmcsOrderResult; steps: OrderFulfillmentStep[]; } @@ -47,10 +60,9 @@ export interface OrderFulfillmentContext { */ @Injectable() export class OrderFulfillmentOrchestrator { - // eslint-disable-next-line max-params -- NestJS dependency injection requires all services to be injected via constructor constructor( @Inject(Logger) private readonly logger: Logger, - private readonly salesforceService: SalesforceFacade, + private readonly salesforceService: SalesforceService, private readonly opportunityService: SalesforceOpportunityService, private readonly whmcsOrderService: WhmcsOrderService, private readonly orderOrchestrator: OrderOrchestrator, @@ -58,7 +70,10 @@ export class OrderFulfillmentOrchestrator { private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService, private readonly simFulfillmentService: SimFulfillmentService, private readonly distributedTransactionService: DistributedTransactionService, - private readonly sideEffects: FulfillmentSideEffectsService + private readonly orderEvents: OrderEventsService, + private readonly ordersCache: OrdersCacheService, + private readonly mappingsService: MappingsService, + private readonly notifications: NotificationService ) {} /** @@ -72,349 +87,6 @@ export class OrderFulfillmentOrchestrator { return this.executeFulfillmentWithTransactions(sfOrderId, payload, idempotencyKey); } - /** - * Validate fulfillment request and handle already-provisioned case - * Returns true if order was already provisioned (no further processing needed) - */ - private async executeValidationStep(context: OrderFulfillmentContext): Promise { - const { sfOrderId } = context; - - this.updateStepStatus(context, "validation", "in_progress"); - try { - // eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions - context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( - sfOrderId, - context.idempotencyKey - ); - this.updateStepStatus(context, "validation", "completed"); - - if (context.validation.isAlreadyProvisioned) { - this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId }); - this.sideEffects.publishAlreadyProvisioned(sfOrderId, context.validation.whmcsOrderId); - await this.sideEffects.invalidateCaches(sfOrderId, context.validation?.sfOrder?.AccountId); - return true; - } - - return false; - } catch (error) { - this.updateStepStatus(context, "validation", "failed", extractErrorMessage(error)); - this.logger.error("Fulfillment validation failed", { - sfOrderId, - error: extractErrorMessage(error), - }); - throw error; - } - } - - /** - * Fetch and validate order details - */ - private async fetchOrderDetails(context: OrderFulfillmentContext): Promise { - const { sfOrderId, idempotencyKey } = context; - - try { - const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId); - if (!orderDetails) { - throw new OrderValidationException("Order details could not be retrieved.", { - sfOrderId, - idempotencyKey, - }); - } - // eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions - context.orderDetails = orderDetails; - } catch (error) { - this.logger.error("Failed to get order details", { - sfOrderId, - error: extractErrorMessage(error), - }); - throw error; - } - } - - /** - * Build transaction steps for fulfillment workflow - */ - private buildTransactionSteps( - context: OrderFulfillmentContext, - payload: Record, - state: { mappingResult?: WhmcsOrderItemMappingResult; whmcsCreateResult?: WhmcsOrderResult } - ): Parameters[0] { - const { sfOrderId } = context; - - return [ - this.createSfStatusUpdateStep(context, sfOrderId), - this.createOrderDetailsStep(context), - this.createMappingStep(context, sfOrderId, state), - this.createWhmcsCreateStep(context, sfOrderId, state), - this.createWhmcsAcceptStep(context, sfOrderId, state), - this.createSimFulfillmentStep(context, payload), - this.createSfSuccessUpdateStep(context, sfOrderId, state), - this.createOpportunityUpdateStep(context, sfOrderId, state), - ]; - } - - private createSfStatusUpdateStep(context: OrderFulfillmentContext, sfOrderId: string) { - return { - id: "sf_status_update", - description: "Update Salesforce order status to Activating", - execute: this.createTrackedStep(context, "sf_status_update", async () => { - const result = await this.salesforceService.updateOrder({ - Id: sfOrderId, - Activation_Status__c: "Activating", - }); - this.sideEffects.publishActivating(sfOrderId); - await this.sideEffects.notifyOrderApproved( - sfOrderId, - context.validation?.sfOrder?.AccountId - ); - return result; - }), - rollback: async () => { - await this.salesforceService.updateOrder({ Id: sfOrderId, Activation_Status__c: "Failed" }); - }, - critical: true, - }; - } - - private createOrderDetailsStep(context: OrderFulfillmentContext) { - return { - id: "order_details", - description: "Retain order details in context", - execute: this.createTrackedStep(context, "order_details", async () => - Promise.resolve(context.orderDetails) - ), - critical: false, - }; - } - - private createMappingStep( - context: OrderFulfillmentContext, - _sfOrderId: string, - state: { mappingResult?: WhmcsOrderItemMappingResult } - ) { - return { - id: "mapping", - description: "Map OrderItems to WHMCS format", - execute: this.createTrackedStep(context, "mapping", async () => { - if (!context.orderDetails) { - return Promise.reject(new Error("Order details are required for mapping")); - } - const result = mapOrderToWhmcsItems(context.orderDetails); - state.mappingResult = result; - - this.logger.log("OrderItems mapped to WHMCS", { - totalItems: result.summary.totalItems, - serviceItems: result.summary.serviceItems, - activationItems: result.summary.activationItems, - }); - - return Promise.resolve(result); - }), - critical: true, - }; - } - - private createWhmcsCreateStep( - context: OrderFulfillmentContext, - sfOrderId: string, - state: { mappingResult?: WhmcsOrderItemMappingResult; whmcsCreateResult?: WhmcsOrderResult } - ) { - return { - id: "whmcs_create", - description: "Create order in WHMCS", - execute: this.createTrackedStep(context, "whmcs_create", async () => { - if (!context.validation) { - throw new OrderValidationException("Validation context is missing", { - sfOrderId, - step: "whmcs_create_order", - }); - } - if (!state.mappingResult) { - throw new FulfillmentException("Mapping result is not available", { - sfOrderId, - step: "whmcs_create_order", - }); - } - - const orderNotes = createOrderNotes( - sfOrderId, - `Provisioned from Salesforce Order ${sfOrderId}` - ); - const sfOpportunityId = context.orderDetails?.opportunityId; - - const result = await this.whmcsOrderService.addOrder({ - clientId: context.validation.clientId, - items: state.mappingResult.whmcsItems, - paymentMethod: "stripe", - promoCode: "1st Month Free (Monthly Plan)", - sfOrderId, - sfOpportunityId, - notes: orderNotes, - noinvoiceemail: true, - noemail: true, - }); - - // eslint-disable-next-line require-atomic-updates -- state object is local to this orchestration and steps execute sequentially - state.whmcsCreateResult = result; - return result; - }), - rollback: async () => { - if (state.whmcsCreateResult?.orderId) { - this.logger.error( - "WHMCS order created but fulfillment failed - manual cleanup required", - { - orderId: state.whmcsCreateResult.orderId, - sfOrderId, - action: "MANUAL_CLEANUP_REQUIRED", - } - ); - } - return Promise.resolve(); - }, - critical: true, - }; - } - - private createWhmcsAcceptStep( - context: OrderFulfillmentContext, - sfOrderId: string, - state: { whmcsCreateResult?: WhmcsOrderResult } - ) { - return { - id: "whmcs_accept", - description: "Accept/provision order in WHMCS", - execute: this.createTrackedStep(context, "whmcs_accept", async () => { - if (!state.whmcsCreateResult?.orderId) { - throw new WhmcsOperationException("WHMCS order ID missing before acceptance step", { - sfOrderId, - step: "whmcs_accept_order", - }); - } - - await this.whmcsOrderService.acceptOrder(state.whmcsCreateResult.orderId, sfOrderId); - return { orderId: state.whmcsCreateResult.orderId }; - }), - rollback: async () => { - if (state.whmcsCreateResult?.orderId) { - this.logger.error( - "WHMCS order accepted but fulfillment failed - manual cleanup required", - { - orderId: state.whmcsCreateResult.orderId, - serviceIds: state.whmcsCreateResult.serviceIds, - sfOrderId, - action: "MANUAL_SERVICE_TERMINATION_REQUIRED", - } - ); - } - return Promise.resolve(); - }, - critical: true, - }; - } - - private createSimFulfillmentStep( - context: OrderFulfillmentContext, - payload: Record - ) { - return { - id: "sim_fulfillment", - description: "SIM-specific fulfillment (if applicable)", - execute: this.createTrackedStep(context, "sim_fulfillment", async () => { - if (context.orderDetails?.orderType === "SIM") { - const configurations = this.extractConfigurations(payload["configurations"]); - await this.simFulfillmentService.fulfillSimOrder({ - orderDetails: context.orderDetails, - configurations, - }); - return { completed: true as const }; - } - return { skipped: true as const }; - }), - critical: false, - }; - } - - private createSfSuccessUpdateStep( - context: OrderFulfillmentContext, - sfOrderId: string, - state: { whmcsCreateResult?: WhmcsOrderResult } - ) { - return { - id: "sf_success_update", - description: "Update Salesforce with success", - execute: this.createTrackedStep(context, "sf_success_update", async () => { - const result = await this.salesforceService.updateOrder({ - Id: sfOrderId, - Status: "Completed", - Activation_Status__c: "Activated", - WHMCS_Order_ID__c: state.whmcsCreateResult?.orderId?.toString(), - }); - this.sideEffects.publishCompleted( - sfOrderId, - state.whmcsCreateResult?.orderId, - state.whmcsCreateResult?.serviceIds - ); - await this.sideEffects.notifyOrderActivated( - sfOrderId, - context.validation?.sfOrder?.AccountId - ); - return result; - }), - rollback: async () => { - await this.salesforceService.updateOrder({ Id: sfOrderId, Activation_Status__c: "Failed" }); - }, - critical: true, - }; - } - - private createOpportunityUpdateStep( - context: OrderFulfillmentContext, - sfOrderId: string, - state: { whmcsCreateResult?: WhmcsOrderResult } - ) { - return { - id: "opportunity_update", - description: "Update Opportunity with WHMCS Service ID and Active stage", - execute: this.createTrackedStep(context, "opportunity_update", async () => { - const opportunityId = context.orderDetails?.opportunityId; - const serviceId = state.whmcsCreateResult?.serviceIds?.[0]; - - if (!opportunityId) { - this.logger.debug("No Opportunity linked to order, skipping update", { sfOrderId }); - return { skipped: true as const }; - } - - try { - await this.opportunityService.updateStage( - opportunityId, - OPPORTUNITY_STAGE.ACTIVE, - "Service activated via fulfillment" - ); - - if (serviceId) { - await this.opportunityService.linkWhmcsServiceToOpportunity(opportunityId, serviceId); - } - - this.logger.log("Opportunity updated with Active stage and WHMCS link", { - opportunityIdTail: opportunityId.slice(-4), - whmcsServiceId: serviceId, - sfOrderId, - }); - - return { opportunityId, whmcsServiceId: serviceId }; - } catch (error) { - this.logger.warn("Failed to update Opportunity after fulfillment", { - error: extractErrorMessage(error), - opportunityId, - sfOrderId, - }); - return { failed: true as const, error: extractErrorMessage(error) }; - } - }), - critical: false, - }; - } - /** * Execute fulfillment workflow using distributed transactions for atomicity */ @@ -428,7 +100,7 @@ export class OrderFulfillmentOrchestrator { idempotencyKey, validation: null, steps: this.initializeSteps( - typeof payload["orderType"] === "string" ? payload["orderType"] : "Unknown" + typeof payload.orderType === "string" ? payload.orderType : "Unknown" ), }; @@ -438,28 +110,451 @@ export class OrderFulfillmentOrchestrator { }); try { - // Step 1: Validation (returns early if already provisioned) - const alreadyProvisioned = await this.executeValidationStep(context); - if (alreadyProvisioned) { - return context; + // Step 1: Validation (no rollback needed) + this.updateStepStatus(context, "validation", "in_progress"); + try { + context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( + sfOrderId, + idempotencyKey + ); + this.updateStepStatus(context, "validation", "completed"); + + if (context.validation.isAlreadyProvisioned) { + this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId }); + this.orderEvents.publish(sfOrderId, { + orderId: sfOrderId, + status: "Completed", + activationStatus: "Activated", + stage: "completed", + source: "fulfillment", + message: "Order already provisioned", + timestamp: new Date().toISOString(), + payload: { + whmcsOrderId: context.validation.whmcsOrderId, + }, + }); + await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); + return context; + } + } catch (error) { + this.updateStepStatus(context, "validation", "failed", extractErrorMessage(error)); + this.logger.error("Fulfillment validation failed", { + sfOrderId, + error: extractErrorMessage(error), + }); + throw error; } - // Step 2: Fetch order details - await this.fetchOrderDetails(context); + // Step 2: Get order details (no rollback needed) + try { + const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId); + if (!orderDetails) { + throw new OrderValidationException("Order details could not be retrieved.", { + sfOrderId, + idempotencyKey, + }); + } + context.orderDetails = orderDetails; + } catch (error) { + this.logger.error("Failed to get order details", { + sfOrderId, + error: extractErrorMessage(error), + }); + throw error; + } // Step 3: Execute the main fulfillment workflow as a distributed transaction - const state: { - mappingResult?: WhmcsOrderItemMappingResult; - whmcsCreateResult?: WhmcsOrderResult; - } = {}; - const transactionSteps = this.buildTransactionSteps(context, payload, state); + // New flow: SIM activation (PA05-18 + PA02-01 + PA05-05) β†’ Activated status β†’ WHMCS β†’ Registration Completed + let simFulfillmentResult: SimFulfillmentResult | undefined; + let mappingResult: WhmcsOrderItemMappingResult | undefined; + let whmcsCreateResult: WhmcsOrderResult | undefined; const fulfillmentResult = - await this.distributedTransactionService.executeDistributedTransaction(transactionSteps, { - description: `Order fulfillment for ${sfOrderId}`, - timeout: 300000, - continueOnNonCriticalFailure: true, - }); + await this.distributedTransactionService.executeDistributedTransaction( + [ + { + id: "sf_status_update", + description: "Update Salesforce order status to Activating", + execute: this.createTrackedStep(context, "sf_status_update", async () => { + const result = await this.salesforceService.updateOrder({ + Id: sfOrderId, + Activation_Status__c: "Activating", + }); + this.orderEvents.publish(sfOrderId, { + orderId: sfOrderId, + status: "Processing", + activationStatus: "Activating", + stage: "in_progress", + source: "fulfillment", + timestamp: new Date().toISOString(), + }); + await this.safeNotifyOrder({ + type: NOTIFICATION_TYPE.ORDER_APPROVED, + sfOrderId, + accountId: context.validation?.sfOrder?.AccountId, + actionUrl: `/account/orders/${sfOrderId}`, + }); + return result; + }), + rollback: async () => { + await this.salesforceService.updateOrder({ + Id: sfOrderId, + Activation_Status__c: "Failed", + }); + }, + critical: true, + }, + { + id: "order_details", + description: "Retain order details in context", + execute: this.createTrackedStep(context, "order_details", () => + Promise.resolve(context.orderDetails) + ), + critical: false, + }, + // SIM fulfillment now runs BEFORE WHMCS (PA05-18 + PA02-01 + PA05-05) + { + id: "sim_fulfillment", + description: "SIM activation via Freebit (PA05-18 + PA02-01 + PA05-05)", + execute: this.createTrackedStep(context, "sim_fulfillment", async () => { + if (context.orderDetails?.orderType === "SIM") { + const sfOrder = context.validation?.sfOrder; + const configurations = this.extractConfigurations( + payload.configurations, + sfOrder + ); + const assignedPhysicalSimId = + typeof sfOrder?.Assign_Physical_SIM__c === "string" + ? sfOrder.Assign_Physical_SIM__c + : undefined; + + // Extract voice options from SF order + const voiceMailEnabled = sfOrder?.SIM_Voice_Mail__c === true; + const callWaitingEnabled = sfOrder?.SIM_Call_Waiting__c === true; + + // Extract contact identity from porting fields (for PA05-05) + // These fields are populated when order has MNP/porting data + const contactIdentity = this.extractContactIdentity(sfOrder); + + this.logger.log("Starting SIM fulfillment (before WHMCS)", { + orderId: context.sfOrderId, + simType: sfOrder?.SIM_Type__c, + assignedPhysicalSimId, + voiceMailEnabled, + callWaitingEnabled, + hasContactIdentity: !!contactIdentity, + }); + + const result = await this.simFulfillmentService.fulfillSimOrder({ + orderDetails: context.orderDetails, + configurations, + assignedPhysicalSimId, + voiceMailEnabled, + callWaitingEnabled, + contactIdentity, + }); + + simFulfillmentResult = result; + context.simFulfillmentResult = result; + + return result; + } + return { activated: false, simType: "eSIM" as const }; + }), + rollback: () => { + // SIM activation cannot be easily rolled back + // Log for manual intervention if needed + this.logger.warn( + "SIM fulfillment step needs rollback - manual intervention may be required", + { + sfOrderId, + simFulfillmentResult, + } + ); + return Promise.resolve(); + }, + critical: context.validation?.sfOrder?.SIM_Type__c === "Physical SIM", + }, + // Update status to "Activated" after successful SIM fulfillment + { + id: "sf_activated_update", + description: "Update Salesforce order status to Activated", + execute: this.createTrackedStep(context, "sf_activated_update", async () => { + if (context.orderDetails?.orderType === "SIM" && simFulfillmentResult?.activated) { + const result = await this.salesforceService.updateOrder({ + Id: sfOrderId, + Status: "Activated", + Activation_Status__c: "Activated", + }); + this.orderEvents.publish(sfOrderId, { + orderId: sfOrderId, + status: "Activated", + activationStatus: "Activated", + stage: "in_progress", + source: "fulfillment", + message: "SIM activated, proceeding to billing setup", + timestamp: new Date().toISOString(), + }); + return result; + } + return { skipped: true }; + }), + rollback: async () => { + await this.salesforceService.updateOrder({ + Id: sfOrderId, + Activation_Status__c: "Failed", + }); + }, + critical: false, + }, + { + id: "mapping", + description: "Map OrderItems to WHMCS format with SIM data", + execute: this.createTrackedStep(context, "mapping", () => { + if (!context.orderDetails) { + return Promise.reject(new Error("Order details are required for mapping")); + } + // Use domain mapper directly - single transformation! + const result = mapOrderToWhmcsItems(context.orderDetails); + + // 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!, + ...(simFulfillmentResult!.serialNumber && { + SerialNumber: simFulfillmentResult!.serialNumber, + }), + }; + }); + } + + mappingResult = result; + + this.logger.log("OrderItems mapped to WHMCS", { + totalItems: result.summary.totalItems, + serviceItems: result.summary.serviceItems, + activationItems: result.summary.activationItems, + hasSimData: !!simFulfillmentResult?.phoneNumber, + }); + + return Promise.resolve(result); + }), + critical: true, + }, + { + id: "whmcs_create", + description: "Create order in WHMCS", + execute: this.createTrackedStep(context, "whmcs_create", async () => { + if (!context.validation) { + throw new OrderValidationException("Validation context is missing", { + sfOrderId, + step: "whmcs_create_order", + }); + } + if (!mappingResult) { + throw new FulfillmentException("Mapping result is not available", { + sfOrderId, + step: "whmcs_create_order", + }); + } + + const orderNotes = createOrderNotes( + sfOrderId, + `Provisioned from Salesforce Order ${sfOrderId}` + ); + + // Get OpportunityId from order details for WHMCS lifecycle linking + const sfOpportunityId = context.orderDetails?.opportunityId; + + const result = await this.whmcsOrderService.addOrder({ + clientId: context.validation.clientId, + items: mappingResult.whmcsItems, + paymentMethod: "stripe", + promoCode: "1st Month Free (Monthly Plan)", + sfOrderId, + sfOpportunityId, // Pass to WHMCS for bidirectional linking + notes: orderNotes, + noinvoiceemail: true, + noemail: true, + }); + + whmcsCreateResult = result; + return result; + }), + rollback: () => { + if (whmcsCreateResult?.orderId) { + // Note: WHMCS doesn't have an automated cancel API + // Manual intervention required for order cleanup + this.logger.error( + "WHMCS order created but fulfillment failed - manual cleanup required", + { + orderId: whmcsCreateResult.orderId, + sfOrderId, + action: "MANUAL_CLEANUP_REQUIRED", + } + ); + } + return Promise.resolve(); + }, + critical: true, + }, + { + id: "whmcs_accept", + description: "Accept/provision order in WHMCS", + execute: this.createTrackedStep(context, "whmcs_accept", async () => { + if (!whmcsCreateResult?.orderId) { + throw new WhmcsOperationException( + "WHMCS order ID missing before acceptance step", + { + sfOrderId, + step: "whmcs_accept_order", + } + ); + } + + 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) { + // Note: WHMCS doesn't have an automated cancel API for accepted orders + // Manual intervention required for service termination + this.logger.error( + "WHMCS order accepted but fulfillment failed - manual cleanup required", + { + orderId: whmcsCreateResult.orderId, + serviceIds: whmcsCreateResult.serviceIds, + sfOrderId, + action: "MANUAL_SERVICE_TERMINATION_REQUIRED", + } + ); + } + return Promise.resolve(); + }, + critical: true, + }, + // Note: sim_fulfillment step was moved earlier in the flow (before WHMCS) + { + id: "sf_registration_complete", + description: "Update Salesforce with WHMCS registration info", + execute: this.createTrackedStep(context, "sf_registration_complete", async () => { + // 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, + 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: "Activated", + activationStatus: "Activated", + stage: "completed", + source: "fulfillment", + timestamp: new Date().toISOString(), + payload: { + whmcsOrderId: whmcsCreateResult?.orderId, + whmcsServiceIds: whmcsCreateResult?.serviceIds, + simPhoneNumber: simFulfillmentResult?.phoneNumber, + }, + }); + await this.safeNotifyOrder({ + type: NOTIFICATION_TYPE.ORDER_ACTIVATED, + sfOrderId, + accountId: context.validation?.sfOrder?.AccountId, + actionUrl: "/account/services", + }); + return result; + }), + rollback: async () => { + await this.salesforceService.updateOrder({ + Id: sfOrderId, + Activation_Status__c: "Failed", + }); + }, + critical: true, + }, + { + id: "opportunity_update", + description: "Update Opportunity with WHMCS Service ID and Active stage", + execute: this.createTrackedStep(context, "opportunity_update", async () => { + const opportunityId = context.orderDetails?.opportunityId; + const serviceId = whmcsCreateResult?.serviceIds?.[0]; + + if (!opportunityId) { + this.logger.debug("No Opportunity linked to order, skipping update", { + sfOrderId, + }); + return { skipped: true as const }; + } + + try { + // Update Opportunity stage to Active and set WHMCS Service ID + await this.opportunityService.updateStage( + opportunityId, + OPPORTUNITY_STAGE.ACTIVE, + "Service activated via fulfillment" + ); + + if (serviceId) { + await this.opportunityService.linkWhmcsServiceToOpportunity( + opportunityId, + serviceId, + context.validation?.clientId // Pass client ID for WHMCS admin URL + ); + } + + this.logger.log("Opportunity updated with Active stage and WHMCS link", { + opportunityIdTail: opportunityId.slice(-4), + whmcsServiceId: serviceId, + sfOrderId, + }); + + return { opportunityId, whmcsServiceId: serviceId }; + } catch (error) { + // Log but don't fail - Opportunity update is non-critical + this.logger.warn("Failed to update Opportunity after fulfillment", { + error: extractErrorMessage(error), + opportunityId, + sfOrderId, + }); + return { failed: true as const, error: extractErrorMessage(error) }; + } + }), + critical: false, // Opportunity update failure shouldn't rollback fulfillment + }, + ], + { + description: `Order fulfillment for ${sfOrderId}`, + timeout: 300000, // 5 minutes + continueOnNonCriticalFailure: true, + } + ); if (!fulfillmentResult.success) { this.logger.error("Fulfillment transaction failed", { @@ -479,8 +574,9 @@ export class OrderFulfillmentOrchestrator { ); } - context.mappingResult = state.mappingResult; - context.whmcsResult = state.whmcsCreateResult; + // Update context with results + context.mappingResult = mappingResult; + context.whmcsResult = whmcsCreateResult; this.logger.log("Transactional fulfillment completed successfully", { sfOrderId, @@ -488,46 +584,278 @@ export class OrderFulfillmentOrchestrator { duration: fulfillmentResult.duration, }); - await this.sideEffects.invalidateCaches(sfOrderId, context.validation?.sfOrder?.AccountId); + await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); return context; } catch (error) { + await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); await this.handleFulfillmentError(context, error as Error); - await this.sideEffects.onFulfillmentFailed( + await this.safeNotifyOrder({ + type: NOTIFICATION_TYPE.ORDER_FAILED, sfOrderId, - context.validation?.sfOrder?.AccountId, - error instanceof Error ? error.message : String(error) - ); + accountId: context.validation?.sfOrder?.AccountId, + actionUrl: `/account/orders/${sfOrderId}`, + }); + this.orderEvents.publish(sfOrderId, { + orderId: sfOrderId, + status: "Pending Review", + activationStatus: "Failed", + stage: "failed", + source: "fulfillment", + timestamp: new Date().toISOString(), + reason: error instanceof Error ? error.message : String(error), + }); throw error; } } /** * Initialize fulfillment steps + * + * New order (Physical SIM activation flow): + * 1. validation + * 2. sf_status_update (Activating) + * 3. order_details + * 4. sim_fulfillment (PA05-18 + PA02-01 + PA05-05) - SIM orders only + * 5. sf_activated_update - SIM orders only + * 6. mapping (with SIM data for WHMCS) + * 7. whmcs_create + * 8. whmcs_accept + * 9. sf_registration_complete (WHMCS info, skip Status for SIM) + * 10. opportunity_update */ private initializeSteps(orderType?: string): OrderFulfillmentStep[] { const steps: OrderFulfillmentStep[] = [ { step: "validation", status: "pending" }, { step: "sf_status_update", status: "pending" }, { step: "order_details", status: "pending" }, + ]; + + // Add SIM fulfillment steps for SIM orders (before WHMCS) + if (orderType === "SIM") { + steps.push({ step: "sim_fulfillment", status: "pending" }); + steps.push({ step: "sf_activated_update", status: "pending" }); + } + + // WHMCS steps come after SIM activation + steps.push( { step: "mapping", status: "pending" }, { step: "whmcs_create", status: "pending" }, { step: "whmcs_accept", status: "pending" }, - ]; + { step: "sf_registration_complete", status: "pending" }, + { step: "opportunity_update", status: "pending" } + ); - // Add SIM fulfillment step for SIM orders - if (orderType === "SIM") { - steps.push({ step: "sim_fulfillment", status: "pending" }); - } - - steps.push({ step: "sf_success_update", status: "pending" }); return steps; } - private extractConfigurations(rawConfigurations: unknown): Record { + private extractConfigurations( + rawConfigurations: unknown, + sfOrder?: SalesforceOrderRecord | null + ): Record { + const config: Record = {}; + + // Start with payload configurations if provided if (rawConfigurations && typeof rawConfigurations === "object") { - return { ...(rawConfigurations as Record) }; + Object.assign(config, rawConfigurations as Record); + } + + // Fill in missing fields from Salesforce order (CDC flow fallback) + if (sfOrder) { + if (!config.simType && sfOrder.SIM_Type__c) { + config.simType = sfOrder.SIM_Type__c; + } + if (!config.eid && sfOrder.EID__c) { + config.eid = sfOrder.EID__c; + } + if (!config.activationType && sfOrder.Activation_Type__c) { + config.activationType = sfOrder.Activation_Type__c; + } + if (!config.scheduledAt && sfOrder.Activation_Scheduled_At__c) { + config.scheduledAt = sfOrder.Activation_Scheduled_At__c; + } + if (!config.mnpPhone && sfOrder.MNP_Phone_Number__c) { + config.mnpPhone = sfOrder.MNP_Phone_Number__c; + } + // MNP fields + if (!config.isMnp && sfOrder.MNP_Application__c) { + config.isMnp = sfOrder.MNP_Application__c ? "true" : undefined; + } + if (!config.mnpNumber && sfOrder.MNP_Reservation_Number__c) { + config.mnpNumber = sfOrder.MNP_Reservation_Number__c; + } + if (!config.mnpExpiry && sfOrder.MNP_Expiry_Date__c) { + config.mnpExpiry = sfOrder.MNP_Expiry_Date__c; + } + if (!config.mvnoAccountNumber && sfOrder.MVNO_Account_Number__c) { + config.mvnoAccountNumber = sfOrder.MVNO_Account_Number__c; + } + if (!config.portingFirstName && sfOrder.Porting_FirstName__c) { + config.portingFirstName = sfOrder.Porting_FirstName__c; + } + if (!config.portingLastName && sfOrder.Porting_LastName__c) { + config.portingLastName = sfOrder.Porting_LastName__c; + } + if (!config.portingFirstNameKatakana && sfOrder.Porting_FirstName_Katakana__c) { + config.portingFirstNameKatakana = sfOrder.Porting_FirstName_Katakana__c; + } + if (!config.portingLastNameKatakana && sfOrder.Porting_LastName_Katakana__c) { + config.portingLastNameKatakana = sfOrder.Porting_LastName_Katakana__c; + } + if (!config.portingGender && sfOrder.Porting_Gender__c) { + config.portingGender = sfOrder.Porting_Gender__c; + } + if (!config.portingDateOfBirth && sfOrder.Porting_DateOfBirth__c) { + config.portingDateOfBirth = sfOrder.Porting_DateOfBirth__c; + } + } + + return config; + } + + /** + * Extract contact identity data from Salesforce order porting fields + * + * Used for PA05-05 Voice Options Registration which requires: + * - Name in Kanji and Kana + * - Gender (M/F) + * - Birthday (YYYYMMDD) + * + * Returns undefined if required fields are missing. + */ + private extractContactIdentity( + sfOrder?: SalesforceOrderRecord | null + ): ContactIdentityData | undefined { + if (!sfOrder) return undefined; + + // Extract porting fields + const firstnameKanji = sfOrder.Porting_FirstName__c; + const lastnameKanji = sfOrder.Porting_LastName__c; + const firstnameKana = sfOrder.Porting_FirstName_Katakana__c; + const lastnameKana = sfOrder.Porting_LastName_Katakana__c; + const genderRaw = sfOrder.Porting_Gender__c; + const birthdayRaw = sfOrder.Porting_DateOfBirth__c; + + // Validate all required fields are present + if (!firstnameKanji || !lastnameKanji) { + this.logger.debug("Missing name fields for contact identity", { + hasFirstName: !!firstnameKanji, + hasLastName: !!lastnameKanji, + }); + return undefined; + } + + if (!firstnameKana || !lastnameKana) { + this.logger.debug("Missing kana name fields for contact identity", { + hasFirstNameKana: !!firstnameKana, + hasLastNameKana: !!lastnameKana, + }); + return undefined; + } + + // Validate gender (must be M or F) + const gender = genderRaw === "M" || genderRaw === "F" ? genderRaw : undefined; + if (!gender) { + this.logger.debug("Invalid or missing gender for contact identity", { genderRaw }); + return undefined; + } + + // Format birthday to YYYYMMDD + const birthday = this.formatBirthdayToYYYYMMDD(birthdayRaw); + if (!birthday) { + this.logger.debug("Invalid or missing birthday for contact identity", { birthdayRaw }); + return undefined; + } + + return { + firstnameKanji, + lastnameKanji, + firstnameKana, + lastnameKana, + gender, + birthday, + }; + } + + /** + * Format birthday from various formats to YYYYMMDD + */ + private formatBirthdayToYYYYMMDD(dateStr?: string | null): string | undefined { + if (!dateStr) return undefined; + + // If already in YYYYMMDD format + if (/^\d{8}$/.test(dateStr)) { + return dateStr; + } + + // Try parsing as ISO date (YYYY-MM-DD) + const isoMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (isoMatch) { + return `${isoMatch[1]}${isoMatch[2]}${isoMatch[3]}`; + } + + // Try parsing as Date object + try { + const date = new Date(dateStr); + if (!isNaN(date.getTime())) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}${month}${day}`; + } + } catch { + // Parsing failed + } + + return undefined; + } + + private async invalidateOrderCaches(orderId: string, accountId?: string | null): Promise { + const tasks: Array> = [this.ordersCache.invalidateOrder(orderId)]; + if (accountId) { + tasks.push(this.ordersCache.invalidateAccountOrders(accountId)); + } + + try { + await Promise.all(tasks); + } catch (error) { + this.logger.warn("Failed to invalidate order caches", { + orderId, + accountId: accountId ?? undefined, + error: extractErrorMessage(error), + }); + } + } + + private async safeNotifyOrder(params: { + type: (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE]; + sfOrderId: string; + accountId?: unknown; + actionUrl: string; + }): Promise { + try { + const sfAccountId = salesforceAccountIdSchema.safeParse(params.accountId); + if (!sfAccountId.success) return; + + const mapping = await this.mappingsService.findBySfAccountId(sfAccountId.data); + if (!mapping?.userId) return; + + await this.notifications.createNotification({ + userId: mapping.userId, + type: params.type, + source: NOTIFICATION_SOURCE.SYSTEM, + sourceId: params.sfOrderId, + actionUrl: params.actionUrl, + }); + } catch (error) { + this.logger.warn( + { + sfOrderId: params.sfOrderId, + type: params.type, + err: error instanceof Error ? error.message : String(error), + }, + "Failed to create in-app order notification" + ); } - return {}; } /** @@ -559,8 +887,8 @@ export class OrderFulfillmentOrchestrator { this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode) ) .toString() - .slice(0, 60), - Activation_Error_Message__c: userMessage?.slice(0, 255), + .substring(0, 60), + Activation_Error_Message__c: userMessage?.substring(0, 255), }; await this.salesforceService.updateOrder(updates as { Id: string; [key: string]: unknown }); @@ -583,8 +911,8 @@ export class OrderFulfillmentOrchestrator { getFulfillmentSummary(context: OrderFulfillmentContext): { success: boolean; status: "Already Fulfilled" | "Fulfilled" | "Failed"; - whmcsOrderId?: string | undefined; - whmcsServiceIds?: number[] | undefined; + whmcsOrderId?: string; + whmcsServiceIds?: number[]; message: string; steps: OrderFulfillmentStep[]; } { @@ -592,24 +920,21 @@ export class OrderFulfillmentOrchestrator { const failedStep = context.steps.find((s: OrderFulfillmentStep) => s.status === "failed"); if (context.validation?.isAlreadyProvisioned) { - const whmcsOrderId = context.validation.whmcsOrderId; return { success: true, status: "Already Fulfilled", - ...(whmcsOrderId !== undefined && { whmcsOrderId }), + whmcsOrderId: context.validation.whmcsOrderId, message: "Order was already fulfilled in WHMCS", steps: context.steps, }; } if (isSuccess) { - const whmcsOrderId = context.whmcsResult?.orderId.toString(); - const whmcsServiceIds = context.whmcsResult?.serviceIds; return { success: true, status: "Fulfilled", - ...(whmcsOrderId !== undefined && { whmcsOrderId }), - ...(whmcsServiceIds !== undefined && { whmcsServiceIds }), + whmcsOrderId: context.whmcsResult?.orderId.toString(), + whmcsServiceIds: context.whmcsResult?.serviceIds, message: "Order fulfilled successfully in WHMCS", steps: context.steps, }; @@ -618,7 +943,7 @@ export class OrderFulfillmentOrchestrator { return { success: false, status: "Failed", - message: failedStep?.error ?? "Fulfillment failed", + message: failedStep?.error || "Fulfillment failed", steps: context.steps, }; } @@ -636,17 +961,13 @@ export class OrderFulfillmentOrchestrator { if (status === "in_progress") { step.status = "in_progress"; step.startedAt = timestamp; - delete step.error; + step.error = undefined; return; } step.status = status; step.completedAt = timestamp; - if (status === "failed" && error !== undefined) { - step.error = error; - } else { - delete step.error; - } + step.error = status === "failed" ? error : undefined; } private createTrackedStep( 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 f64d0b8c..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,14 +1,13 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js"; +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"; -import type { - OrderFulfillmentValidationResult, - SalesforceOrderRecord, -} from "@customer-portal/domain/orders/providers"; +import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers"; import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; +import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers"; import { PaymentValidatorService } from "./payment-validator.service.js"; /** @@ -19,7 +18,8 @@ import { PaymentValidatorService } from "./payment-validator.service.js"; export class OrderFulfillmentValidator { constructor( @Inject(Logger) private readonly logger: Logger, - private readonly salesforceService: SalesforceFacade, + private readonly salesforceService: SalesforceService, + private readonly salesforceAccountService: SalesforceAccountService, private readonly mappingsService: MappingsService, private readonly paymentValidator: PaymentValidatorService ) {} @@ -64,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, @@ -124,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 afe2f70c..e3510eb4 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -1,84 +1,108 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; +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 { 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"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { SimActivationException, OrderValidationException, } from "@bff/core/exceptions/domain-exceptions.js"; +/** + * Contact identity data for PA05-05 voice option registration + */ +export interface ContactIdentityData { + firstnameKanji: string; + lastnameKanji: string; + firstnameKana: string; + lastnameKana: string; + gender: "M" | "F"; + birthday: string; // YYYYMMDD format +} + export interface SimFulfillmentRequest { orderDetails: OrderDetails; configurations: Record; + /** Salesforce ID of the assigned Physical SIM (from Assign_Physical_SIM__c) */ + assignedPhysicalSimId?: string; + /** Voice Mail enabled from Order.SIM_Voice_Mail__c */ + voiceMailEnabled?: boolean; + /** Call Waiting enabled from Order.SIM_Call_Waiting__c */ + callWaitingEnabled?: boolean; + /** Contact identity data for PA05-05 */ + contactIdentity?: ContactIdentityData; } -type SimType = "eSIM" | "Physical SIM"; -type ActivationType = "Immediate" | "Scheduled"; - -interface MnpConfig { - reserveNumber?: string; - reserveExpireDate?: string; - account?: string; - firstnameKanji?: string; - lastnameKanji?: string; - firstnameZenKana?: string; - lastnameZenKana?: string; - gender?: string; - birthday?: string; -} - -interface ParsedSimConfig { - simType: SimType; - eid: string | undefined; - activationType: ActivationType; - scheduledAt: string | undefined; - phoneNumber: string | undefined; - mnp: MnpConfig | undefined; +/** + * Result from SIM fulfillment containing inventory data for WHMCS + */ +export interface SimFulfillmentResult { + /** Whether the SIM was successfully activated */ + activated: boolean; + /** SIM type that was activated */ + simType: "eSIM" | "Physical SIM"; + /** Phone number from SIM inventory (for WHMCS custom fields) */ + phoneNumber?: string; + /** PT Number / Serial number from SIM inventory (for WHMCS custom fields) */ + serialNumber?: string; + /** Salesforce SIM Inventory ID */ + simInventoryId?: string; } @Injectable() export class SimFulfillmentService { constructor( - private readonly freebit: FreebitFacade, + private readonly freebit: FreebitOrchestratorService, + private readonly freebitAccountReg: FreebitAccountRegistrationService, + private readonly freebitVoiceOptions: FreebitVoiceOptionsService, + private readonly simInventory: SalesforceSIMInventoryService, @Inject(Logger) private readonly logger: Logger ) {} - async fulfillSimOrder(request: SimFulfillmentRequest): Promise { - const { orderDetails, configurations } = request; + async fulfillSimOrder(request: SimFulfillmentRequest): Promise { + const { + orderDetails, + configurations, + assignedPhysicalSimId, + voiceMailEnabled = false, + callWaitingEnabled = false, + contactIdentity, + } = request; + + const simType = this.readEnum(configurations.simType, ["eSIM", "Physical SIM"]); this.logger.log("Starting SIM fulfillment", { orderId: orderDetails.id, orderType: orderDetails.orderType, + simType: simType ?? "(not set)", + hasAssignedPhysicalSim: !!assignedPhysicalSimId, + voiceMailEnabled, + callWaitingEnabled, + hasContactIdentity: !!contactIdentity, }); - const config = this.parseSimConfig(configurations); - const planSku = this.extractPlanSku(orderDetails); + // Validate SIM type is explicitly set - don't default to eSIM + if (!simType) { + throw new SimActivationException( + "SIM Type must be explicitly set to 'eSIM' or 'Physical SIM'", + { + orderId: orderDetails.id, + configuredSimType: configurations.simType, + } + ); + } - this.validateSimConfig(orderDetails.id, config); + const eid = this.readString(configurations.eid); + const activationType = + this.readEnum(configurations.activationType, ["Immediate", "Scheduled"]) ?? "Immediate"; + const scheduledAt = this.readString(configurations.scheduledAt); + const phoneNumber = this.readString(configurations.mnpPhone); + const mnp = this.extractMnpConfig(configurations); - await this.executeSimActivation(orderDetails.id, config, planSku); - - this.logger.log("SIM fulfillment completed successfully", { - orderId: orderDetails.id, - account: config.phoneNumber, - planSku, - }); - } - - private parseSimConfig(configurations: Record): ParsedSimConfig { - return { - simType: this.readEnum(configurations["simType"], ["eSIM", "Physical SIM"]) ?? "eSIM", - eid: this.readString(configurations["eid"]), - activationType: - this.readEnum(configurations["activationType"], ["Immediate", "Scheduled"]) ?? "Immediate", - scheduledAt: this.readString(configurations["scheduledAt"]), - phoneNumber: this.readString(configurations["mnpPhone"]), - mnp: this.extractMnpConfig(configurations), - }; - } - - private extractPlanSku(orderDetails: OrderDetails): string { const simPlanItem = orderDetails.items.find( (item: OrderItemDetails) => item.product?.itemClass === "Plan" || item.product?.sku?.toLowerCase().includes("sim") @@ -91,6 +115,7 @@ export class SimFulfillmentService { } const planSku = simPlanItem.product?.sku; + const planName = simPlanItem.product?.name; if (!planSku) { throw new OrderValidationException("SIM plan SKU not found", { orderId: orderDetails.id, @@ -98,77 +123,128 @@ export class SimFulfillmentService { }); } - return planSku; - } - - private validateSimConfig(orderId: string, config: ParsedSimConfig): void { - if (config.simType === "eSIM" && (!config.eid || config.eid.length < 15)) { - throw new SimActivationException("EID is required for eSIM and must be valid", { - orderId, - simType: config.simType, - eidLength: config.eid?.length, - }); - } - - if (!config.phoneNumber) { - throw new SimActivationException("Phone number is required for SIM activation", { - orderId, - }); - } - } - - private async executeSimActivation( - orderId: string, - config: ParsedSimConfig, - planSku: string - ): Promise { - const { simType, eid, activationType, scheduledAt, phoneNumber, mnp } = config; - if (simType === "eSIM") { - if (!eid) { - throw new SimActivationException("EID is required for eSIM activation", { orderId }); + // eSIM activation flow + if (!eid || eid.length < 15) { + throw new SimActivationException("EID is required for eSIM and must be valid", { + orderId: orderDetails.id, + simType, + eidLength: eid?.length, + }); } + + if (!phoneNumber) { + throw new SimActivationException("Phone number is required for eSIM activation", { + orderId: orderDetails.id, + }); + } + await this.activateEsim({ - account: phoneNumber!, + account: phoneNumber, eid, planSku, activationType, scheduledAt, mnp, }); - } else { - await this.activatePhysicalSim({ - account: phoneNumber!, + + this.logger.log("eSIM fulfillment completed successfully", { + orderId: orderDetails.id, + account: phoneNumber, planSku, - activationType, - scheduledAt, }); + + return { + activated: true, + simType: "eSIM", + phoneNumber, + }; + } else { + // 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)", + { orderId: orderDetails.id } + ); + } + + const simData = await this.activatePhysicalSim({ + orderId: orderDetails.id, + simInventoryId: assignedPhysicalSimId, + planSku, + planName, + voiceMailEnabled, + callWaitingEnabled, + contactIdentity, + }); + + this.logger.log("Physical SIM fulfillment completed successfully", { + orderId: orderDetails.id, + simInventoryId: assignedPhysicalSimId, + planSku, + voiceMailEnabled, + callWaitingEnabled, + phoneNumber: simData.phoneNumber, + serialNumber: simData.serialNumber, + }); + + return { + activated: true, + simType: "Physical SIM", + phoneNumber: simData.phoneNumber, + serialNumber: simData.serialNumber, + simInventoryId: assignedPhysicalSimId, + }; } } + /** + * Activate eSIM via Freebit PA05-41 API + */ private async activateEsim(params: { account: string; eid: string; planSku: string; - activationType: ActivationType; - scheduledAt: string | undefined; - mnp: MnpConfig | undefined; + activationType: "Immediate" | "Scheduled"; + scheduledAt?: string; + mnp?: { + reserveNumber?: string; + reserveExpireDate?: string; + account?: string; + firstnameKanji?: string; + lastnameKanji?: string; + firstnameZenKana?: string; + lastnameZenKana?: string; + gender?: string; + birthday?: string; + }; }): Promise { const { account, eid, planSku, activationType, scheduledAt, mnp } = params; try { - const shipDate = activationType === "Scheduled" ? scheduledAt : undefined; - const mnpData = this.buildMnpData(mnp); - const identityData = this.buildIdentityData(mnp); - await this.freebit.activateEsimAccountNew({ account, eid, planCode: planSku, contractLine: "5G", - ...(shipDate !== undefined && { shipDate }), - ...(mnpData !== undefined && { mnp: mnpData }), - ...(identityData !== undefined && { identity: identityData }), + shipDate: activationType === "Scheduled" ? scheduledAt : undefined, + mnp: + mnp && mnp.reserveNumber && mnp.reserveExpireDate + ? { + reserveNumber: mnp.reserveNumber, + reserveExpireDate: mnp.reserveExpireDate, + } + : undefined, + identity: mnp + ? { + firstnameKanji: mnp.firstnameKanji, + lastnameKanji: mnp.lastnameKanji, + firstnameZenKana: mnp.firstnameZenKana, + lastnameZenKana: mnp.lastnameZenKana, + gender: mnp.gender, + birthday: mnp.birthday, + } + : undefined, }); this.logger.log("eSIM activated successfully", { @@ -186,59 +262,150 @@ export class SimFulfillmentService { } } + /** + * Activate Physical SIM via Freebit PA02-01 + PA05-05 APIs + * + * 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 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: { - account: string; + orderId: string; + simInventoryId: string; planSku: string; - activationType: ActivationType; - scheduledAt: string | undefined; - }): Promise { - const { account, planSku, activationType, scheduledAt } = params; - try { - const topUpOptions: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}; - if (activationType === "Scheduled" && scheduledAt !== undefined) { - topUpOptions.scheduledAt = scheduledAt; - } - await this.freebit.topUpSim(account, 0, topUpOptions); + planName?: string; + voiceMailEnabled: boolean; + callWaitingEnabled: boolean; + contactIdentity?: ContactIdentityData; + }): Promise<{ phoneNumber: string; serialNumber: string }> { + const { + orderId, + simInventoryId, + planSku, + planName, + voiceMailEnabled, + callWaitingEnabled, + contactIdentity, + } = params; - this.logger.log("Physical SIM activation scheduled", { - account, - planSku, + this.logger.log("Starting Physical SIM activation (PA02-01 + PA05-05)", { + orderId, + simInventoryId, + planSku, + voiceMailEnabled, + callWaitingEnabled, + hasContactIdentity: !!contactIdentity, + }); + + // Step 1 & 2: Fetch and validate SIM Inventory + const simRecord = await this.simInventory.getAndValidateForActivation(simInventoryId); + + // Step 3: Map product to Freebit plan code + const planCode = mapProductToFreebitPlanCode(planSku, planName); + if (!planCode) { + throw new SimActivationException( + `Unable to map product to Freebit plan code. SKU: ${planSku}, Name: ${planName}`, + { orderId, simInventoryId, planSku, planName } + ); + } + + // Use phone number from SIM inventory + const accountPhoneNumber = simRecord.phoneNumber; + + this.logger.log("Physical SIM inventory validated", { + orderId, + simInventoryId, + accountPhoneNumber, + ptNumber: simRecord.ptNumber, + planCode, + }); + + try { + // Step 4: Call Freebit PA02-01 (Account Registration) + this.logger.log("Calling PA02-01 Account Registration", { + orderId, + account: accountPhoneNumber, + planCode, }); + + await this.freebitAccountReg.registerAccount({ + account: accountPhoneNumber, + planCode, + createType: "new", + }); + + this.logger.log("PA02-01 Account Registration successful", { + orderId, + account: accountPhoneNumber, + }); + + // 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", { + orderId, + account: accountPhoneNumber, + voiceMailEnabled, + callWaitingEnabled, + }); + + await this.freebitVoiceOptions.registerVoiceOptions({ + account: accountPhoneNumber, + voiceMailEnabled, + callWaitingEnabled, + identificationData: { + lastnameKanji: contactIdentity.lastnameKanji, + firstnameKanji: contactIdentity.firstnameKanji, + lastnameKana: contactIdentity.lastnameKana, + firstnameKana: contactIdentity.firstnameKana, + gender: contactIdentity.gender, + birthday: contactIdentity.birthday, + }, + }); + + this.logger.log("PA05-05 Voice Options Registration successful", { + orderId, + account: accountPhoneNumber, + }); + } else { + this.logger.warn("Skipping PA05-05: No contact identity data provided", { + orderId, + account: accountPhoneNumber, + }); + } + + // Step 6: Update SIM Inventory status to "Assigned" + await this.simInventory.markAsAssigned(simInventoryId); + + this.logger.log("Physical SIM activated successfully", { + orderId, + simInventoryId, + accountPhoneNumber, + planCode, + voiceMailEnabled, + callWaitingEnabled, + }); + + // Return SIM data for WHMCS custom fields + return { + phoneNumber: simRecord.phoneNumber, + serialNumber: simRecord.ptNumber, + }; } catch (error: unknown) { this.logger.error("Physical SIM activation failed", { - account, - planSku, + orderId, + simInventoryId, + phoneNumber: simRecord.phoneNumber, error: extractErrorMessage(error), }); throw error; } } - private buildMnpData( - mnp: MnpConfig | undefined - ): { reserveNumber: string; reserveExpireDate: string } | undefined { - if (!mnp?.reserveNumber || !mnp?.reserveExpireDate) { - return undefined; - } - return { reserveNumber: mnp.reserveNumber, reserveExpireDate: mnp.reserveExpireDate }; - } - - private buildIdentityData(mnp: MnpConfig | undefined): Record | undefined { - if (!mnp) { - return undefined; - } - - const identity: Record = {}; - if (mnp.firstnameKanji !== undefined) identity["firstnameKanji"] = mnp.firstnameKanji; - if (mnp.lastnameKanji !== undefined) identity["lastnameKanji"] = mnp.lastnameKanji; - if (mnp.firstnameZenKana !== undefined) identity["firstnameZenKana"] = mnp.firstnameZenKana; - if (mnp.lastnameZenKana !== undefined) identity["lastnameZenKana"] = mnp.lastnameZenKana; - if (mnp.gender !== undefined) identity["gender"] = mnp.gender; - if (mnp.birthday !== undefined) identity["birthday"] = mnp.birthday; - - return Object.keys(identity).length > 0 ? identity : undefined; - } - private readString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } @@ -247,46 +414,54 @@ export class SimFulfillmentService { return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined; } - private extractMnpConfig(config: Record): MnpConfig | undefined { - const nested = config["mnp"]; + private extractMnpConfig(config: Record) { + const nested = config.mnp; const source = nested && typeof nested === "object" ? (nested as Record) : config; - const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]); + const isMnpFlag = this.readString(source.isMnp ?? config.isMnp); if (isMnpFlag && isMnpFlag !== "true") { return undefined; } - const mnpConfig = this.buildMnpConfigFromSource(source); - return this.hasMnpFields(mnpConfig) ? mnpConfig : undefined; - } + const reserveNumber = this.readString(source.mnpNumber ?? source.reserveNumber); + const reserveExpireDate = this.readString(source.mnpExpiry ?? source.reserveExpireDate); + const account = this.readString(source.mvnoAccountNumber ?? source.account); + const firstnameKanji = this.readString(source.portingFirstName ?? source.firstnameKanji); + const lastnameKanji = this.readString(source.portingLastName ?? source.lastnameKanji); + const firstnameZenKana = this.readString( + source.portingFirstNameKatakana ?? source.firstnameZenKana + ); + const lastnameZenKana = this.readString( + source.portingLastNameKatakana ?? source.lastnameZenKana + ); + const gender = this.readString(source.portingGender ?? source.gender); + const birthday = this.readString(source.portingDateOfBirth ?? source.birthday); - private buildMnpConfigFromSource(source: Record): MnpConfig { - return this.buildConfigObject([ - ["reserveNumber", source["mnpNumber"] ?? source["reserveNumber"]], - ["reserveExpireDate", source["mnpExpiry"] ?? source["reserveExpireDate"]], - ["account", source["mvnoAccountNumber"] ?? source["account"]], - ["firstnameKanji", source["portingFirstName"] ?? source["firstnameKanji"]], - ["lastnameKanji", source["portingLastName"] ?? source["lastnameKanji"]], - ["firstnameZenKana", source["portingFirstNameKatakana"] ?? source["firstnameZenKana"]], - ["lastnameZenKana", source["portingLastNameKatakana"] ?? source["lastnameZenKana"]], - ["gender", source["portingGender"] ?? source["gender"]], - ["birthday", source["portingDateOfBirth"] ?? source["birthday"]], - ]); - } - - private buildConfigObject(entries: [keyof MnpConfig, unknown][]): MnpConfig { - const config: MnpConfig = {}; - for (const [key, value] of entries) { - const strValue = this.readString(value); - if (strValue !== undefined) { - config[key] = strValue; - } + if ( + !reserveNumber && + !reserveExpireDate && + !account && + !firstnameKanji && + !lastnameKanji && + !firstnameZenKana && + !lastnameZenKana && + !gender && + !birthday + ) { + return undefined; } - return config; - } - private hasMnpFields(config: MnpConfig): boolean { - return Object.values(config).some(value => value !== undefined); + return { + reserveNumber, + reserveExpireDate, + account, + firstnameKanji, + lastnameKanji, + firstnameZenKana, + lastnameZenKana, + gender, + birthday, + }; } } diff --git a/apps/bff/src/modules/services/application/sim-services.service.ts b/apps/bff/src/modules/services/application/sim-services.service.ts index 229f8383..ead26ba6 100644 --- a/apps/bff/src/modules/services/application/sim-services.service.ts +++ b/apps/bff/src/modules/services/application/sim-services.service.ts @@ -33,7 +33,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", @@ -68,7 +68,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" @@ -120,7 +120,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/bff/src/modules/subscriptions/sim-management/services/queries/sim-billing.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-billing.service.ts index 120c1499..50327120 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-billing.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-billing.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Inject, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -6,6 +7,7 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js"; export interface SimChargeInvoiceResult { invoice: { id: number; number: string; total: number; status: string }; transactionId?: string; + refunded?: boolean; } interface OneTimeChargeParams { @@ -23,10 +25,19 @@ interface OneTimeChargeParams { @Injectable() export class SimBillingService { + private readonly testMode: boolean; + constructor( private readonly whmcsInvoiceService: WhmcsInvoiceService, + private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger - ) {} + ) { + // Enable test mode via environment variable + this.testMode = this.configService.get("SIM_BILLING_TEST_MODE") === "true"; + if (this.testMode) { + this.logger.warn("SIM Billing is in TEST MODE - payments will be automatically refunded"); + } + } async createOneTimeCharge(params: OneTimeChargeParams): Promise { const { @@ -47,15 +58,15 @@ export class SimBillingService { description, amount: amountJpy, currency, - ...(dueDate === undefined ? {} : { dueDate }), - ...(notes === undefined ? {} : { notes }), + dueDate, + notes, }); const paymentResult = await this.whmcsInvoiceService.capturePayment({ invoiceId: invoice.id, amount: amountJpy, currency, - ...(userId === undefined ? {} : { userId }), + userId, }); if (!paymentResult.success) { @@ -80,14 +91,44 @@ export class SimBillingService { description, amountJpy, transactionId: paymentResult.transactionId, + testMode: this.testMode, ...metadata, }); + // In test mode, automatically refund the payment + let refunded = false; + if (this.testMode && paymentResult.transactionId) { + this.logger.log("TEST MODE: Automatically refunding payment", { + invoiceId: invoice.id, + transactionId: paymentResult.transactionId, + amount: amountJpy, + }); + + const refundResult = await this.whmcsInvoiceService.refundPayment({ + invoiceId: invoice.id, + transactionId: paymentResult.transactionId, + amount: amountJpy, + reason: "TEST MODE - Automatic refund for SIM billing test", + }); + + if (refundResult.success) { + refunded = true; + this.logger.log("TEST MODE: Payment refunded successfully", { + invoiceId: invoice.id, + amount: amountJpy, + }); + } else { + this.logger.warn("TEST MODE: Failed to refund payment", { + invoiceId: invoice.id, + error: refundResult.error, + }); + } + } + return { invoice, - ...(paymentResult.transactionId === undefined - ? {} - : { transactionId: paymentResult.transactionId }), + transactionId: paymentResult.transactionId, + refunded, }; } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts index 7fba71cc..1adc4bbc 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts @@ -122,10 +122,32 @@ export class SimOrchestrator { */ async getSimInfo(userId: string, subscriptionId: number): Promise { try { - const [details, usage] = await Promise.all([ - this.getSimDetails(userId, subscriptionId), - this.getSimUsage(userId, subscriptionId), - ]); + // Fetch details first (required) + const details = await this.getSimDetails(userId, subscriptionId); + + // Fetch usage separately - gracefully handle errors (e.g., error 210 = no traffic data) + let usage: SimUsage; + try { + usage = await this.getSimUsage(userId, subscriptionId); + } catch (usageError) { + // Log but don't fail - return default usage values + this.logger.warn( + `Failed to get SIM usage for subscription ${subscriptionId}, using defaults`, + { + error: extractErrorMessage(usageError), + userId, + subscriptionId, + note: "This is normal for new SIMs or accounts without traffic data (error 210)", + } + ); + usage = { + account: details.account || "", + todayUsageMb: 0, + todayUsageKb: 0, + recentDaysUsage: [], + isBlacklisted: false, + }; + } // If Freebit doesn't return remaining quota, derive it from plan code (e.g., PASI_50G) // by subtracting measured usage (today + recentDays) from the plan cap. diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts index c9faabce..022d7ecb 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts @@ -1,7 +1,6 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; -import { SubscriptionsOrchestrator } from "../../subscriptions-orchestrator.service.js"; +import { SubscriptionsService } from "../../subscriptions.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import type { SimValidationResult } from "../interfaces/sim-base.interface.js"; import { @@ -13,8 +12,7 @@ import { @Injectable() export class SimValidationService { constructor( - private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator, - private readonly configService: ConfigService, + private readonly subscriptionsService: SubscriptionsService, @Inject(Logger) private readonly logger: Logger ) {} @@ -29,7 +27,7 @@ export class SimValidationService { ): Promise { try { // Get subscription details to verify it's a SIM service - const subscription = await this.subscriptionsOrchestrator.getSubscriptionById( + const subscription = await this.subscriptionsService.getSubscriptionById( userId, subscriptionId ); @@ -42,46 +40,36 @@ export class SimValidationService { // Extract SIM account identifier (using domain function) let account = extractSimAccountFromSubscription(subscription); - // If no account found, check for test fallback from env or throw error + // If no account found, log detailed info and throw error if (!account) { - const testSimAccount = this.configService.get("TEST_SIM_ACCOUNT"); + this.logger.error( + `No SIM account identifier found for subscription ${subscriptionId}`, + { + userId, + subscriptionId, + productName: subscription.productName, + domain: subscription.domain, + customFieldKeys: subscription.customFields + ? Object.keys(subscription.customFields) + : [], + customFieldValues: subscription.customFields, + orderNumber: subscription.orderNumber, + note: "Set the phone number in 'domain' field or a custom field named 'phone', 'msisdn', 'Phone Number', etc.", + } + ); - if (testSimAccount) { - account = testSimAccount; - this.logger.warn( - `No SIM account identifier found for subscription ${subscriptionId}, using TEST_SIM_ACCOUNT fallback`, - { - userId, - subscriptionId, - productName: subscription.productName, - domain: subscription.domain, - customFields: subscription.customFields ? Object.keys(subscription.customFields) : [], - } - ); - } else { - throw new BadRequestException( - `No SIM account identifier found for subscription ${subscriptionId}. ` + - "Please ensure the subscription has a valid SIM account number in custom fields." - ); - } + throw new BadRequestException( + `No SIM phone number found for this subscription. Please ensure the phone number is set in WHMCS (domain field or custom field named 'Phone Number', 'MSISDN', etc.)` + ); } // Clean up the account format (using domain function) account = cleanSimAccount(account); - // Skip phone number format validation for testing - // In production, you might want to add validation back: - // const cleanAccount = account.replace(/^\+81/, '0'); // Convert +81 to 0 - // if (!/^0\d{9,10}$/.test(cleanAccount)) { - // throw new BadRequestException(`Invalid SIM account format: ${account}. Expected Japanese phone number format (10-11 digits starting with 0).`); - // } - // account = cleanAccount; - - this.logger.log(`Using SIM account for testing: ${account}`, { + this.logger.log(`Using SIM account: ${account}`, { userId, subscriptionId, account, - note: "Phone number format validation skipped for testing", }); return { account }; @@ -105,45 +93,35 @@ export class SimValidationService { subscriptionId: number ): Promise> { try { - const subscription = await this.subscriptionsOrchestrator.getSubscriptionById( + const subscription = await this.subscriptionsService.getSubscriptionById( userId, subscriptionId ); - // Check for specific SIM data (from config or use defaults for testing) - const expectedSimNumber = this.configService.get("TEST_SIM_ACCOUNT", ""); - const expectedEid = this.configService.get("TEST_SIM_EID", ""); - - const foundSimNumber = Object.entries(subscription.customFields || {}).find( - ([, value]) => - value !== undefined && - value !== null && - this.formatCustomFieldValue(value).includes(expectedSimNumber) - ); - - const eidField = Object.entries(subscription.customFields || {}).find(([, value]) => { - if (value === undefined || value === null) return false; - return this.formatCustomFieldValue(value).includes(expectedEid); - }); + // Try to extract account using the standard function + const extractedAccount = extractSimAccountFromSubscription(subscription); return { subscriptionId, productName: subscription.productName, domain: subscription.domain, orderNumber: subscription.orderNumber, - customFields: subscription.customFields, isSimService: isSimSubscription(subscription), groupName: subscription.groupName, status: subscription.status, - // Specific SIM data checks - expectedSimNumber, - expectedEid, - foundSimNumber: foundSimNumber - ? { field: foundSimNumber[0], value: foundSimNumber[1] } - : null, - foundEid: eidField ? { field: eidField[0], value: eidField[1] } : null, - allCustomFieldKeys: Object.keys(subscription.customFields || {}), - allCustomFieldValues: subscription.customFields, + // Account extraction result + extractedAccount, + accountSource: extractedAccount + ? subscription.domain + ? "domain field" + : "custom field or order number" + : "NOT FOUND - check fields below", + // All custom fields for debugging + customFieldKeys: Object.keys(subscription.customFields || {}), + customFields: subscription.customFields, + hint: !extractedAccount + ? "Set phone number in 'domain' field OR add custom field named: phone, msisdn, phonenumber, Phone Number, MSISDN, etc." + : undefined, }; } catch (error) { const sanitizedError = extractErrorMessage(error); diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts index 8a84f776..d2d7d429 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts @@ -9,14 +9,16 @@ import { Header, UseGuards, } from "@nestjs/common"; -import { SimOrchestrator } from "./services/sim-orchestrator.service.js"; -import { SimTopUpPricingService } from "./services/queries/sim-topup-pricing.service.js"; -import { SimPlanService } from "./services/mutations/sim-plan.service.js"; -import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js"; -import { EsimManagementService } from "./services/mutations/esim-management.service.js"; +import { SimManagementService } from "../sim-management.service.js"; +import { SimTopUpPricingService } from "./services/sim-topup-pricing.service.js"; +import { SimPlanService } from "./services/sim-plan.service.js"; +import { SimCancellationService } from "./services/sim-cancellation.service.js"; +import { EsimManagementService } from "./services/esim-management.service.js"; +import { FreebitRateLimiterService } from "@bff/integrations/freebit/services/freebit-rate-limiter.service.js"; import { AdminGuard } from "@bff/core/security/guards/admin.guard.js"; import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; + import { subscriptionIdParamSchema, simActionResponseSchema, @@ -24,10 +26,10 @@ import { } from "@customer-portal/domain/subscriptions"; import type { SimActionResponse, SimPlanChangeResult } from "@customer-portal/domain/subscriptions"; import { - simTopUpRequestSchema, - simPlanChangeRequestSchema, + simTopupRequestSchema, + simChangePlanRequestSchema, simCancelRequestSchema, - simFeaturesUpdateRequestSchema, + simFeaturesRequestSchema, simTopUpHistoryRequestSchema, simChangePlanFullRequestSchema, simCancelFullRequestSchema, @@ -46,17 +48,12 @@ import { type SimCancellationPreview, } from "@customer-portal/domain/sim"; -// Cache-Control header constants -const CACHE_CONTROL_PUBLIC_1H = "public, max-age=3600"; -const CACHE_CONTROL_PRIVATE_5M = "private, max-age=300"; -const CACHE_CONTROL_PRIVATE_1M = "private, max-age=60"; - // DTOs class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {} -class SimTopUpRequestDto extends createZodDto(simTopUpRequestSchema) {} -class SimPlanChangeRequestDto extends createZodDto(simPlanChangeRequestSchema) {} +class SimTopupRequestDto extends createZodDto(simTopupRequestSchema) {} +class SimChangePlanRequestDto extends createZodDto(simChangePlanRequestSchema) {} class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {} -class SimFeaturesUpdateRequestDto extends createZodDto(simFeaturesUpdateRequestSchema) {} +class SimFeaturesRequestDto extends createZodDto(simFeaturesRequestSchema) {} class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {} class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {} class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {} @@ -80,19 +77,19 @@ class SimCancellationPreviewResponseDto extends createZodDto(simCancellationPrev @Controller("subscriptions") export class SimController { - // eslint-disable-next-line max-params -- NestJS dependency injection requires all services to be injected via constructor constructor( - private readonly simOrchestrator: SimOrchestrator, + private readonly simManagementService: SimManagementService, private readonly simTopUpPricingService: SimTopUpPricingService, private readonly simPlanService: SimPlanService, private readonly simCancellationService: SimCancellationService, - private readonly esimManagementService: EsimManagementService + private readonly esimManagementService: EsimManagementService, + private readonly rateLimiter: FreebitRateLimiterService ) {} // ==================== Static SIM Routes (must be before :id routes) ==================== @Get("sim/top-up/pricing") - @Header("Cache-Control", CACHE_CONTROL_PUBLIC_1H) + @Header("Cache-Control", "public, max-age=3600") @ZodResponse({ description: "Get SIM top-up pricing", type: SimTopUpPricingResponseDto }) async getSimTopUpPricing() { const pricing = await this.simTopUpPricingService.getTopUpPricing(); @@ -100,7 +97,7 @@ export class SimController { } @Get("sim/top-up/pricing/preview") - @Header("Cache-Control", CACHE_CONTROL_PUBLIC_1H) + @Header("Cache-Control", "public, max-age=3600") @ZodResponse({ description: "Preview SIM top-up pricing", type: SimTopUpPricingPreviewResponseDto, @@ -113,7 +110,14 @@ export class SimController { @Get("debug/sim-details/:account") @UseGuards(AdminGuard) async debugSimDetails(@Param("account") account: string) { - return await this.simOrchestrator.getSimDetailsDirectly(account); + return await this.simManagementService.getSimDetailsDebug(account); + } + + @Post("debug/sim-rate-limit/clear/:account") + @UseGuards(AdminGuard) + async clearRateLimit(@Param("account") account: string) { + await this.rateLimiter.clearRateLimitForAccount(account); + return { message: `Rate limit cleared for account ${account}` }; } // ==================== Subscription-specific SIM Routes ==================== @@ -124,25 +128,25 @@ export class SimController { @Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto ): Promise> { - return this.simOrchestrator.debugSimSubscription(req.user.id, params.id); + return this.simManagementService.debugSimSubscription(req.user.id, params.id); } @Get(":id/sim") @ZodResponse({ description: "Get SIM info", type: SimInfoDto }) async getSimInfo(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) { - return this.simOrchestrator.getSimInfo(req.user.id, params.id); + return this.simManagementService.getSimInfo(req.user.id, params.id); } @Get(":id/sim/details") @ZodResponse({ description: "Get SIM details", type: SimDetailsDto }) async getSimDetails(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) { - return this.simOrchestrator.getSimDetails(req.user.id, params.id); + return this.simManagementService.getSimDetails(req.user.id, params.id); } @Get(":id/sim/usage") @ZodResponse({ description: "Get SIM usage", type: SimUsageDto }) async getSimUsage(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) { - return this.simOrchestrator.getSimUsage(req.user.id, params.id); + return this.simManagementService.getSimUsage(req.user.id, params.id); } @Get(":id/sim/top-up-history") @@ -152,7 +156,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Query() query: SimTopUpHistoryRequestDto ) { - return this.simOrchestrator.getSimTopUpHistory(req.user.id, params.id, query); + return this.simManagementService.getSimTopUpHistory(req.user.id, params.id, query); } @Post(":id/sim/top-up") @@ -160,9 +164,9 @@ export class SimController { async topUpSim( @Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto, - @Body() body: SimTopUpRequestDto + @Body() body: SimTopupRequestDto ): Promise { - await this.simOrchestrator.topUpSim(req.user.id, params.id, body); + await this.simManagementService.topUpSim(req.user.id, params.id, body); return { message: "SIM top-up completed successfully" }; } @@ -171,9 +175,9 @@ export class SimController { async changeSimPlan( @Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto, - @Body() body: SimPlanChangeRequestDto + @Body() body: SimChangePlanRequestDto ): Promise { - const result = await this.simOrchestrator.changeSimPlan(req.user.id, params.id, body); + const result = await this.simManagementService.changeSimPlan(req.user.id, params.id, body); return { message: "SIM plan change completed successfully", ...result, @@ -187,7 +191,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimCancelRequestDto ): Promise { - await this.simOrchestrator.cancelSim(req.user.id, params.id, body); + await this.simManagementService.cancelSim(req.user.id, params.id, body); return { message: "SIM cancellation completed successfully" }; } @@ -198,11 +202,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimReissueEsimRequestDto ): Promise { - await this.simOrchestrator.reissueEsimProfile( - req.user.id, - params.id, - body.newEid ? { newEid: body.newEid } : {} - ); + await this.simManagementService.reissueEsimProfile(req.user.id, params.id, body.newEid); return { message: "eSIM profile reissue completed successfully" }; } @@ -211,16 +211,16 @@ export class SimController { async updateSimFeatures( @Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto, - @Body() body: SimFeaturesUpdateRequestDto + @Body() body: SimFeaturesRequestDto ): Promise { - await this.simOrchestrator.updateSimFeatures(req.user.id, params.id, body); + await this.simManagementService.updateSimFeatures(req.user.id, params.id, body); return { message: "SIM features updated successfully" }; } // ==================== Enhanced SIM Management Endpoints ==================== @Get(":id/sim/available-plans") - @Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) + @Header("Cache-Control", "private, max-age=300") @ZodResponse({ description: "Get available SIM plans", type: SimAvailablePlansResponseDto }) async getAvailablePlans( @Request() req: RequestWithUser, @@ -245,7 +245,7 @@ export class SimController { } @Get(":id/sim/cancellation-preview") - @Header("Cache-Control", CACHE_CONTROL_PRIVATE_1M) + @Header("Cache-Control", "private, max-age=60") @ZodResponse({ description: "Get SIM cancellation preview", type: SimCancellationPreviewResponseDto, diff --git a/apps/portal/.env.example b/apps/portal/.env.example deleted file mode 100644 index de987a58..00000000 --- a/apps/portal/.env.example +++ /dev/null @@ -1,40 +0,0 @@ -# ============================================================================= -# Customer Portal Frontend - Development Environment -# ============================================================================= -# Copy this file to .env.local in apps/portal/: -# cp .env.example .env.local -# -# Note: NEXT_PUBLIC_* variables are exposed to the browser. -# In development, Next.js rewrites /api/* to the BFF, so you typically -# don't need to set NEXT_PUBLIC_API_BASE. -# ============================================================================= - -# ----------------------------------------------------------------------------- -# Application Identity -# ----------------------------------------------------------------------------- -NEXT_PUBLIC_APP_NAME=Customer Portal (Dev) -NEXT_PUBLIC_APP_VERSION=1.0.0-dev - -# ----------------------------------------------------------------------------- -# API Configuration -# ----------------------------------------------------------------------------- -# In development: Leave empty or unset - Next.js rewrites /api/* to BFF -# In production: Set to /api (nginx proxies to BFF) or full BFF URL -# NEXT_PUBLIC_API_BASE= - -# BFF URL for Next.js rewrites (server-side only, not exposed to browser) -BFF_URL=http://localhost:4000 - -# ----------------------------------------------------------------------------- -# Development Tools -# ----------------------------------------------------------------------------- -NEXT_PUBLIC_ENABLE_DEVTOOLS=true - -# ----------------------------------------------------------------------------- -# Salesforce Embedded Service (Optional - for Agentforce widget) -# ----------------------------------------------------------------------------- -# NEXT_PUBLIC_SF_EMBEDDED_SERVICE_URL= -# NEXT_PUBLIC_SF_ORG_ID= -# NEXT_PUBLIC_SF_EMBEDDED_SERVICE_DEPLOYMENT_ID= -# NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SITE_URL= -# NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SCRT2_URL= diff --git a/apps/portal/public/assets/images/About us.png b/apps/portal/public/assets/images/About us.png new file mode 100644 index 00000000..7efb3367 Binary files /dev/null and b/apps/portal/public/assets/images/About us.png differ diff --git a/apps/portal/public/assets/images/Hero Image.png b/apps/portal/public/assets/images/Hero Image.png new file mode 100644 index 00000000..ae98954d Binary files /dev/null and b/apps/portal/public/assets/images/Hero Image.png differ diff --git a/apps/portal/public/assets/images/Why_us.png b/apps/portal/public/assets/images/Why_us.png new file mode 100644 index 00000000..60a82d57 Binary files /dev/null and b/apps/portal/public/assets/images/Why_us.png differ diff --git a/apps/portal/public/assets/images/acronis-quick-assist.svg b/apps/portal/public/assets/images/acronis-quick-assist.svg new file mode 100644 index 00000000..1c07a324 --- /dev/null +++ b/apps/portal/public/assets/images/acronis-quick-assist.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/portal/public/assets/images/arconis.png b/apps/portal/public/assets/images/arconis.png new file mode 100644 index 00000000..d261179c Binary files /dev/null and b/apps/portal/public/assets/images/arconis.png differ diff --git a/apps/portal/public/assets/images/teamviewer-qs.svg b/apps/portal/public/assets/images/teamviewer-qs.svg new file mode 100644 index 00000000..33e5923d --- /dev/null +++ b/apps/portal/public/assets/images/teamviewer-qs.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/portal/public/assets/images/teamviewer.png b/apps/portal/public/assets/images/teamviewer.png new file mode 100644 index 00000000..bb1abe0b Binary files /dev/null and b/apps/portal/public/assets/images/teamviewer.png differ diff --git a/apps/portal/src/app/(public)/(site)/about/page.tsx b/apps/portal/src/app/(public)/(site)/about/page.tsx index f95bf359..03f2132b 100644 --- a/apps/portal/src/app/(public)/(site)/about/page.tsx +++ b/apps/portal/src/app/(public)/(site)/about/page.tsx @@ -4,8 +4,27 @@ * Corporate profile and company information. */ +import type { Metadata } from "next"; import { AboutUsView } from "@/features/marketing/views/AboutUsView"; +export const metadata: Metadata = { + title: "About Us - 20+ Years Serving Expats in Japan | Assist Solutions", + description: + "Since 2002, Assist Solutions has been the trusted IT partner for expats and international businesses in Japan. Bilingual support, no Japanese required.", + keywords: [ + "Assist Solutions expats", + "IT company foreigners Japan", + "English IT support Tokyo", + "expat services Japan", + ], + openGraph: { + title: "About Assist Solutions - IT for Expats Since 2002", + description: + "20+ years serving Japan's international community. Internet, mobile, VPN, and tech support with full English support.", + type: "website", + }, +}; + export default function AboutPage() { return ; } diff --git a/apps/portal/src/app/(public)/(site)/blog/layout.tsx b/apps/portal/src/app/(public)/(site)/blog/layout.tsx new file mode 100644 index 00000000..4cbb618f --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/blog/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Blog & Updates - Assist Solutions", + description: + "Tips, guides, and news for expats and businesses in Japan. Learn about internet setup, SIM cards, VPN services, and life in Japan.", + keywords: [ + "Japan expat blog", + "internet Japan guide", + "SIM card Japan", + "living in Japan tips", + "Assist Solutions news", + ], + openGraph: { + title: "Blog & Updates - Assist Solutions", + description: "Tips, guides, and news for the international community in Japan.", + type: "website", + }, +}; + +export default function BlogLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/portal/src/app/(public)/(site)/blog/page.tsx b/apps/portal/src/app/(public)/(site)/blog/page.tsx new file mode 100644 index 00000000..4ee6ea1e --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/blog/page.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { cn } from "@/shared/utils"; + +// Sample blog data +const categories = [ + { id: "all", label: "Latest", count: 5 }, + { id: "guides", label: "Guides", count: 2 }, + { id: "tech", label: "Tech Tips", count: 2 }, + { id: "news", label: "News", count: 1 }, +]; + +const authors = { + default: { + name: "Author Name", + role: "Assist Solutions", + avatar: "/assets/images/avatar-placeholder.png", + }, +}; + +const blogPosts = [ + { + id: "1", + slug: "blog-1", + category: "guides", + categoryLabel: "Guides", + title: "Blog 1", + excerpt: + "Sample blog post content. This is a placeholder for future blog content that will be added to the website.", + image: "/assets/images/blog-placeholder-1.jpg", + author: authors.default, + date: "2025-01-10", + featured: true, + }, + { + id: "2", + slug: "blog-2", + category: "tech", + categoryLabel: "Tech Tips", + title: "Blog 2", + excerpt: + "Sample blog post content. This is a placeholder for future blog content that will be added to the website.", + image: "/assets/images/blog-placeholder-2.jpg", + author: authors.default, + date: "2025-01-08", + featured: true, + }, + { + id: "3", + slug: "blog-3", + category: "guides", + categoryLabel: "Guides", + title: "Blog 3", + excerpt: + "Sample blog post content. This is a placeholder for future blog content that will be added to the website.", + image: "/assets/images/blog-placeholder-3.jpg", + author: authors.default, + date: "2025-01-05", + }, + { + id: "4", + slug: "blog-4", + category: "tech", + categoryLabel: "Tech Tips", + title: "Blog 4", + excerpt: + "Sample blog post content. This is a placeholder for future blog content that will be added to the website.", + image: "/assets/images/blog-placeholder-4.jpg", + author: authors.default, + date: "2025-01-03", + }, + { + id: "5", + slug: "blog-5", + category: "news", + categoryLabel: "News", + title: "Blog 5", + excerpt: + "Sample blog post content. This is a placeholder for future blog content that will be added to the website.", + image: "/assets/images/blog-placeholder-5.jpg", + author: authors.default, + date: "2024-12-28", + }, +]; + +export default function BlogPage() { + const [activeCategory, setActiveCategory] = useState("all"); + + const filteredPosts = + activeCategory === "all" + ? blogPosts + : blogPosts.filter(post => post.category === activeCategory); + + return ( +
+
+ {/* Sidebar */} + + + {/* Blog Grid */} +
+
+ {filteredPosts.map(post => ( +
+ + {/* Image */} +
+
+ + {post.title.charAt(0)} + +
+ {/* Hover overlay */} +
+
+ + {/* Category */} + + {post.categoryLabel} + + + {/* Title */} +

+ {post.title} +

+ + {/* Excerpt */} +

+ {post.excerpt} +

+ + {/* Author */} +
+
+ {post.author.name.charAt(0)} +
+
+

{post.author.name}

+

{post.author.role}

+
+
+ +
+ ))} +
+ + {/* Empty state */} + {filteredPosts.length === 0 && ( +
+

No posts found in this category.

+
+ )} +
+
+
+ ); +} diff --git a/apps/portal/src/app/(public)/(site)/contact/page.tsx b/apps/portal/src/app/(public)/(site)/contact/page.tsx index cea3eeee..05dd850e 100644 --- a/apps/portal/src/app/(public)/(site)/contact/page.tsx +++ b/apps/portal/src/app/(public)/(site)/contact/page.tsx @@ -1,11 +1,31 @@ /** - * Public Contact Page + * Public Support & Contact Page * - * Contact form for unauthenticated users. + * Combined FAQ, contact options, and contact form for unauthenticated users. */ +import type { Metadata } from "next"; import { PublicContactView } from "@/features/support/views/PublicContactView"; +export const metadata: Metadata = { + title: "Contact Us - English Support for Expats | Assist Solutions", + description: + "Need help? Our English-speaking team is here for you. Call 0120-660-470 (toll-free in Japan), chat, or email. No Japanese required.", + keywords: [ + "English support Japan", + "IT help expats Tokyo", + "contact Assist Solutions", + "bilingual support Japan", + "English customer service", + ], + openGraph: { + title: "Contact Us - English Support | Assist Solutions", + description: + "Questions about internet, mobile, or IT services? Our English-speaking team is ready to help. Toll-free: 0120-660-470", + type: "website", + }, +}; + export default function ContactPage() { return ; } diff --git a/apps/portal/src/app/(public)/(site)/help/page.tsx b/apps/portal/src/app/(public)/(site)/help/page.tsx index dc45df1d..36d84e3e 100644 --- a/apps/portal/src/app/(public)/(site)/help/page.tsx +++ b/apps/portal/src/app/(public)/(site)/help/page.tsx @@ -1,11 +1,11 @@ /** - * Public Support Page + * Public Help Page * - * FAQ and help center for unauthenticated users. + * Redirects to the combined Support & Contact page. */ -import { PublicSupportView } from "@/features/support/views/PublicSupportView"; +import { redirect } from "next/navigation"; -export default function PublicSupportPage() { - return ; +export default function PublicHelpPage() { + redirect("/contact"); } diff --git a/apps/portal/src/app/(public)/(site)/page.tsx b/apps/portal/src/app/(public)/(site)/page.tsx index bae0be3d..c0764b2e 100644 --- a/apps/portal/src/app/(public)/(site)/page.tsx +++ b/apps/portal/src/app/(public)/(site)/page.tsx @@ -1,5 +1,27 @@ +import type { Metadata } from "next"; import { PublicLandingView } from "@/features/landing-page"; +export const metadata: Metadata = { + title: "Assist Solutions - Internet, Mobile & IT Services for Expats in Japan", + description: + "One stop IT solution for Japan's international community. Reliable fiber internet, mobile SIM cards, VPN, TV services and bilingual tech support since 2002.", + keywords: [ + "internet Japan", + "expat internet Tokyo", + "SIM card Japan", + "English support IT", + "fiber optic Japan", + "VPN Japan", + ], + openGraph: { + title: "Assist Solutions - IT Services for Expats in Japan", + description: + "Reliable internet, mobile, VPN and tech support with English service. Serving Japan's international community since 2002.", + type: "website", + locale: "en_US", + }, +}; + export default function PublicHomePage() { return ; } diff --git a/apps/portal/src/app/(public)/(site)/services/business/page.tsx b/apps/portal/src/app/(public)/(site)/services/business/page.tsx index dbef92a9..6180276e 100644 --- a/apps/portal/src/app/(public)/(site)/services/business/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/business/page.tsx @@ -1,17 +1,37 @@ +import type { Metadata } from "next"; import { Button } from "@/components/atoms"; import { Server, Monitor, Wrench, Globe } from "lucide-react"; +export const metadata: Metadata = { + title: "IT Solutions for International Businesses in Japan | Assist Solutions", + description: + "Enterprise IT for foreign companies in Japan. Dedicated internet, office networks, data center hosting, all with bilingual support. We understand international business needs.", + keywords: [ + "IT for foreign companies Japan", + "international business IT Tokyo", + "bilingual IT support Japan", + "office network setup foreigners", + "enterprise IT English support", + ], + openGraph: { + title: "Business IT for International Companies - Assist Solutions", + description: + "Enterprise IT with bilingual support. Dedicated internet, office networks, and data center services for foreign companies in Japan.", + type: "website", + }, +}; + export default function BusinessSolutionsPage() { return (
{/* Header */}

- Business Solutions + IT for International Businesses

- We provide comprehensive business solutions including DIA (Dedicated Internet Access) with - SLA and bandwidth guarantees to ensure your business stays connected. + Running an international company in Japan? We provide enterprise IT with bilingual + support, so your team can focus on business, not navigating Japanese tech providers.

@@ -23,10 +43,9 @@ export default function BusinessSolutionsPage() {

Office LAN Setup

- Whether you are upgrading your current LAN for greater bandwidth and reliability or - installing a new LAN for a new facility, Assist Solutions will ensure you make informed - decisions. From cable installation and data switches to configuration of routers and - firewalls, we help you determine a cost-effective and reliable way to do this. + Setting up a new office or upgrading your network? We handle everything in English. from + planning to installation. Cable runs, switches, routers, and firewalls configured by + bilingual technicians who understand international business needs.

@@ -37,10 +56,9 @@ export default function BusinessSolutionsPage() {

Onsite & Remote Tech Support

- We provide onsite and remote support to make sure your network is up and running as - quickly as possible. Assist Solutions can help with your IT needs so you can grow your - business with ease and stability. From computer networks to phone and printer - installations, our team will complete your project to your highest satisfaction. + IT issues don't wait, and neither do we. Our English-speaking technicians provide + fast onsite and remote support for your business. Network problems, hardware issues, + software setup. We keep your operations running smoothly.

@@ -53,10 +71,9 @@ export default function BusinessSolutionsPage() { Dedicated Internet Access (DIA)

- Dedicated Internet Access is designed for businesses that need greater Internet capacity - and a dedicated connection between their existing Local Area Network (LAN) and the - public Internet. We are able to provide a bandwidth guarantee with a service level - agreement depending on what is most suitable for your business. + Need guaranteed bandwidth for your business? Our Dedicated Internet Access provides + enterprise-grade connectivity with SLA guarantees. Perfect for companies requiring + reliable, high-capacity connections with English contracts and support.

@@ -67,10 +84,9 @@ export default function BusinessSolutionsPage() {

Data Center Service

- Our Data Center Service provides high-quality data center facilities in Equinix (Tokyo - Tennozu Isle) and GDC (Gotenyama) and many value-added network services to help - establish stable infrastructure platforms. This improves both reliability and efficiency - in your company. + Host your infrastructure in world-class Tokyo data centers (Equinix, GDC Gotenyama). We + provide colocation and managed services with English support, making it easy for + international companies to establish reliable IT infrastructure in Japan.

@@ -78,13 +94,15 @@ export default function BusinessSolutionsPage() { {/* CTA */}

- Interested in our Business Solutions? + Let's Talk About Your IT Needs

- Contact us today to discuss your requirements and how we can help your business grow. + Running an international business in Japan comes with unique challenges. We've been + helping foreign companies navigate Japanese IT for over 20 years. Let's discuss how + we can support your operations.

diff --git a/apps/portal/src/app/(public)/(site)/services/internet/page.tsx b/apps/portal/src/app/(public)/(site)/services/internet/page.tsx index 2e23ef80..75f274bc 100644 --- a/apps/portal/src/app/(public)/(site)/services/internet/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/internet/page.tsx @@ -4,9 +4,29 @@ * Displays internet plans for unauthenticated users. */ +import type { Metadata } from "next"; import { PublicInternetPlansView } from "@/features/services/views/PublicInternetPlans"; import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices"; +export const metadata: Metadata = { + title: "Internet for Expats in Japan - Fiber Optic with English Support | Assist Solutions", + description: + "High-speed NTT fiber internet with full English support. No Japanese required for signup. We handle the paperwork, you enjoy fast internet. Serving expats since 2002.", + keywords: [ + "internet for expats Japan", + "English internet service Tokyo", + "NTT fiber foreigners", + "10Gbps internet expats", + "no Japanese internet signup", + ], + openGraph: { + title: "Internet for Expats - Assist Solutions", + description: + "High-speed fiber internet with English support. No Japanese required. We handle everything.", + type: "website", + }, +}; + export default function PublicInternetPlansPage() { return ( <> diff --git a/apps/portal/src/app/(public)/(site)/services/onsite/OnsiteSupportContent.tsx b/apps/portal/src/app/(public)/(site)/services/onsite/OnsiteSupportContent.tsx new file mode 100644 index 00000000..920c6951 --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/onsite/OnsiteSupportContent.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/atoms"; +import { Users, Monitor, Tv, Headset, ChevronDown } from "lucide-react"; + +export function OnsiteSupportContent() { + return ( +
+ {/* Header */} +
+

+ Tech Help in English, At Your Door +

+

+ Need help with your router, computer, or home network? Our English-speaking technicians + come to your home or office to solve tech problems, explained in a language you + understand. +

+
+ + {/* Main Services */} +
+
+

We Come to You

+

+ Living in Japan without strong Japanese skills can make tech problems frustrating. + That's where we come in. Our English-speaking technicians visit your home or office + to help with setup, troubleshooting, and configuration. +

+

+ For quick fixes, we also offer remote support. We connect to your device securely over + the internet to diagnose and resolve issues without a home visit. +

+
+ +
+
+
+ +
+
+ + {/* Pricing Cards */} +
+ {/* Onsite Network & Computer Support */} +
+
+ +
+

+ Onsite Network & Computer Support +

+
+
Basic Service Fee
+
15,000 JPY
+
+
+ + {/* Remote Support */} +
+
+ +
+

+ Remote Network & Computer Support +

+
+
Basic Service Fee
+
5,000 JPY
+
+
+ + {/* Onsite TV Support */} +
+
+ +
+

Onsite TV Support Service

+
+
Basic Service Fee
+
15,000 JPY
+
+
+
+ + {/* FAQ Section */} +
+

+ Frequently Asked Questions +

+
+ + Yes, the Assist Solutions technical team is able to visit your residence for device + set up including Wi-Fi routers, printers, Apple TVs etc. +
+
+ Our tech consulting team will be able to make suggestions based on your residence + layout and requirements. Please contact us at info@asolutions.co.jp for a free + consultation. + + } + /> + + + Our In-Home Technical Assistance service can be provided in Tokyo, Saitama and + Kanagawa prefecture. +
+
+ *Please note that this service may not available in some areas within the above + prefectures. +
+ For more information, please contact us at info@asolutions.co.jp + + } + /> +
+
+ + {/* CTA */} +
+

+ Tech Problems? We Speak Your Language. +

+

+ Don't struggle with Japanese-only support lines. Get help from technicians who + explain things clearly in English. +

+ +
+
+ ); +} + +/** + * FAQ Item component with expand/collapse functionality + */ +function FaqItem({ question, answer }: { question: string; answer: React.ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + {isOpen && ( +
{answer}
+ )} +
+ ); +} diff --git a/apps/portal/src/app/(public)/(site)/services/onsite/page.tsx b/apps/portal/src/app/(public)/(site)/services/onsite/page.tsx index ded38d14..b3395cce 100644 --- a/apps/portal/src/app/(public)/(site)/services/onsite/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/onsite/page.tsx @@ -1,142 +1,25 @@ -import { Button } from "@/components/atoms"; -import { Users, Monitor, Tv, Headset } from "lucide-react"; +import type { Metadata } from "next"; +import { OnsiteSupportContent } from "./OnsiteSupportContent"; + +export const metadata: Metadata = { + title: "English Tech Support at Home - Tokyo & Surrounding Areas | Assist Solutions", + description: + "Tech help in English at your door. Wi-Fi, computers, networks. Our bilingual technicians solve problems and explain things clearly. Tokyo, Saitama, Kanagawa.", + keywords: [ + "English tech support Tokyo", + "IT support expats Japan", + "bilingual computer help", + "home network setup foreigners", + "English speaking IT Japan", + ], + openGraph: { + title: "English Tech Support at Home - Assist Solutions", + description: + "Bilingual technicians for home and office IT support. We explain things in English.", + type: "website", + }, +}; export default function OnsiteSupportPage() { - return ( -
- {/* Header */} -
-

- Onsite Support -

-

- We dispatch our skillful in-house tech staff to your residence or office for your needs. -

-
- - {/* Main Services */} -
-
-

Need Our Technical Support?

-

- We can provide you with on-site technical support service. If you would like for our - technicians to visit your residence and provide technical assistance, please let us - know. -

-

- We also provide "Remote Access Services" which allows our technicians to do support via - Remote Access Software over the Internet connection to fix up the issue (depends on what - the issue is). -

-
- -
-
-
- -
-
- - {/* Pricing Cards */} -
- {/* Onsite Network & Computer Support */} -
-
- -
-

- Onsite Network & Computer Support -

-
-
Basic Service Fee
-
15,000 JPY
-
-
- - {/* Remote Support */} -
-
- -
-

- Remote Network & Computer Support -

-
-
Basic Service Fee
-
5,000 JPY
-
-
- - {/* Onsite TV Support */} -
-
- -
-

Onsite TV Support Service

-
-
Basic Service Fee
-
15,000 JPY
-
-
-
- - {/* FAQ Section */} -
-

- Frequently Asked Questions -

- -
-
-

- My home requires multiple Wi-Fi routers. Would you be able to assist with this? -

-

- Yes, the Assist Solutions technical team is able to visit your residence for device - set up including Wi-Fi routers, printers, Apple TVs etc. Our tech consulting team will - be able to make suggestions based on your residence layout and requirements. Please - contact us for a free consultation. -

-
- -
-

- I am already subscribed to a different Internet provider but require more Wi-Fi - coverage. Would I be able to just opt for the Onsite Support service without switching - over my entire home Internet service? -

-

- Yes, we are able to offer the Onsite Support service as a standalone service. -

-
- -
-

- Do you offer this service outside of Tokyo? -

-
-

- Our In-Home Technical Assistance service can be provided in Tokyo, Saitama and - Kanagawa prefecture. -

-

- *Please note that this service may not available in some areas within the above - prefectures. For more information, please contact us. -

-
-
-
-
- - {/* CTA */} -
-

Ready to get started?

- -
-
- ); + return ; } diff --git a/apps/portal/src/app/(public)/(site)/services/page.tsx b/apps/portal/src/app/(public)/(site)/services/page.tsx index 9c48566a..75a25c80 100644 --- a/apps/portal/src/app/(public)/(site)/services/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/page.tsx @@ -1,5 +1,44 @@ -import { PublicServicesOverview } from "@/features/services/views/PublicServicesOverview"; +import type { Metadata } from "next"; +import { ServicesGrid } from "@/features/services/components/common/ServicesGrid"; -export default function ServicesPage() { - return ; +export const metadata: Metadata = { + title: "Services for Expats in Japan - Internet, Mobile, VPN & IT Support | Assist Solutions", + description: + "IT services designed for foreigners in Japan. Fiber internet, SIM cards, VPN access, and tech support, all with full English support. No Japanese required.", + keywords: [ + "internet service Japan expats", + "SIM card foreigners Tokyo", + "VPN service Japan", + "IT support expats Japan", + "English internet service Japan", + ], + openGraph: { + title: "Services for Expats - Assist Solutions", + description: + "Internet, mobile, VPN and tech support services with full English support for expats in Japan.", + type: "website", + }, +}; + +interface ServicesPageProps { + basePath?: string; +} + +export default function ServicesPage({ basePath = "/services" }: ServicesPageProps) { + return ( +
+ {/* Header */} +
+

+ Services for Expats in Japan +

+

+ Tired of navigating Japanese-only websites and contracts? We provide internet, mobile, and + IT services with full English support. No Japanese required. +

+
+ + +
+ ); } diff --git a/apps/portal/src/app/(public)/(site)/services/sim/page.tsx b/apps/portal/src/app/(public)/(site)/services/sim/page.tsx index 365bfbbb..f23caa04 100644 --- a/apps/portal/src/app/(public)/(site)/services/sim/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/sim/page.tsx @@ -4,9 +4,29 @@ * Displays SIM plans for unauthenticated users. */ +import type { Metadata } from "next"; import { PublicSimPlansView } from "@/features/services/views/PublicSimPlans"; import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices"; +export const metadata: Metadata = { + title: "SIM Cards for Foreigners in Japan - No Hanko Required | Assist Solutions", + description: + "SIM cards for expats on Japan's best network. Foreign credit cards accepted, no hanko needed, English support. First month free. Get connected in days, not weeks.", + keywords: [ + "SIM card foreigners Japan", + "mobile service expats Tokyo", + "no hanko SIM Japan", + "foreign credit card SIM", + "English SIM service Japan", + ], + openGraph: { + title: "SIM Cards for Foreigners - Assist Solutions", + description: + "Mobile plans for expats. Foreign credit cards accepted, no hanko. English support included.", + type: "website", + }, +}; + export default function PublicSimPlansPage() { return ( <> diff --git a/apps/portal/src/app/(public)/(site)/services/tv/page.tsx b/apps/portal/src/app/(public)/(site)/services/tv/page.tsx deleted file mode 100644 index 25d0aadb..00000000 --- a/apps/portal/src/app/(public)/(site)/services/tv/page.tsx +++ /dev/null @@ -1,816 +0,0 @@ -import { Button } from "@/components/atoms"; -import { - Tv, - Film, - Music, - Trophy, - Newspaper, - Sparkles, - MoreHorizontal, - GraduationCap, - Globe, -} from "lucide-react"; - -// Constants for duplicate strings -const CATEGORY = { - MOVIE: "Movie", - SPORTS: "Sports", - MUSIC: "Music", - KIDS: "Kids", - FOREIGN_DRAMA: "Foreign Drama", - DOCUMENTARY: "Documentary", - NEWS_BUSINESS: "News & Business", - ENTERTAINMENT: "Entertainment", - OTHERS: "Others", -} as const; - -const HOUSING_TYPE = { - APARTMENT: "Apartment Type", - HOME: "Home Type", - STANDARD: "Standard", -} as const; - -const PRICE = { - FREE: "Free", - TAX_NOTE: "*Prices shown above are including tax (10%)", -} as const; - -const PACKAGE_TITLE = { - BASIC: "Basic Package", - BIG: "Big Package", - STANDARD: "Standard Package", -} as const; - -// Frequently used channels -const CHANNEL = { - THE_CINEMA_HD: "The Cinema HD", - MOVIE_PLUS_HD: "Movie Plus HD", - CHANNEL_NECO_HD: "Channel NECO HD", - JAPANESE_MOVIE_HD: "Japanese Movie HD", - FOX_MOVIE_PREMIUM_HD: "Fox Movie Premium HD", - CINEFIL_WOWOW_HD: "Cinefil Wowow HD", - NTV_G_HD: "NTV G HD", - EX_SPORTS: "EX Sports", - DANCE_CHANNEL: "Dance Channel", - GOLF_NETWORK_HD: "Golf Network HD", - GAORA_HD: "GAORA HD", - J_SPORTS_1_HD: "J Sports 1 HD", - J_SPORTS_2_HD: "J Sports 2 HD", - J_SPORTS_3_HD: "J Sports 3 HD", - SKY_A_SPORTS_HD: "Sky A Sports HD", - MUSIC_JAPAN_TV_HD: "Music Japan TV HD", - MTV_HD: "MTV HD", - MUSIC_ON_TV_HD: "Music ON! TV HD", - SPACE_SHOWER_TV_HD: "Space Shower TV HD", - SPACE_SHOWER_TV_PLUS_HD: "Space Shower TV Plus HD", - KAYOU_POPS_HD: "Kayou Pops HD", - MUSIC_AIR_HD: "Music Air HD", - MUSIC_GRAFFITI_TV: "Music Graffiti TV", - CARTOON_NETWORK_HD: "Cartoon Network HD", - DISNEY_CHANNEL_HD: "Disney Channel HD", - DISNEY_XD_HD: "Disney XD HD", - KIDS_STATION_HD: "Kids Station HD", - ANIMAX_HD: "Animax HD", - LALA_HD: "Lala HD", - ASIA_DRAMATIC_TV_HD: "Asia Dramatic TV HD", - KBS_WORLD_HD: "KBS World HD", - SUPER_DRAMA_TV_HD: "Super Drama TV HD", - AXN_MYSTERY_HD: "AXN Mystery HD", - AXN_HD: "AXN HD", - FOX_HD: "FOX HD", - FOX_CLASSICS_HD: "FOX CLASSICS HD", - HISTORY_CHANNEL_HD: "History Channel HD", - NATIONAL_GEOGRAPHIC_HD: "National Geographic HD", - DISCOVERY_HD: "Discovery HD", - NAT_GEO_WILD_HD: "Nat Geo Wild HD", - ANIMAL_PLANET_HD: "Animal Planet HD", - TBS_NEWS_BIRD_HD: "TBS News Bird HD", - NIKKEI_CNBC_HD: "Nikkei CNBC HD", - BBC_WORLD_NEWS_HD: "BBC World News HD", - CNNJ_HD: "CNNj HD", - NTV_NEWS_24: "NTV News 24", - FOX_SPORTS_HD: "Fox Sports HD", - FUJITV_ONE_HD: "FujiTV One HD", - FUJITV_TWO_HD: "FujiTV Two HD", - TBS_CHANNEL_1_HD: "TBS Channel 1 HD", - TBS_CHANNEL_2_HD: "TBS Channel 2 HD", - TV_ASAHI_CHANNEL_1_HD: "TV Asahi Channel 1 HD", - TV_ASAHI_CHANNEL_2_HD: "TV Asahi Channel 2 HD", - FAMILY_THEATER_HD: "Family Theater HD", - HOME_DRAMA_HD: "Home Drama HD", - SAMURAI_DRAMA_HD: "Samurai Drama HD", - TABI_CHANNEL_HD: "TABI Channel HD", - TSURI_VISION_HD: "Tsuri Vision HD", - IGO_SHOGI_CHANNEL_HD: "Igo/Shogi Channel HD", - MONDO_TV_HD: "Mondo TV HD", - DISNEY_JUNIOR: "Disney Junior", - CHANNEL_GINGA: "Channel Ginga", - NTV_PLUS: "NTV Plus", - SPACE_SHOWER_TV: "Space Shower TV", - MUSIC_AIR: "Music Air", - CARTOON_NETWORK: "Cartoon Network", - NATIONAL_GEOGRAPHIC: "National Geographic", - IGO_SHOGI_CHANNEL: "Igo/Shogi Channel", - CHANNEL_GINGA_HD: "Channel Ginga HD", - NHK_WORLD_JAPAN: "NHK World Japan", -} as const; - -// Service data -const SKY_PERFECTV_PREMIUM_HIKARI_DATA = { - title: "Sky PerfecTV Premium Hikari (Optical Fiber TV)", - fees: [ - { type: HOUSING_TYPE.APARTMENT, initial: PRICE.FREE, monthly: "4,567 JPY" }, - { - type: HOUSING_TYPE.HOME, - initial: "14,630 JPY (6,680 JPY if simultaneous installation)", - monthly: "5,392 JPY", - }, - ], - channels: { - movie: [ - CHANNEL.THE_CINEMA_HD, - CHANNEL.MOVIE_PLUS_HD, - CHANNEL.CHANNEL_NECO_HD, - CHANNEL.JAPANESE_MOVIE_HD, - CHANNEL.FOX_MOVIE_PREMIUM_HD, - CHANNEL.CINEFIL_WOWOW_HD, - CHANNEL.NTV_G_HD, - CHANNEL.EX_SPORTS, - CHANNEL.DANCE_CHANNEL, - ], - sports: [ - CHANNEL.GOLF_NETWORK_HD, - CHANNEL.GAORA_HD, - CHANNEL.J_SPORTS_1_HD, - CHANNEL.J_SPORTS_2_HD, - CHANNEL.J_SPORTS_3_HD, - CHANNEL.SKY_A_SPORTS_HD, - ], - music: [ - CHANNEL.MUSIC_JAPAN_TV_HD, - CHANNEL.MTV_HD, - CHANNEL.MUSIC_ON_TV_HD, - CHANNEL.SPACE_SHOWER_TV_HD, - CHANNEL.SPACE_SHOWER_TV_PLUS_HD, - CHANNEL.KAYOU_POPS_HD, - CHANNEL.MUSIC_AIR_HD, - CHANNEL.MUSIC_GRAFFITI_TV, - ], - kids: [ - CHANNEL.CARTOON_NETWORK_HD, - CHANNEL.DISNEY_CHANNEL_HD, - CHANNEL.DISNEY_XD_HD, - CHANNEL.KIDS_STATION_HD, - CHANNEL.ANIMAX_HD, - CHANNEL.LALA_HD, - CHANNEL.ASIA_DRAMATIC_TV_HD, - CHANNEL.KBS_WORLD_HD, - ], - foreignDrama: [ - CHANNEL.SUPER_DRAMA_TV_HD, - CHANNEL.AXN_MYSTERY_HD, - CHANNEL.AXN_HD, - CHANNEL.FOX_HD, - CHANNEL.FOX_CLASSICS_HD, - ], - documentary: [ - CHANNEL.HISTORY_CHANNEL_HD, - CHANNEL.NATIONAL_GEOGRAPHIC_HD, - CHANNEL.DISCOVERY_HD, - CHANNEL.NAT_GEO_WILD_HD, - CHANNEL.ANIMAL_PLANET_HD, - ], - newsBusiness: [ - CHANNEL.TBS_NEWS_BIRD_HD, - CHANNEL.NIKKEI_CNBC_HD, - CHANNEL.BBC_WORLD_NEWS_HD, - CHANNEL.CNNJ_HD, - CHANNEL.NTV_NEWS_24, - "SORA Weather Channel", - "E-tenki.net", - "Entametele HD", - "Nittele Plus HD", - ], - entertainment: [ - CHANNEL.FOX_SPORTS_HD, - CHANNEL.FUJITV_ONE_HD, - CHANNEL.FUJITV_TWO_HD, - CHANNEL.TBS_CHANNEL_1_HD, - CHANNEL.TBS_CHANNEL_2_HD, - CHANNEL.TV_ASAHI_CHANNEL_1_HD, - CHANNEL.TV_ASAHI_CHANNEL_2_HD, - ], - others: [ - CHANNEL.MONDO_TV_HD, - CHANNEL.FAMILY_THEATER_HD, - CHANNEL.HOME_DRAMA_HD, - CHANNEL.SAMURAI_DRAMA_HD, - CHANNEL.TABI_CHANNEL_HD, - CHANNEL.TSURI_VISION_HD, - CHANNEL.IGO_SHOGI_CHANNEL_HD, - ], - }, -}; - -const SKY_PERFECTV_PREMIUM_SATELLITE_DATA = { - title: "Sky PerfecTV Premium (Satellite)", - fees: [{ type: HOUSING_TYPE.STANDARD, initial: PRICE.FREE, monthly: "4,514 JPY" }], - channels: { - movie: [ - "Imagica BS HD", - CHANNEL.THE_CINEMA_HD, - CHANNEL.MOVIE_PLUS_HD, - CHANNEL.CHANNEL_NECO_HD, - CHANNEL.JAPANESE_MOVIE_HD, - CHANNEL.FOX_MOVIE_PREMIUM_HD, - CHANNEL.CINEFIL_WOWOW_HD, - ], - sports: [ - CHANNEL.J_SPORTS_3_HD, - CHANNEL.SKY_A_SPORTS_HD, - CHANNEL.NTV_G_HD, - CHANNEL.EX_SPORTS, - CHANNEL.DANCE_CHANNEL, - ], - music: [ - CHANNEL.MUSIC_JAPAN_TV_HD, - CHANNEL.MTV_HD, - CHANNEL.MUSIC_ON_TV_HD, - CHANNEL.SPACE_SHOWER_TV_HD, - CHANNEL.SPACE_SHOWER_TV_PLUS_HD, - CHANNEL.KAYOU_POPS_HD, - CHANNEL.MUSIC_AIR_HD, - CHANNEL.MUSIC_GRAFFITI_TV, - ], - kids: [ - CHANNEL.CARTOON_NETWORK_HD, - CHANNEL.DISNEY_CHANNEL_HD, - CHANNEL.DISNEY_XD_HD, - CHANNEL.KIDS_STATION_HD, - CHANNEL.ANIMAX_HD, - CHANNEL.DISNEY_JUNIOR, - CHANNEL.ASIA_DRAMATIC_TV_HD, - CHANNEL.KBS_WORLD_HD, - ], - foreignDrama: [ - CHANNEL.SUPER_DRAMA_TV_HD, - CHANNEL.AXN_MYSTERY_HD, - CHANNEL.AXN_HD, - CHANNEL.FOX_HD, - CHANNEL.FOX_CLASSICS_HD, - CHANNEL.LALA_HD, - ], - documentary: [ - CHANNEL.HISTORY_CHANNEL_HD, - CHANNEL.NATIONAL_GEOGRAPHIC_HD, - CHANNEL.DISCOVERY_HD, - CHANNEL.NAT_GEO_WILD_HD, - CHANNEL.ANIMAL_PLANET_HD, - ], - newsBusiness: [ - CHANNEL.TBS_NEWS_BIRD_HD, - CHANNEL.NIKKEI_CNBC_HD, - CHANNEL.BBC_WORLD_NEWS_HD, - CHANNEL.CNNJ_HD, - CHANNEL.NTV_NEWS_24, - "CCTV Daifu", - "SORA Weather Channel", - "Entametele HD", - "NTV Plus HD", - CHANNEL.CHANNEL_GINGA, - ], - entertainment: [ - CHANNEL.FOX_SPORTS_HD, - CHANNEL.FUJITV_ONE_HD, - CHANNEL.FUJITV_TWO_HD, - CHANNEL.TBS_CHANNEL_1_HD, - CHANNEL.TBS_CHANNEL_2_HD, - CHANNEL.TV_ASAHI_CHANNEL_1_HD, - CHANNEL.TV_ASAHI_CHANNEL_2_HD, - "Yose Channel", - ], - others: [ - "IGO & Shogi", - "Mondo 21 HD", - CHANNEL.FAMILY_THEATER_HD, - CHANNEL.HOME_DRAMA_HD, - CHANNEL.SAMURAI_DRAMA_HD, - CHANNEL.TABI_CHANNEL_HD, - "Railyway Channel HD", - ], - }, -}; - -const SKY_PERFECTV_SATELLITE_DATA = { - title: "Sky PerfecTV (Satellite)", - fees: [ - { type: HOUSING_TYPE.APARTMENT, initial: PRICE.FREE, monthly: "4,389 JPY" }, - { - type: HOUSING_TYPE.HOME, - initial: "11,330 JPY (6,680 JPY if simultaneous installation)", - monthly: "5,214 JPY", - }, - ], - channels: { - movie: [ - CHANNEL.MOVIE_PLUS_HD, - "Japanese Movie HD (BS)", - "The Cinema", - "Channel NECO", - CHANNEL.CINEFIL_WOWOW_HD, - ], - sports: ["Nittele G HD", CHANNEL.SKY_A_SPORTS_HD, "GAORA Sports HD", CHANNEL.FOX_SPORTS_HD], - music: [ - CHANNEL.MTV_HD, - CHANNEL.MUSIC_ON_TV_HD, - CHANNEL.SPACE_SHOWER_TV, - CHANNEL.MUSIC_AIR, - "Space Shower TV Plus", - "Kayo Pops", - ], - kids: [ - CHANNEL.KIDS_STATION_HD, - "BS Animax HD (BS)", - "Disney (BS)", - CHANNEL.CARTOON_NETWORK, - CHANNEL.DISNEY_JUNIOR, - ], - foreignDrama: [CHANNEL.LALA_HD, CHANNEL.SUPER_DRAMA_TV_HD, "AXN", "AXN Mystery", "FOX"], - documentary: ["History Channel", CHANNEL.NATIONAL_GEOGRAPHIC, "Discovery", "Animal Planet"], - newsBusiness: [ - "TBS News Bird", - "BBC World News", - "CNNj", - CHANNEL.NTV_NEWS_24, - CHANNEL.TBS_CHANNEL_1_HD, - "TBS Channel 2", - CHANNEL.NTV_PLUS, - CHANNEL.CHANNEL_GINGA, - ], - entertainment: [ - "Fuji TV One HD", - "Fuji TV Two HD", - CHANNEL.TV_ASAHI_CHANNEL_1_HD, - CHANNEL.TV_ASAHI_CHANNEL_2_HD, - CHANNEL.IGO_SHOGI_CHANNEL, - CHANNEL.MONDO_TV_HD, - ], - others: [ - "Tsuri Vision HD (BS)", - "Family Gekijo HD", - CHANNEL.SAMURAI_DRAMA_HD, - "Home Drama Channel", - ], - }, -}; - -const ITSCOM_DATA = { - title: "iTSCOM (CATV)", - fees: [{ type: "Big Package", initial: "11,000 JPY", monthly: "5,280 JPY" }], - channels: { - movie: [ - CHANNEL.MOVIE_PLUS_HD, - CHANNEL.JAPANESE_MOVIE_HD, - CHANNEL.CHANNEL_NECO_HD, - "FOX Movie Premium HD", - ], - sports: [ - CHANNEL.GOLF_NETWORK_HD, - "Nittele G HD", - CHANNEL.SKY_A_SPORTS_HD, - CHANNEL.GAORA_HD, - CHANNEL.J_SPORTS_1_HD, - CHANNEL.J_SPORTS_2_HD, - CHANNEL.J_SPORTS_3_HD, - ], - music: [CHANNEL.MTV_HD, CHANNEL.MUSIC_ON_TV_HD, CHANNEL.SPACE_SHOWER_TV, CHANNEL.MUSIC_AIR], - kids: [ - CHANNEL.ANIMAX_HD, - CHANNEL.KIDS_STATION_HD, - "Disney XD", - CHANNEL.CARTOON_NETWORK, - "Baby TV", - CHANNEL.DISNEY_JUNIOR, - ], - foreignDrama: [ - CHANNEL.AXN_HD, - CHANNEL.FOX_HD, - CHANNEL.ASIA_DRAMATIC_TV_HD, - CHANNEL.SUPER_DRAMA_TV_HD, - CHANNEL.AXN_MYSTERY_HD, - CHANNEL.FAMILY_THEATER_HD, - ], - documentary: [ - CHANNEL.DISCOVERY_HD, - CHANNEL.NATIONAL_GEOGRAPHIC, - CHANNEL.ANIMAL_PLANET_HD, - CHANNEL.HISTORY_CHANNEL_HD, - ], - newsBusiness: [ - CHANNEL.BBC_WORLD_NEWS_HD, - CHANNEL.CNNJ_HD, - CHANNEL.NIKKEI_CNBC_HD, - CHANNEL.TBS_NEWS_BIRD_HD, - "TV Asahi Channel 2", - CHANNEL.NTV_NEWS_24, - CHANNEL.NHK_WORLD_JAPAN, - CHANNEL.DISNEY_CHANNEL_HD, - ], - entertainment: [ - "LaLa TV HD", - CHANNEL.CHANNEL_GINGA_HD, - "Dlife HD", - "FOX Sports HD", - CHANNEL.TBS_CHANNEL_1_HD, - "TBS Channel 2", - CHANNEL.NTV_PLUS, - ], - others: [ - "Japanet Channel DX HD", - "Jewelly Gem Shopping HD", - CHANNEL.SAMURAI_DRAMA_HD, - "TABI Channel", - "QVC", - "Shop Channel", - CHANNEL.IGO_SHOGI_CHANNEL, - ], - }, -}; - -const JCOM_DATA = { - title: "JCOM (CATV)", - fees: [{ type: "Standard Package", initial: "9,900 JPY", monthly: "6,074 JPY" }], - channels: { - movie: [ - CHANNEL.MOVIE_PLUS_HD, - CHANNEL.THE_CINEMA_HD, - CHANNEL.CHANNEL_NECO_HD, - CHANNEL.JAPANESE_MOVIE_HD, - "Fox Movies", - CHANNEL.CINEFIL_WOWOW_HD, - "Fox Sports & Entertainment", - ], - sports: [ - CHANNEL.J_SPORTS_1_HD, - CHANNEL.J_SPORTS_2_HD, - CHANNEL.J_SPORTS_3_HD, - CHANNEL.SKY_A_SPORTS_HD, - CHANNEL.GOLF_NETWORK_HD, - CHANNEL.GAORA_HD, - ], - music: [CHANNEL.MTV_HD, CHANNEL.MUSIC_ON_TV_HD, CHANNEL.SPACE_SHOWER_TV, "Kayou Pops"], - kids: [ - CHANNEL.ANIMAX_HD, - CHANNEL.KIDS_STATION_HD, - CHANNEL.CARTOON_NETWORK_HD, - "Disney CHannel HD", - CHANNEL.DISNEY_XD_HD, - "Disney Junior HD", - CHANNEL.AXN_MYSTERY_HD, - ], - foreignDrama: [ - CHANNEL.SUPER_DRAMA_TV_HD, - CHANNEL.FOX_HD, - "FOX Classics HD", - CHANNEL.AXN_HD, - CHANNEL.LALA_HD, - CHANNEL.KBS_WORLD_HD, - ], - documentary: [ - "Histroy Channel HD", - CHANNEL.NATIONAL_GEOGRAPHIC_HD, - "Discovery Channel HD", - CHANNEL.ANIMAL_PLANET_HD, - ], - newsBusiness: [ - CHANNEL.TBS_NEWS_BIRD_HD, - CHANNEL.NIKKEI_CNBC_HD, - "TV Asahi Channel 2 HD", - "CNNj", - CHANNEL.NTV_NEWS_24, - CHANNEL.NHK_WORLD_JAPAN, - "KBS World", - CHANNEL.NTV_PLUS, - "Act On TV", - ], - entertainment: [ - CHANNEL.FUJITV_ONE_HD, - CHANNEL.FUJITV_TWO_HD, - CHANNEL.TBS_CHANNEL_1_HD, - CHANNEL.TBS_CHANNEL_2_HD, - CHANNEL.CHANNEL_GINGA_HD, - "TV Asahi Channel 1", - "Housou University TV", - ], - others: [ - CHANNEL.FAMILY_THEATER_HD, - CHANNEL.SAMURAI_DRAMA_HD, - "Home Drama Channel HD", - CHANNEL.MONDO_TV_HD, - CHANNEL.TSURI_VISION_HD, - CHANNEL.IGO_SHOGI_CHANNEL, - ], - }, -}; - -// CSS class constants -const STYLES = { - cardBorder: "border border-border/60", - cardRounded: "rounded-2xl", - sectionSpacing: "mb-16", - textForeground: "text-foreground", - textMuted: "text-muted-foreground", - leadingRelaxed: "leading-relaxed", -} as const; - -// Types -interface Fee { - type: string; - initial: string; - monthly: string; -} - -interface ChannelData { - movie: string[]; - sports: string[]; - music: string[]; - kids: string[]; - foreignDrama: string[]; - documentary: string[]; - newsBusiness: string[]; - entertainment: string[]; - others: string[]; -} - -interface TVServiceData { - title: string; - fees: Fee[]; - channels: ChannelData; -} - -// Sub-components -function PageHeader(): React.ReactElement { - return ( -
-

- TV Services -

-

- Providing a variety of options for our customers such as Satellite TV, Cable TV and Optical - Fiber TV. -

-
- ); -} - -function ServiceIntro(): React.ReactElement { - return ( -
-

Service Lineup

-

- We are proud to act as agents for Japan's major paid TV service providers, and we will - arrange your services on your behalf (no service fee required for us to arrange your - services). Usually each building has their pre-assigned main TV service providers. To find - out which TV service you can apply for, please feel free to contact us anytime. -

- -
- ); -} - -function FAQSection(): React.ReactElement { - return ( -
-

- Frequently Asked Questions -

-
-
-

- Is Assist Solutions directly providing the TV service? -

-

- As partners, we are able to refer you to each cable TV company available at your home. - However, once the service starts, the cable TV service itself will be directly provided - by each cable TV company. -

-
- -
-

- Would I be able to choose any cable TV service that Assist Solutions is partnered with? -

-

- In Japan, most cable TV companies have predetermined service areas. We will be able to - check which services are available for your home. Please contact us for a free - consultation. -

-
-
-
- ); -} - -function CTASection(): React.ReactElement { - return ( -
-

Find the best TV service for you

- -
- ); -} - -function TVServiceSection({ - title, - fees, - note, - children, -}: { - title: string; - fees: Fee[]; - note?: string; - children?: React.ReactNode; -}): React.ReactElement { - return ( -
-
-
-
- -
-

{title}

-
-
- -
-
-

- Service Fees -

-
- - - - - - - - - - {fees.map((fee, i) => ( - - - - - - ))} - -
TypeInitial CostMonthly Cost
{fee.type}{fee.initial}{fee.monthly}
-
- {note &&

{note}

} -
- - {children} -
-
- ); -} - -function ChannelPackage({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}): React.ReactElement { - return ( -
-

- {title} -

-
{children}
-
- ); -} - -function getCategoryIcon(title: string): React.ReactElement { - const iconClass = "h-4 w-4 text-primary"; - - switch (title) { - case CATEGORY.MOVIE: - return ; - case CATEGORY.MUSIC: - return ; - case CATEGORY.SPORTS: - return ; - case CATEGORY.NEWS_BUSINESS: - return ; - case CATEGORY.ENTERTAINMENT: - case CATEGORY.KIDS: - return ; - case CATEGORY.FOREIGN_DRAMA: - return ; - case CATEGORY.DOCUMENTARY: - return ; - case CATEGORY.OTHERS: - default: - return ; - } -} - -function ChannelCategory({ - title, - channels, -}: { - title: string; - channels: string[]; -}): React.ReactElement { - return ( -
-

- {getCategoryIcon(title)} - {title} -

-
    - {channels.map(channel => ( -
  • - {channel} -
  • - ))} -
-
- ); -} - -function ServiceChannelPackage({ - packageTitle, - channels, -}: { - packageTitle: string; - channels: ChannelData; -}): React.ReactElement { - return ( - - - - - - - - - - - - ); -} - -function TVServiceWithChannels({ - data, - packageTitle, -}: { - data: TVServiceData; - packageTitle: string; -}): React.ReactElement { - return ( - - - - ); -} - -function ServicesList(): React.ReactElement { - return ( -
- - - - - -
- ); -} - -export default function TVServicesPage(): React.ReactElement { - return ( -
- - - - - -
- ); -} diff --git a/apps/portal/src/app/(public)/(site)/services/vpn/page.tsx b/apps/portal/src/app/(public)/(site)/services/vpn/page.tsx index abb231d5..cea0703d 100644 --- a/apps/portal/src/app/(public)/(site)/services/vpn/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/vpn/page.tsx @@ -4,9 +4,28 @@ * Displays VPN plans for unauthenticated users. */ +import type { Metadata } from "next"; import { PublicVpnPlansView } from "@/features/services/views/PublicVpnPlans"; import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices"; +export const metadata: Metadata = { + title: "VPN for Expats - Stream US/UK Content in Japan | Assist Solutions", + description: + "Watch your favorite shows from home while living in Japan. Pre-configured VPN router, just plug in and stream. US and UK servers available.", + keywords: [ + "VPN expats Japan", + "stream US content Japan", + "Netflix VPN Tokyo", + "UK streaming Japan", + ], + openGraph: { + title: "VPN for Expats in Japan - Assist Solutions", + description: + "Stream your favorite content from home. Pre-configured router, just plug in. US and UK servers.", + type: "website", + }, +}; + export default function PublicVpnPlansPage() { return ( <> diff --git a/apps/portal/src/app/layout.tsx b/apps/portal/src/app/layout.tsx index 5d05d8b9..4762b115 100644 --- a/apps/portal/src/app/layout.tsx +++ b/apps/portal/src/app/layout.tsx @@ -1,22 +1,69 @@ import type { Metadata } from "next"; import { headers } from "next/headers"; -import { Sora } from "next/font/google"; -import { GeistSans } from "geist/font/sans"; import "./globals.css"; import { QueryProvider } from "@/core/providers"; import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning"; -// Display font for headlines and hero text -const sora = Sora({ - subsets: ["latin"], - variable: "--font-display", - display: "swap", - weight: ["500", "600", "700", "800"], -}); - export const metadata: Metadata = { - title: "Assist Solutions Portal", - description: "Manage your subscriptions, billing, and support with Assist Solutions", + title: { + default: "Assist Solutions - IT Services for Expats in Japan", + template: "%s | Assist Solutions", + }, + description: + "One stop IT solution for Japan's international community. Internet, mobile, VPN, and tech support with English service since 2002.", + metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://portal.asolutions.co.jp"), + alternates: { + canonical: "/", + }, + openGraph: { + type: "website", + locale: "en_US", + siteName: "Assist Solutions", + }, + twitter: { + card: "summary_large_image", + }, + robots: { + index: true, + follow: true, + }, +}; + +// Organization structured data for rich search results +const organizationJsonLd = { + "@context": "https://schema.org", + "@type": "Organization", + name: "Assist Solutions Corp.", + alternateName: "Assist Solutions", + url: "https://asolutions.co.jp", + logo: "https://portal.asolutions.co.jp/assets/images/logo.png", + foundingDate: "2002-03-08", + description: + "IT and telecom services for Japan's international community with bilingual English/Japanese support.", + address: { + "@type": "PostalAddress", + streetAddress: "3F Azabu Maruka Bldg., 3-8-2 Higashi Azabu", + addressLocality: "Minato-ku", + addressRegion: "Tokyo", + postalCode: "106-0044", + addressCountry: "JP", + }, + contactPoint: [ + { + "@type": "ContactPoint", + telephone: "+81-3-3560-1006", + contactType: "customer service", + availableLanguage: ["English", "Japanese"], + }, + { + "@type": "ContactPoint", + telephone: "0120-660-470", + contactType: "customer service", + areaServed: "JP", + availableLanguage: ["English", "Japanese"], + }, + ], + sameAs: ["https://www.asolutions.co.jp"], }; // Disable static generation for the entire app since it uses dynamic features extensively @@ -34,7 +81,13 @@ export default async function RootLayout({ return ( - + +