diff --git a/ASI_N6_PASI_20251229.csv b/ASI_N6_PASI_20251229.csv deleted file mode 100644 index 1ed34348..00000000 --- a/ASI_N6_PASI_20251229.csv +++ /dev/null @@ -1,50 +0,0 @@ -1,02000002470001,PT0220024700010,PASI,2025-12-29,,,, -2,02000002470002,PT0220024700020,PASI,2025-12-29,,,, -3,02000002470003,PT0220024700030,PASI,2025-12-29,,,, -4,02000002470004,PT0220024700040,PASI,2025-12-29,,,, -5,02000002470005,PT0220024700050,PASI,2025-12-29,,,, -6,02000002470006,PT0220024700060,PASI,2025-12-29,,,, -7,02000002470007,PT0220024700070,PASI,2025-12-29,,,, -8,02000002470008,PT0220024700080,PASI,2025-12-29,,,, -9,02000002470009,PT0220024700090,PASI,2025-12-29,,,, -10,02000002470010,PT0220024700100,PASI,2025-12-29,,,, -11,02000002470011,PT0220024700110,PASI,2025-12-29,,,, -12,02000002470012,PT0220024700120,PASI,2025-12-29,,,, -13,02000002470013,PT0220024700130,PASI,2025-12-29,,,, -14,02000002470014,PT0220024700140,PASI,2025-12-29,,,, -15,02000002470015,PT0220024700150,PASI,2025-12-29,,,, -16,02000002470016,PT0220024700160,PASI,2025-12-29,,,, -17,02000002470017,PT0220024700170,PASI,2025-12-29,,,, -18,02000002470018,PT0220024700180,PASI,2025-12-29,,,, -19,02000002470019,PT0220024700190,PASI,2025-12-29,,,, -20,02000002470020,PT0220024700200,PASI,2025-12-29,,,, -21,02000002470021,PT0220024700210,PASI,2025-12-29,,,, -22,02000002470022,PT0220024700220,PASI,2025-12-29,,,, -23,02000002470023,PT0220024700230,PASI,2025-12-29,,,, -24,02000002470024,PT0220024700240,PASI,2025-12-29,,,, -25,02000002470025,PT0220024700250,PASI,2025-12-29,,,, -26,02000002470026,PT0220024700260,PASI,2025-12-29,,,, -27,02000002470027,PT0220024700270,PASI,2025-12-29,,,, -28,02000002470028,PT0220024700280,PASI,2025-12-29,,,, -29,02000002470029,PT0220024700290,PASI,2025-12-29,,,, -30,02000002470030,PT0220024700300,PASI,2025-12-29,,,, -31,02000002470031,PT0220024700310,PASI,2025-12-29,,,, -32,02000002470032,PT0220024700320,PASI,2025-12-29,,,, -33,02000002470033,PT0220024700330,PASI,2025-12-29,,,, -34,02000002470034,PT0220024700340,PASI,2025-12-29,,,, -35,02000002470035,PT0220024700350,PASI,2025-12-29,,,, -36,02000002470036,PT0220024700360,PASI,2025-12-29,,,, -37,02000002470037,PT0220024700370,PASI,2025-12-29,,,, -38,02000002470038,PT0220024700380,PASI,2025-12-29,,,, -39,02000002470039,PT0220024700390,PASI,2025-12-29,,,, -40,02000002470040,PT0220024700400,PASI,2025-12-29,,,, -41,02000002470041,PT0220024700410,PASI,2025-12-29,,,, -42,02000002470042,PT0220024700420,PASI,2025-12-29,,,, -43,02000002470043,PT0220024700430,PASI,2025-12-29,,,, -44,02000002470044,PT0220024700440,PASI,2025-12-29,,,, -45,02000002470045,PT0220024700450,PASI,2025-12-29,,,, -46,02000002470046,PT0220024700460,PASI,2025-12-29,,,, -47,02000002470047,PT0220024700470,PASI,2025-12-29,,,, -48,02000002470048,PT0220024700480,PASI,2025-12-29,,,, -49,02000002470049,PT0220024700490,PASI,2025-12-29,,,, -50,02000002470050,PT0220024700500,PASI,2025-12-29,,,, diff --git a/ASI_N7_PASI_20251229.csv b/ASI_N7_PASI_20251229.csv deleted file mode 100644 index 2357f15f..00000000 --- a/ASI_N7_PASI_20251229.csv +++ /dev/null @@ -1,50 +0,0 @@ -51,07000240001,PT0270002400010,PASI,20251229,,,, -52,07000240002,PT0270002400020,PASI,20251229,,,, -53,07000240003,PT0270002400030,PASI,20251229,,,, -54,07000240004,PT0270002400040,PASI,20251229,,,, -55,07000240005,PT0270002400050,PASI,20251229,,,, -56,07000240006,PT0270002400060,PASI,20251229,,,, -57,07000240007,PT0270002400070,PASI,20251229,,,, -58,07000240008,PT0270002400080,PASI,20251229,,,, -59,07000240009,PT0270002400090,PASI,20251229,,,, -60,07000240010,PT0270002400100,PASI,20251229,,,, -61,07000240011,PT0270002400110,PASI,20251229,,,, -62,07000240012,PT0270002400120,PASI,20251229,,,, -63,07000240013,PT0270002400130,PASI,20251229,,,, -64,07000240014,PT0270002400140,PASI,20251229,,,, -65,07000240015,PT0270002400150,PASI,20251229,,,, -66,07000240016,PT0270002400160,PASI,20251229,,,, -67,07000240017,PT0270002400170,PASI,20251229,,,, -68,07000240018,PT0270002400180,PASI,20251229,,,, -69,07000240019,PT0270002400190,PASI,20251229,,,, -70,07000240020,PT0270002400200,PASI,20251229,,,, -71,07000240021,PT0270002400210,PASI,20251229,,,, -72,07000240022,PT0270002400220,PASI,20251229,,,, -73,07000240023,PT0270002400230,PASI,20251229,,,, -74,07000240024,PT0270002400240,PASI,20251229,,,, -75,07000240025,PT0270002400250,PASI,20251229,,,, -76,07000240026,PT0270002400260,PASI,20251229,,,, -77,07000240027,PT0270002400270,PASI,20251229,,,, -78,07000240028,PT0270002400280,PASI,20251229,,,, -79,07000240029,PT0270002400290,PASI,20251229,,,, -80,07000240030,PT0270002400300,PASI,20251229,,,, -81,07000240031,PT0270002400310,PASI,20251229,,,, -82,07000240032,PT0270002400320,PASI,20251229,,,, -83,07000240033,PT0270002400330,PASI,20251229,,,, -84,07000240034,PT0270002400340,PASI,20251229,,,, -85,07000240035,PT0270002400350,PASI,20251229,,,, -86,07000240036,PT0270002400360,PASI,20251229,,,, -87,07000240037,PT0270002400370,PASI,20251229,,,, -88,07000240038,PT0270002400380,PASI,20251229,,,, -89,07000240039,PT0270002400390,PASI,20251229,,,, -90,07000240040,PT0270002400400,PASI,20251229,,,, -91,07000240041,PT0270002400410,PASI,20251229,,,, -92,07000240042,PT0270002400420,PASI,20251229,,,, -93,07000240043,PT0270002400430,PASI,20251229,,,, -94,07000240044,PT0270002400440,PASI,20251229,,,, -95,07000240045,PT0270002400450,PASI,20251229,,,, -96,07000240046,PT0270002400460,PASI,20251229,,,, -97,07000240047,PT0270002400470,PASI,20251229,,,, -98,07000240048,PT0270002400480,PASI,20251229,,,, -99,07000240049,PT0270002400490,PASI,20251229,,,, -100,07000240050,PT0270002400500,PASI,20251229,,,, diff --git a/ASI_N7_PASI_20251229_salesforce.csv b/ASI_N7_PASI_20251229_salesforce.csv deleted file mode 100644 index 72ce1e69..00000000 --- a/ASI_N7_PASI_20251229_salesforce.csv +++ /dev/null @@ -1,50 +0,0 @@ -51,7000240001,PT0270002400010,PASI,12/29/2025 -52,7000240002,PT0270002400020,PASI,12/29/2025 -53,7000240003,PT0270002400030,PASI,12/29/2025 -54,7000240004,PT0270002400040,PASI,12/29/2025 -55,7000240005,PT0270002400050,PASI,12/29/2025 -56,7000240006,PT0270002400060,PASI,12/29/2025 -57,7000240007,PT0270002400070,PASI,12/29/2025 -58,7000240008,PT0270002400080,PASI,12/29/2025 -59,7000240009,PT0270002400090,PASI,12/29/2025 -60,7000240010,PT0270002400100,PASI,12/29/2025 -61,7000240011,PT0270002400110,PASI,12/29/2025 -62,7000240012,PT0270002400120,PASI,12/29/2025 -63,7000240013,PT0270002400130,PASI,12/29/2025 -64,7000240014,PT0270002400140,PASI,12/29/2025 -65,7000240015,PT0270002400150,PASI,12/29/2025 -66,7000240016,PT0270002400160,PASI,12/29/2025 -67,7000240017,PT0270002400170,PASI,12/29/2025 -68,7000240018,PT0270002400180,PASI,12/29/2025 -69,7000240019,PT0270002400190,PASI,12/29/2025 -70,7000240020,PT0270002400200,PASI,12/29/2025 -71,7000240021,PT0270002400210,PASI,12/29/2025 -72,7000240022,PT0270002400220,PASI,12/29/2025 -73,7000240023,PT0270002400230,PASI,12/29/2025 -74,7000240024,PT0270002400240,PASI,12/29/2025 -75,7000240025,PT0270002400250,PASI,12/29/2025 -76,7000240026,PT0270002400260,PASI,12/29/2025 -77,7000240027,PT0270002400270,PASI,12/29/2025 -78,7000240028,PT0270002400280,PASI,12/29/2025 -79,7000240029,PT0270002400290,PASI,12/29/2025 -80,7000240030,PT0270002400300,PASI,12/29/2025 -81,7000240031,PT0270002400310,PASI,12/29/2025 -82,7000240032,PT0270002400320,PASI,12/29/2025 -83,7000240033,PT0270002400330,PASI,12/29/2025 -84,7000240034,PT0270002400340,PASI,12/29/2025 -85,7000240035,PT0270002400350,PASI,12/29/2025 -86,7000240036,PT0270002400360,PASI,12/29/2025 -87,7000240037,PT0270002400370,PASI,12/29/2025 -88,7000240038,PT0270002400380,PASI,12/29/2025 -89,7000240039,PT0270002400390,PASI,12/29/2025 -90,7000240040,PT0270002400400,PASI,12/29/2025 -91,7000240041,PT0270002400410,PASI,12/29/2025 -92,7000240042,PT0270002400420,PASI,12/29/2025 -93,7000240043,PT0270002400430,PASI,12/29/2025 -94,7000240044,PT0270002400440,PASI,12/29/2025 -95,7000240045,PT0270002400450,PASI,12/29/2025 -96,7000240046,PT0270002400460,PASI,12/29/2025 -97,7000240047,PT0270002400470,PASI,12/29/2025 -98,7000240048,PT0270002400480,PASI,12/29/2025 -99,7000240049,PT0270002400490,PASI,12/29/2025 -100,7000240050,PT0270002400500,PASI,12/29/2025 diff --git a/Untitled b/Untitled deleted file mode 100644 index a85e8c45..00000000 --- a/Untitled +++ /dev/null @@ -1 +0,0 @@ -(半黒音声) \ No newline at end of file diff --git a/apps/bff/sim-api-test-log.csv b/apps/bff/sim-api-test-log.csv index 060f78b0..dd758a43 100644 --- a/apps/bff/sim-api-test-log.csv +++ b/apps/bff/sim-api-test-log.csv @@ -1,20 +1,86 @@ Timestamp,API Endpoint,API Method,Phone Number,SIM Identifier,Request Payload,Response Status,Error,Additional Info -2026-01-19T04:05:41.856Z,/mvno/getTrafficInfo/,POST,02000524104652,02000524104652,"{""account"":""02000524104652""}",Success,,OK -2026-01-19T04:05:41.945Z,/mvno/getTrafficInfo/,POST,02000524104652,02000524104652,"{""account"":""02000524104652""}",Success,,OK -2026-01-20T08:29:56.809Z,/mvno/getTrafficInfo/,POST,02000524104652,02000524104652,"{""account"":""02000524104652""}",Success,,OK -2026-01-20T08:29:56.956Z,/mvno/getTrafficInfo/,POST,02000524104652,02000524104652,"{""account"":""02000524104652""}",Success,,OK -2026-01-29T05:54:55.030Z,/mvno/getTrafficInfo/,POST,07000240050,07000240050,"{""account"":""07000240050""}",Error: 210,API Error: NG,API Error: NG -2026-01-29T05:54:59.051Z,/mvno/getTrafficInfo/,POST,07000240050,07000240050,"{""account"":""07000240050""}",Error: 210,API Error: NG,API Error: NG -2026-01-29T05:55:03.587Z,/mvno/getTrafficInfo/,POST,07000240050,07000240050,"{""account"":""07000240050""}",Error: 210,API Error: NG,API Error: NG -2026-01-30T05:00:27.476Z,/mvno/getTrafficInfo/,POST,07000240050,07000240050,"{""account"":""07000240050""}",Error: 210,API Error: NG,API Error: NG -2026-01-30T05:00:30.722Z,/mvno/getTrafficInfo/,POST,07000240050,07000240050,"{""account"":""07000240050""}",Error: 210,API Error: NG,API Error: NG -2026-01-30T05:00:41.667Z,/mvno/getTrafficInfo/,POST,2877252932,2877252932,"{""account"":""2877252932""}",Error: 210,API Error: NG,API Error: NG -2026-01-30T05:00:41.940Z,/mvno/getTrafficInfo/,POST,2877252932,2877252932,"{""account"":""2877252932""}",Error: 210,API Error: NG,API Error: NG -2026-01-30T05:00:43.417Z,/mvno/getTrafficInfo/,POST,2877252932,2877252932,"{""account"":""2877252932""}",Error: 210,API Error: NG,API Error: NG -2026-01-30T05:00:46.655Z,/mvno/getTrafficInfo/,POST,2877252932,2877252932,"{""account"":""2877252932""}",Error: 210,API Error: NG,API Error: NG -2026-01-30T06:41:21.669Z,/mvno/ota/addAcnt/,POST,07000240050,07000240050,"{""authKey"":""[REDACTED]"",""aladinOperated"":""10"",""createType"":""new"",""account"":""07000240050"",""productNumber"":""PT0270002400500"",""planCode"":""PASI_5G"",""contractLine"":""5G"",""size"":""nano"",""addKind"":""N"",""deliveryCode"":""PASI""}",Error: 204,API Error: Bad Request,API Error: Bad Request -2026-01-30T06:44:46.220Z,/mvno/ota/addAcnt/,POST,07000240050,07000240050,"{""authKey"":""[REDACTED]"",""aladinOperated"":""10"",""createType"":""new"",""account"":""07000240050"",""productNumber"":""PT0270002400500"",""planCode"":""PASI_5G"",""contractLine"":""5G"",""size"":""nano"",""addKind"":""N"",""deliveryCode"":""PASI""}",Error: 204,API Error: Bad Request,API Error: Bad Request -2026-01-30T06:54:15.819Z,/mvno/ota/addAcnt/,POST,07000240050,07000240050,"{""authKey"":""[REDACTED]"",""aladinOperated"":""10"",""createType"":""new"",""account"":""07000240050"",""productNumber"":""PT0270002400500"",""simkind"":""3MS"",""planCode"":""PASI_5G"",""contractLine"":""5G"",""size"":""nano"",""addKind"":""N"",""shipDate"":""20260130"",""deliveryCode"":""PASI""}",Error: 204,API Error: Bad Request,API Error: Bad Request -2026-01-30T07:00:25.099Z,/mvno/ota/addAcnt/,POST,07000240050,07000240050,"{""authKey"":""[REDACTED]"",""aladinOperated"":""10"",""createType"":""new"",""account"":""07000240050"",""productNumber"":""PT0270002400500"",""size"":""nano"",""planCode"":""PASI_5G"",""deliveryCode"":""PASI"",""addKind"":""N"",""shipDate"":""20260130""}",Error: 204,API Error: Bad Request,API Error: Bad Request -2026-01-30T07:05:10.543Z,/mvno/ota/addAcnt/,POST,07000240050,07000240050,"{""authKey"":""[REDACTED]"",""aladinOperated"":""10"",""createType"":""new"",""account"":""07000240050"",""productNumber"":""0270002400500"",""size"":""nano"",""planCode"":""PASI_5G"",""deliveryCode"":""PASI"",""addKind"":""N"",""shipDate"":""20260130""}",Error: 204,API Error: Bad Request,API Error: Bad Request -2026-01-30T07:08:08.685Z,/mvno/ota/addAcnt/,POST,07000240050,07000240050,"{""aladinOperated"":""10"",""createType"":""new"",""account"":""07000240050"",""productNumber"":""0270002400500"",""size"":""nano"",""planCode"":""PASI_5G"",""deliveryCode"":""PASI"",""addKind"":""N"",""shipDate"":""20260130""}",Error: 201,API Error: Bad Request,API Error: Bad Request +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 diff --git a/apps/bff/src/integrations/freebit/freebit.module.ts b/apps/bff/src/integrations/freebit/freebit.module.ts index 945b2d41..25163f39 100644 --- a/apps/bff/src/integrations/freebit/freebit.module.ts +++ b/apps/bff/src/integrations/freebit/freebit.module.ts @@ -54,6 +54,8 @@ import { SimManagementModule } from "../../modules/subscriptions/sim-management/ // Physical SIM activation services (PA02-01 + PA05-05) FreebitAccountRegistrationService, FreebitVoiceOptionsService, + // Rate limiter (needed by SimController) + FreebitRateLimiterService, ], }) export class FreebitModule {} 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 878048ca..5bd5a31a 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -82,13 +82,18 @@ export class FreebitClientService { if (resultCode && resultCode !== "100") { const isProd = process.env.NODE_ENV === "production"; - this.logger.warn("Freebit API returned error response", { + const errorDetails = { url, resultCode, statusCode, statusMessage: responseData.status?.message, - ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), - }); + ...(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"}`, @@ -146,16 +151,28 @@ export class FreebitClientService { } /** - * 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); + // Add authKey to the payload for authentication + const requestPayload = { ...payload, authKey }; + 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 () => { @@ -164,7 +181,7 @@ export class FreebitClientService { url, attempt, maxAttempts: config.retryAttempts, - payload: redactForLogs(payload), + payload: redactForLogs(requestPayload), }); const controller = new AbortController(); @@ -173,7 +190,7 @@ export class FreebitClientService { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + body: JSON.stringify(requestPayload), signal: controller.signal, }); @@ -191,14 +208,19 @@ export class FreebitClientService { if (resultCode && resultCode !== "100") { const isProd = process.env.NODE_ENV === "production"; - this.logger.error("Freebit API returned error result code", { + 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, 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 5c9bd3e7..2fea5f92 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 90400b1e..e2bed8a2 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts @@ -73,6 +73,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"; @@ -81,49 +94,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, @@ -132,6 +115,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; @@ -151,6 +172,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 19ea62d8..9636c89f 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-usage.service.ts b/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts index abd03e6a..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, + 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.service.ts b/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts index 3b4f8b56..a0112888 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts @@ -205,6 +205,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 { @@ -214,10 +216,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()) { @@ -239,19 +247,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/salesforce/services/salesforce-sim-inventory.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts index 5cf1fd7e..2f915b98 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-sim-inventory.service.ts @@ -100,15 +100,6 @@ export class SalesforceSIMInventoryService { return this.mapToSimInventoryRecord(record); } catch (error: unknown) { - // Temporary: Raw console log to see full Salesforce error - console.error(">>> SIM_INVENTORY QUERY ERROR >>>", { - simInventoryId: safeId, - soql, - errorMessage: error instanceof Error ? error.message : String(error), - errorName: error instanceof Error ? error.name : "Unknown", - errorStack: error instanceof Error ? error.stack : undefined, - fullError: JSON.stringify(error, Object.getOwnPropertyNames(error as object), 2), - }); this.logger.error("Failed to fetch SIM Inventory record", { simInventoryId: safeId, error: extractErrorMessage(error), 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 327220ea..c6f94eda 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"; @@ -10,6 +11,7 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js"; */ @Injectable() export class WhmcsErrorHandlerService { + constructor(@Inject(Logger) private readonly logger: Logger) {} /** * Handle WHMCS API error response */ @@ -26,22 +28,130 @@ export class WhmcsErrorHandlerService { } /** - * Handle general request errors (network, timeout, etc.) + * Handle general request errors (network, timeout, HTTP status errors, etc.) */ handleRequestError(error: unknown): 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); } + // 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); + } + + // 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, + }); + // If upstream already threw a DomainHttpException or HttpException with code, // let the global exception filter handle it. throw error; } + /** + * 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 9acac278..33799138 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 @@ -285,12 +285,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 9147d257..08a3a6c8 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -336,7 +336,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, }); } @@ -487,6 +502,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/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index a473bd6d..48de90a0 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 @@ -7,7 +7,11 @@ import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-or 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 { + 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"; @@ -44,6 +48,7 @@ export interface OrderFulfillmentContext { idempotencyKey: string; validation: OrderFulfillmentValidationResult | null; orderDetails?: OrderDetails; + simFulfillmentResult?: SimFulfillmentResult; mappingResult?: WhmcsOrderItemMappingResult; whmcsResult?: WhmcsOrderResult; steps: OrderFulfillmentStep[]; @@ -159,6 +164,8 @@ export class OrderFulfillmentOrchestrator { } // Step 3: Execute the main fulfillment workflow as a distributed transaction + // New flow: SIM activation (PA02-01 + PA05-05) → Activated status → WHMCS → Registration Completed + let simFulfillmentResult: SimFulfillmentResult | undefined; let mappingResult: WhmcsOrderItemMappingResult | undefined; let whmcsCreateResult: WhmcsOrderResult | undefined; @@ -205,21 +212,128 @@ export class OrderFulfillmentOrchestrator { ), critical: false, }, + // SIM fulfillment now runs BEFORE WHMCS (PA02-01 + PA05-05) + { + id: "sim_fulfillment", + description: "SIM activation via Freebit (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", + 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 custom fields if we have SIM data + if (simFulfillmentResult?.activated && simFulfillmentResult.phoneNumber) { + result.whmcsItems.forEach(item => { + 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); @@ -318,58 +432,20 @@ export class OrderFulfillmentOrchestrator { }, critical: true, }, + // Note: sim_fulfillment step was moved earlier in the flow (before WHMCS) { - id: "sim_fulfillment", - description: "SIM-specific fulfillment (if applicable)", - execute: this.createTrackedStep(context, "sim_fulfillment", async () => { - if (context.orderDetails?.orderType === "SIM") { - // Extract configurations from payload, with fallback to SF order fields (for CDC flow) - const configurations = this.extractConfigurations( - payload.configurations, - context.validation?.sfOrder - ); - // Extract Physical SIM inventory ID from Salesforce order for Physical SIM activation - const assignedPhysicalSimId = - typeof context.validation?.sfOrder?.Assign_Physical_SIM__c === "string" - ? context.validation.sfOrder.Assign_Physical_SIM__c - : undefined; - - // Debug: Log the extracted Physical SIM ID - this.logger.log("Physical SIM assignment check", { - orderId: context.sfOrderId, - simType: context.validation?.sfOrder?.SIM_Type__c, - assignPhysicalSimRaw: context.validation?.sfOrder?.Assign_Physical_SIM__c, - assignPhysicalSimType: - typeof context.validation?.sfOrder?.Assign_Physical_SIM__c, - assignedPhysicalSimId, - }); - - await this.simFulfillmentService.fulfillSimOrder({ - orderDetails: context.orderDetails, - configurations, - assignedPhysicalSimId, - }); - return { completed: true as const }; - } - return { skipped: true as const }; - }), - // Physical SIM orders MUST have successful SIM fulfillment - customer can't use service without activated SIM - // eSIM orders can retry activation separately if needed since the eSIM profile can be re-downloaded - critical: context.validation?.sfOrder?.SIM_Type__c === "Physical SIM", - }, - { - id: "sf_success_update", - description: "Update Salesforce with success", - execute: this.createTrackedStep(context, "sf_success_update", async () => { + id: "sf_registration_complete", + description: "Update Salesforce to Registration Completed", + execute: this.createTrackedStep(context, "sf_registration_complete", async () => { const result = await this.salesforceService.updateOrder({ Id: sfOrderId, - Status: "Completed", + Status: "Registration Completed", Activation_Status__c: "Activated", WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(), }); this.orderEvents.publish(sfOrderId, { orderId: sfOrderId, - status: "Completed", + status: "Registration Completed", activationStatus: "Activated", stage: "completed", source: "fulfillment", @@ -377,6 +453,7 @@ export class OrderFulfillmentOrchestrator { payload: { whmcsOrderId: whmcsCreateResult?.orderId, whmcsServiceIds: whmcsCreateResult?.serviceIds, + simPhoneNumber: simFulfillmentResult?.phoneNumber, }, }); await this.safeNotifyOrder({ @@ -505,23 +582,41 @@ export class OrderFulfillmentOrchestrator { /** * Initialize fulfillment steps + * + * New order (Physical SIM activation flow): + * 1. validation + * 2. sf_status_update (Activating) + * 3. order_details + * 4. sim_fulfillment (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 + * 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; } @@ -589,6 +684,103 @@ export class OrderFulfillmentOrchestrator { 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) { 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 064f490e..74c9b6b0 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -37,6 +37,22 @@ export interface SimFulfillmentRequest { contactIdentity?: ContactIdentityData; } +/** + * 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( @@ -47,7 +63,7 @@ export class SimFulfillmentService { @Inject(Logger) private readonly logger: Logger ) {} - async fulfillSimOrder(request: SimFulfillmentRequest): Promise { + async fulfillSimOrder(request: SimFulfillmentRequest): Promise { const { orderDetails, configurations, @@ -137,6 +153,12 @@ export class SimFulfillmentService { account: phoneNumber, planSku, }); + + return { + activated: true, + simType: "eSIM", + phoneNumber, + }; } else { // Physical SIM activation flow (PA02-01 + PA05-05) if (!assignedPhysicalSimId) { @@ -146,7 +168,7 @@ export class SimFulfillmentService { ); } - await this.activatePhysicalSim({ + const simData = await this.activatePhysicalSim({ orderId: orderDetails.id, simInventoryId: assignedPhysicalSimId, planSku, @@ -162,7 +184,17 @@ export class SimFulfillmentService { planSku, voiceMailEnabled, callWaitingEnabled, + phoneNumber: simData.phoneNumber, + serialNumber: simData.serialNumber, }); + + return { + activated: true, + simType: "Physical SIM", + phoneNumber: simData.phoneNumber, + serialNumber: simData.serialNumber, + simInventoryId: assignedPhysicalSimId, + }; } } @@ -252,7 +284,7 @@ export class SimFulfillmentService { voiceMailEnabled: boolean; callWaitingEnabled: boolean; contactIdentity?: ContactIdentityData; - }): Promise { + }): Promise<{ phoneNumber: string; serialNumber: string }> { const { orderId, simInventoryId, @@ -359,6 +391,12 @@ export class SimFulfillmentService { 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", { orderId, diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts index 56081db4..50327120 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/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 { @@ -80,12 +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, 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 c043eec5..bc8c3f9b 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 @@ -113,10 +113,32 @@ export class SimOrchestratorService { */ 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 a464c784..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 @@ -40,40 +40,36 @@ export class SimValidationService { // Extract SIM account identifier (using domain function) let account = extractSimAccountFromSubscription(subscription); - // Final fallback - for testing, use the known test SIM number + // If no account found, log detailed info and throw error if (!account) { - // Use the specific test SIM number that should exist in the test environment - account = "02000331144508"; - - this.logger.warn( - `No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`, + this.logger.error( + `No SIM account identifier found for subscription ${subscriptionId}`, { userId, subscriptionId, productName: subscription.productName, domain: subscription.domain, - customFields: subscription.customFields ? Object.keys(subscription.customFields) : [], - note: "Using known test SIM number 02000331144508 - should exist in Freebit test environment", + 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.", } ); + + 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 }; @@ -102,40 +98,30 @@ export class SimValidationService { subscriptionId ); - // Check for specific SIM data - const expectedSimNumber = "02000331144508"; - const expectedEid = "89049032000001000000043598005455"; - - 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 89d00ad1..d2d7d429 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts @@ -14,6 +14,7 @@ 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"; @@ -81,7 +82,8 @@ export class SimController { 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) ==================== @@ -111,6 +113,13 @@ export class SimController { 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 ==================== @Get(":id/sim/debug") diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index 0466760f..5c838804 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -217,38 +217,3 @@ font-family: var(--font-geist-sans, system-ui), system-ui, sans-serif; } } - -/* ============================================================================= - ANIMATIONS - ============================================================================= */ - -/* Floating blob animation for hero background */ -@keyframes blob-float { - 0%, - 100% { - transform: translate(0, 0) scale(1); - } - 33% { - transform: translate(10px, -10px) scale(1.02); - } - 66% { - transform: translate(-5px, 5px) scale(0.98); - } -} - -.animate-blob-float { - animation: blob-float 20s ease-in-out infinite; -} - -/* Animation delay utilities for staggered effects */ -.animation-delay-2000 { - animation-delay: 2s; -} - -.animation-delay-4000 { - animation-delay: 4s; -} - -.animation-delay-6000 { - animation-delay: 6s; -} diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 4d9b95ae..ce2e520c 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -545,17 +545,31 @@ export function PublicLandingView() { {/* Hero Section */}
} - className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-gradient-to-br from-slate-50 via-white to-sky-50/50 py-12 sm:py-16 overflow-hidden transition-all duration-700 ${ + className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 py-12 sm:py-16 overflow-hidden transition-all duration-700 ${ heroInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" }`} > - {/* Animated gradient blobs */} -
-
-
-
-
-
+ {/* Gradient Background */} +
+ + {/* Dot Grid Pattern Overlay */} +
+ + {/* Subtle gradient accent in corner */} +
@@ -689,15 +703,22 @@ export function PublicLandingView() {
+ + {/* Gradient fade to next section */} +
{/* Trust and Excellence Section */}
} - className={`max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 py-14 sm:py-16 transition-all duration-700 ${ + className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-white transition-all duration-700 ${ trustInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" }`} > + {/* Gradient fade to next section */} +
+ +
+
{/* Solutions Carousel */} @@ -750,6 +772,8 @@ export function PublicLandingView() { solutionsInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" }`} > + {/* Gradient fade to next section */} +

Solutions

@@ -827,19 +851,12 @@ export function PublicLandingView() { {/* Remote Support - Full section with mobile-optimized card layout */}
} - className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 py-12 sm:py-16 transition-all duration-700 overflow-hidden ${ + className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-white py-12 sm:py-16 transition-all duration-700 overflow-hidden ${ supportInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" }`} > - {/* Subtle gradient background with connection pattern */} -
-
+ {/* Gradient fade to next section */} +
{/* Section header with connection metaphor */} diff --git a/apps/portal/src/features/marketing/views/AboutUsView.tsx b/apps/portal/src/features/marketing/views/AboutUsView.tsx index db4f57f6..58c82285 100644 --- a/apps/portal/src/features/marketing/views/AboutUsView.tsx +++ b/apps/portal/src/features/marketing/views/AboutUsView.tsx @@ -114,7 +114,7 @@ export function AboutUsView() { }, [computeScrollAmount]); return ( -
+
{/* Hero with geometric pattern */}
{/* Dot grid pattern */} @@ -127,6 +127,8 @@ export function AboutUsView() { /> {/* Subtle gradient overlay for depth */}
+ {/* Gradient fade to next section */} +
@@ -163,6 +165,8 @@ export function AboutUsView() { {/* Business Solutions Carousel */}
+ {/* Gradient fade to next section */} +

Business

@@ -210,7 +214,11 @@ export function AboutUsView() {
{/* Our Values Section */} -
+
+ {/* Gradient fade to next section */} +
+ +

Our Values

@@ -263,10 +271,11 @@ export function AboutUsView() { ))}

+
{/* Corporate Data Section */} -
+
{/* Row 1: headings same level */}
diff --git a/apps/portal/src/features/services/components/base/ServiceHighlights.tsx b/apps/portal/src/features/services/components/base/ServiceHighlights.tsx index 9ef3616b..3a3c44e5 100644 --- a/apps/portal/src/features/services/components/base/ServiceHighlights.tsx +++ b/apps/portal/src/features/services/components/base/ServiceHighlights.tsx @@ -1,4 +1,8 @@ +"use client"; + +import { useRef, useState, useEffect } from "react"; import { CheckCircle } from "lucide-react"; +import { cn } from "@/shared/utils"; export interface HighlightFeature { icon: React.ReactNode; @@ -35,17 +39,117 @@ function HighlightItem({ icon, title, description, highlight }: HighlightFeature } /** - * ServiceHighlights - * - * A clean, grid-based layout for displaying service features/highlights. - * Replaces the old boxed "Why Choose Us" sections. + * Mobile Carousel Item - Compact card for horizontal scrolling */ -export function ServiceHighlights({ features, className = "" }: ServiceHighlightsProps) { +function MobileCarouselItem({ icon, title, description, highlight }: HighlightFeature) { return ( -
- {features.map((feature, index) => ( - - ))} +
+
+ {/* Top row: Icon + Highlight badge */} +
+
+ {icon} +
+ {highlight && ( + + {highlight} + + )} +
+ + {/* Content */} +

{title}

+

{description}

+
); } + +/** + * ServiceHighlights + * + * A clean, grid-based layout for displaying service features/highlights. + * On mobile: horizontal scrolling carousel with snap points. + * On desktop: grid layout. + */ +export function ServiceHighlights({ features, className = "" }: ServiceHighlightsProps) { + const scrollContainerRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + + // Track scroll position to update active dot indicator + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const handleScroll = () => { + const scrollLeft = container.scrollLeft; + const itemWidth = 280 + 12; // card width + gap + const newIndex = Math.round(scrollLeft / itemWidth); + setActiveIndex(Math.min(newIndex, features.length - 1)); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + return () => container.removeEventListener("scroll", handleScroll); + }, [features.length]); + + // Scroll to specific item when dot is clicked + const scrollToIndex = (index: number) => { + const container = scrollContainerRef.current; + if (!container) return; + + const itemWidth = 280 + 12; // card width + gap + container.scrollTo({ + left: index * itemWidth, + behavior: "smooth", + }); + }; + + return ( + <> + {/* Mobile: Horizontal scrolling carousel */} +
+ {/* Scroll container */} +
+ {features.map((feature, index) => ( + + ))} + {/* End spacer for last item visibility */} + + + {/* Dot indicators */} +
+ {features.map((_, index) => ( +
+ + {/* Swipe hint - only show initially */} +

+ Swipe to explore features → +

+
+ + {/* Desktop: Grid layout */} +
+ {features.map((feature, index) => ( + + ))} +
+ + ); +} diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index eafbcf88..7d1f0663 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -463,6 +463,189 @@ function ExpandedTierDetails({ ); } +/** + * Mobile Plan Card - Combined header + inline expandable details for mobile view + */ +function MobilePlanCard({ + config, + minPrice, + tiers, + setupFee, + isExpanded, + onToggle, +}: { + config: OfferingTypeConfig; + minPrice: number; + tiers: TierInfo[]; + setupFee: number; + isExpanded: boolean; + onToggle: () => void; +}) { + return ( +
+ {/* Header - Clickable to expand/collapse */} + + + {/* Inline Expanded Content - slides down below header */} +
+
+
+ {/* Tier Cards - Stacked vertically on mobile */} +
+ {tiers.map(tier => ( +
+ {/* Popular Badge for Gold */} + {tier.tier === "Gold" && ( +
+ + + Popular + +
+ )} + +
+
+ {/* Tier Name */} +

+ {tier.tier} +

+ + {/* Description */} +

{tier.description}

+
+ + {/* Price - Right aligned */} +
+
+ + ¥{tier.monthlyPrice.toLocaleString()} + + /mo +
+ {tier.pricingNote && ( + + {tier.pricingNote} + + )} +
+
+ + {/* Features - Compact horizontal layout */} +
    + {tier.features.slice(0, 2).map((feature, index) => ( +
  • + + + + {feature} +
  • + ))} +
+
+ ))} +
+ + {/* Footer with setup fee */} +
+

+ + + ¥{setupFee.toLocaleString()} one-time setup + {" "} + (or 12/24-month installment) +

+
+
+
+
+
+ ); +} + /** * Available Plans Section - Horizontal cards for each offering type with expandable details */ @@ -486,17 +669,19 @@ function AvailablePlansSection({

Available Plans

- {/* Horizontal card headers */} -
+ {/* Mobile view - Cards with inline expandable content */} +
{availableConfigs.map(config => { const offeringData = plansByOffering[config.id]; if (!offeringData) return null; return ( - toggleCard(config.id)} /> @@ -504,21 +689,41 @@ function AvailablePlansSection({ })}
- {/* Expanded tier details - shown below the horizontal cards */} -
- {availableConfigs.map(config => { - const offeringData = plansByOffering[config.id]; - if (!offeringData || expandedCard !== config.id) return null; + {/* Desktop view - Horizontal card headers with separate expanded section */} +
+
+ {availableConfigs.map(config => { + const offeringData = plansByOffering[config.id]; + if (!offeringData) return null; - return ( - - ); - })} + return ( + toggleCard(config.id)} + /> + ); + })} +
+ + {/* Expanded tier details - shown below the horizontal cards (desktop only) */} +
+ {availableConfigs.map(config => { + const offeringData = plansByOffering[config.id]; + if (!offeringData || expandedCard !== config.id) return null; + + return ( + + ); + })} +
); diff --git a/apps/portal/src/features/subscriptions/components/sim/SimDetailsCard.tsx b/apps/portal/src/features/subscriptions/components/sim/SimDetailsCard.tsx index d620c060..80834dd6 100644 --- a/apps/portal/src/features/subscriptions/components/sim/SimDetailsCard.tsx +++ b/apps/portal/src/features/subscriptions/components/sim/SimDetailsCard.tsx @@ -342,12 +342,17 @@ export function SimDetailsCard({
- {simDetails.internationalRoamingEnabled && ( -
- - International Roaming Enabled -
- )} +
+ + + Int'l Roaming{" "} + {simDetails.internationalRoamingEnabled ? "Enabled" : "Disabled"} + +
)} diff --git a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx index cafee239..34304e16 100644 --- a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx +++ b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx @@ -54,6 +54,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Voice feature states + const [featureLoading, setFeatureLoading] = useState<{ + voiceMail?: boolean; + callWaiting?: boolean; + internationalRoaming?: boolean; + networkType?: boolean; + }>({}); + const [featureError, setFeatureError] = useState(null); + // Navigation handlers const navigateToTopUp = () => router.push(`/account/subscriptions/${subscriptionId}/sim/top-up`); const navigateToChangePlan = () => @@ -114,6 +123,88 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro void fetchSimInfo(); }, [fetchSimInfo]); + // Update a single voice feature + const updateFeature = useCallback( + async ( + featureKey: "voiceMail" | "callWaiting" | "internationalRoaming" | "networkType", + value: boolean | "4G" | "5G" + ) => { + setFeatureLoading(prev => ({ ...prev, [featureKey]: true })); + setFeatureError(null); + + try { + const body: { + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + networkType?: "4G" | "5G"; + } = {}; + + switch (featureKey) { + case "voiceMail": + body.voiceMailEnabled = value as boolean; + break; + case "callWaiting": + body.callWaitingEnabled = value as boolean; + break; + case "internationalRoaming": + body.internationalRoamingEnabled = value as boolean; + break; + case "networkType": + body.networkType = value as "4G" | "5G"; + break; + } + + await apiClient.POST("/api/subscriptions/{id}/sim/features", { + params: { path: { id: subscriptionId } }, + body, + }); + + // Update local state optimistically + setSimInfo(prev => { + if (!prev) return prev; + const updated = { ...prev, details: { ...prev.details } }; + switch (featureKey) { + case "voiceMail": + updated.details.voiceMailEnabled = value as boolean; + break; + case "callWaiting": + updated.details.callWaitingEnabled = value as boolean; + break; + case "internationalRoaming": + updated.details.internationalRoamingEnabled = value as boolean; + break; + case "networkType": + updated.details.networkType = value as string; + break; + } + return updated; + }); + } catch (err: unknown) { + let errorMessage = "Failed to update feature. Please try again."; + + if (err instanceof Error) { + const msg = err.message.toLowerCase(); + // Check for rate limiting errors + if (msg.includes("30 minutes") || msg.includes("must be requested")) { + errorMessage = "Please wait 30 minutes between voice/network/plan changes before trying again."; + } else if (msg.includes("another") && msg.includes("in progress")) { + errorMessage = "Another operation is in progress. Please wait a moment."; + } else { + errorMessage = err.message; + } + } + + setFeatureError(errorMessage); + // Revert by refetching + void fetchSimInfo(); + } finally { + setFeatureLoading(prev => ({ ...prev, [featureKey]: false })); + } + }, + [subscriptionId, fetchSimInfo] + ); + const handleRefresh = () => { setLoading(true); void fetchSimInfo(); @@ -215,26 +306,39 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro {/* Voice toggles */}

Voice Status

+ {featureError && ( +
+ {featureError} +
+ )}
void updateFeature("voiceMail", checked)} /> void updateFeature("networkType", checked ? "5G" : "4G")} /> void updateFeature("callWaiting", checked)} /> void updateFeature("internationalRoaming", checked)} />
@@ -360,19 +464,36 @@ type StatusToggleProps = { label: string; subtitle?: string; checked: boolean; + onChange?: (checked: boolean) => void; + loading?: boolean; + disabled?: boolean; }; -function StatusToggle({ label, subtitle, checked }: StatusToggleProps) { +function StatusToggle({ label, subtitle, checked, onChange, loading, disabled }: StatusToggleProps) { + const isDisabled = disabled || loading; + + const handleClick = () => { + if (!isDisabled && onChange) { + onChange(!checked); + } + }; + return ( -
+

{label}

{subtitle &&

{subtitle}

}
-
diff --git a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx index b5e1a4d2..85d091f7 100644 --- a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx +++ b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx @@ -19,7 +19,6 @@ export function SimChangePlanContainer() { const subscriptionId = params.id as string; const [plans, setPlans] = useState([]); const [selectedPlan, setSelectedPlan] = useState(null); - const [assignGlobalIp, setAssignGlobalIp] = useState(false); const [message, setMessage] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -61,7 +60,6 @@ export function SimChangePlanContainer() { newPlanCode: selectedPlan.freebitPlanCode, newPlanSku: selectedPlan.sku, newPlanName: selectedPlan.name, - assignGlobalIp, }); setMessage(`Plan change scheduled for ${result.scheduledAt || "the 1st of next month"}`); setSelectedPlan(null); @@ -203,20 +201,6 @@ export function SimChangePlanContainer() {
- {/* Global IP Option */} -
- setAssignGlobalIp(e.target.checked)} - className="h-4 w-4 text-primary border-input rounded focus:ring-ring focus:ring-2" - /> - -
- {/* Info Box */}

Important Notes

diff --git a/apps/portal/src/styles/utilities.css b/apps/portal/src/styles/utilities.css index 5826e5d6..43e5be86 100644 --- a/apps/portal/src/styles/utilities.css +++ b/apps/portal/src/styles/utilities.css @@ -160,4 +160,14 @@ outline: var(--cp-focus-ring); outline-offset: var(--cp-focus-ring-offset); } + + /* ===== SCROLLBAR ===== */ + .scrollbar-hide { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + .scrollbar-hide::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ + } }