diff --git a/apps/bff/src/common/email/email.service.ts b/apps/bff/src/common/email/email.service.ts index 2765b8b1..537edbf8 100644 --- a/apps/bff/src/common/email/email.service.ts +++ b/apps/bff/src/common/email/email.service.ts @@ -6,6 +6,7 @@ import { EmailQueueService, EmailJobData } from "./queue/email.queue"; export interface SendEmailOptions { to: string | string[]; + from?: string; subject: string; text?: string; html?: string; diff --git a/apps/bff/src/common/email/providers/sendgrid.provider.ts b/apps/bff/src/common/email/providers/sendgrid.provider.ts index e18a2f47..9e74ca80 100644 --- a/apps/bff/src/common/email/providers/sendgrid.provider.ts +++ b/apps/bff/src/common/email/providers/sendgrid.provider.ts @@ -5,6 +5,7 @@ import sgMail, { MailDataRequired } from "@sendgrid/mail"; export interface ProviderSendOptions { to: string | string[]; + from?: string; subject: string; text?: string; html?: string; @@ -25,7 +26,7 @@ export class SendGridEmailProvider { } async send(options: ProviderSendOptions): Promise { - const from = this.config.get("EMAIL_FROM"); + const from = options.from || this.config.get("EMAIL_FROM"); if (!from) { this.logger.warn("EMAIL_FROM is not configured; email not sent"); return; diff --git a/apps/bff/src/subscriptions/sim-management.service.ts b/apps/bff/src/subscriptions/sim-management.service.ts index 31f1f8f3..beab76c5 100644 --- a/apps/bff/src/subscriptions/sim-management.service.ts +++ b/apps/bff/src/subscriptions/sim-management.service.ts @@ -7,6 +7,7 @@ import { SubscriptionsService } from "./subscriptions.service"; import { SimDetails, SimUsage, SimTopUpHistory } from "../vendors/freebit/interfaces/freebit.types"; import { SimUsageStoreService } from "./sim-usage-store.service"; import { getErrorMessage } from "../common/utils/error.util"; +import { EmailService } from "../common/email/email.service"; export interface SimTopUpRequest { quotaMb: number; @@ -40,9 +41,39 @@ export class SimManagementService { private readonly mappingsService: MappingsService, private readonly subscriptionsService: SubscriptionsService, @Inject(Logger) private readonly logger: Logger, - private readonly usageStore: SimUsageStoreService + private readonly usageStore: SimUsageStoreService, + private readonly email: EmailService ) {} + private async notifySimAction( + action: string, + status: "SUCCESS" | "ERROR", + context: Record + ): Promise { + try { + const statusWord = status === "SUCCESS" ? "SUCCESSFUL" : "ERROR"; + const subject = `[SIM ACTION] ${action} - API RESULT ${statusWord}`; + const to = "info@asolutions.co.jp"; + const from = "ankhbayar@asolutions.co.jp"; // per request + const lines: string[] = [ + `Action: ${action}`, + `Result: ${status}`, + `Timestamp: ${new Date().toISOString()}`, + "", + "Context:", + JSON.stringify(context, null, 2), + ]; + await this.email.sendEmail({ to, from, subject, text: lines.join("\n") }); + } catch (err) { + // Never fail the operation due to notification issues + this.logger.warn("Failed to send SIM action notification email", { + action, + status, + error: getErrorMessage(err), + }); + } + } + /** * Debug method to check subscription data for SIM services */ @@ -462,6 +493,15 @@ export class SimManagementService { invoiceId: invoice.id, transactionId: paymentResult.transactionId, }); + await this.notifySimAction("Top Up Data", "SUCCESS", { + userId, + subscriptionId, + account, + quotaMb: request.quotaMb, + costJpy, + invoiceId: invoice.id, + transactionId: paymentResult.transactionId, + }); } catch (freebititError) { // If Freebit fails after payment, we need to handle this carefully // For now, we'll log the error and throw it - in production, you might want to: @@ -509,9 +549,19 @@ export class SimManagementService { // type: 'refund' // }); - throw new Error( + const errMsg = `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.` - ); + ; + await this.notifySimAction("Top Up Data", "ERROR", { + userId, + subscriptionId, + account, + quotaMb: request.quotaMb, + invoiceId: invoice.id, + transactionId: paymentResult.transactionId, + error: getErrorMessage(freebititError), + }); + throw new Error(errMsg); } } catch (error) { this.logger.error(`Failed to top up SIM for subscription ${subscriptionId}`, { @@ -520,6 +570,12 @@ export class SimManagementService { subscriptionId, quotaMb: request.quotaMb, }); + await this.notifySimAction("Top Up Data", "ERROR", { + userId, + subscriptionId, + quotaMb: request.quotaMb, + error: getErrorMessage(error), + }); throw error; } } @@ -611,6 +667,13 @@ export class SimManagementService { scheduledAt: scheduledAt, assignGlobalIp: false, }); + await this.notifySimAction("Change Plan", "SUCCESS", { + userId, + subscriptionId, + account, + newPlanCode: request.newPlanCode, + scheduledAt, + }); return result; } catch (error) { @@ -620,6 +683,12 @@ export class SimManagementService { subscriptionId, newPlanCode: request.newPlanCode, }); + await this.notifySimAction("Change Plan", "ERROR", { + userId, + subscriptionId, + newPlanCode: request.newPlanCode, + error: getErrorMessage(error), + }); throw error; } } @@ -693,6 +762,16 @@ export class SimManagementService { account, ...request, }); + await this.notifySimAction("Update Features", "SUCCESS", { + userId, + subscriptionId, + account, + ...request, + note: + doVoice && doContract + ? "Voice options applied immediately; contract line change scheduled after 30 minutes" + : undefined, + }); } catch (error) { this.logger.error(`Failed to update SIM features for subscription ${subscriptionId}`, { error: getErrorMessage(error), @@ -700,6 +779,12 @@ export class SimManagementService { subscriptionId, ...request, }); + await this.notifySimAction("Update Features", "ERROR", { + userId, + subscriptionId, + ...request, + error: getErrorMessage(error), + }); throw error; } } @@ -738,12 +823,23 @@ export class SimManagementService { account, runDate, }); + await this.notifySimAction("Cancel SIM", "SUCCESS", { + userId, + subscriptionId, + account, + runDate, + }); } catch (error) { this.logger.error(`Failed to cancel SIM for subscription ${subscriptionId}`, { error: getErrorMessage(error), userId, subscriptionId, }); + await this.notifySimAction("Cancel SIM", "ERROR", { + userId, + subscriptionId, + error: getErrorMessage(error), + }); throw error; } } @@ -780,6 +876,13 @@ export class SimManagementService { oldEid: simDetails.eid, newEid: newEid || undefined, }); + await this.notifySimAction("Reissue eSIM", "SUCCESS", { + userId, + subscriptionId, + account, + oldEid: simDetails.eid, + newEid: newEid || undefined, + }); } catch (error) { this.logger.error(`Failed to reissue eSIM profile for subscription ${subscriptionId}`, { error: getErrorMessage(error), @@ -787,6 +890,12 @@ export class SimManagementService { subscriptionId, newEid: newEid || undefined, }); + await this.notifySimAction("Reissue eSIM", "ERROR", { + userId, + subscriptionId, + newEid: newEid || undefined, + error: getErrorMessage(error), + }); throw error; } } diff --git a/apps/bff/src/subscriptions/subscriptions.module.ts b/apps/bff/src/subscriptions/subscriptions.module.ts index 40e3c143..c8da1502 100644 --- a/apps/bff/src/subscriptions/subscriptions.module.ts +++ b/apps/bff/src/subscriptions/subscriptions.module.ts @@ -6,9 +6,10 @@ import { SimUsageStoreService } from "./sim-usage-store.service"; import { WhmcsModule } from "../vendors/whmcs/whmcs.module"; import { MappingsModule } from "../mappings/mappings.module"; import { FreebititModule } from "../vendors/freebit/freebit.module"; +import { EmailModule } from "../common/email/email.module"; @Module({ - imports: [WhmcsModule, MappingsModule, FreebititModule], + imports: [WhmcsModule, MappingsModule, FreebititModule, EmailModule], controllers: [SubscriptionsController], providers: [SubscriptionsService, SimManagementService, SimUsageStoreService], }) diff --git a/apps/bff/src/vendors/freebit/freebit.service.ts b/apps/bff/src/vendors/freebit/freebit.service.ts index 6ddd7ddc..78bca169 100644 --- a/apps/bff/src/vendors/freebit/freebit.service.ts +++ b/apps/bff/src/vendors/freebit/freebit.service.ts @@ -674,7 +674,8 @@ export class FreebititService { } if (doContract && features.networkType) { - await this.makeAuthenticatedJsonRequest( + // Contract line change endpoint expects form-encoded payload (json=...) + await this.makeAuthenticatedRequest( '/mvno/contractline/change/', { account, diff --git a/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts b/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts index ead0dd4f..72763923 100644 --- a/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts +++ b/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts @@ -224,8 +224,8 @@ export interface FreebititContractLineChangeRequest { export interface FreebititContractLineChangeResponse { resultCode: string | number; - status?: unknown; - statusCode?: string; + status?: { message?: string; statusCode?: string | number }; + statusCode?: string | number; message?: string; } diff --git a/apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx b/apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx index 1023c006..2cf584f2 100644 --- a/apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx +++ b/apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx @@ -1,27 +1,91 @@ "use client"; import Link from "next/link"; -import { useParams } from "next/navigation"; -import { useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; import { DashboardLayout } from "@/components/layout/dashboard-layout"; import { authenticatedApi } from "@/lib/api"; +import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard"; +import { formatPlanShort } from "@/lib/plan"; + +type Step = 1 | 2 | 3; export default function SimCancelPage() { const params = useParams(); + const router = useRouter(); const subscriptionId = parseInt(params.id as string); + + const [step, setStep] = useState(1); const [loading, setLoading] = useState(false); - const [message, setMessage] = useState(null); + const [details, setDetails] = useState(null); const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [acceptTerms, setAcceptTerms] = useState(false); + const [confirmMonthEnd, setConfirmMonthEnd] = useState(false); + const [cancelMonth, setCancelMonth] = useState(""); // YYYYMM + const [email, setEmail] = useState(""); + const [email2, setEmail2] = useState(""); + const [notes, setNotes] = useState(""); + const [registeredEmail, setRegisteredEmail] = useState(null); + + useEffect(() => { + const fetchDetails = async () => { + try { + const d = await authenticatedApi.get(`/subscriptions/${subscriptionId}/sim/details`); + setDetails(d); + } catch (e: any) { + setError(e instanceof Error ? e.message : "Failed to load SIM details"); + } + }; + void fetchDetails(); + }, [subscriptionId]); + + // Fetch registered email (from WHMCS billing info) + useEffect(() => { + const fetchEmail = async () => { + try { + const billing = await authenticatedApi.get<{ email?: string }>(`/me/billing`); + if (billing?.email) setRegisteredEmail(billing.email); + } catch { + // Non-fatal; leave as null + } + }; + void fetchEmail(); + }, []); + + const monthOptions = useMemo(() => { + const opts: { value: string; label: string }[] = []; + const now = new Date(); + // start from next month, 12 options + for (let i = 1; i <= 12; i++) { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + i, 1)); + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + opts.push({ value: `${y}${m}`, label: `${y} / ${m}` }); + } + return opts; + }, []); + + const canProceedStep2 = !!details; + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const emailProvided = email.trim().length > 0 || email2.trim().length > 0; + const emailValid = !emailProvided || (emailPattern.test(email.trim()) && emailPattern.test(email2.trim())); + const emailsMatch = !emailProvided || email.trim() === email2.trim(); + const canProceedStep3 = acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch; + const runDate = cancelMonth ? `${cancelMonth}01` : undefined; // YYYYMM01 const submit = async () => { setLoading(true); - setMessage(null); setError(null); + setMessage(null); try { - await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {}); - setMessage("SIM service cancelled successfully"); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : "Failed to cancel SIM service"); + await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, { + scheduledAt: runDate, + }); + setMessage("Cancellation request submitted. You will receive a confirmation email."); + setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500); + } catch (e: any) { + setError(e instanceof Error ? e.message : "Failed to submit cancellation"); } finally { setLoading(false); } @@ -29,54 +93,181 @@ export default function SimCancelPage() { return ( -
-
- - ← Back to SIM Management - +
+
+ ← Back to SIM Management +
Step {step} of 3
+ + {error && ( +
{error}
+ )} + {message && ( +
{message}
+ )} +
-

Cancel SIM

-

- Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will - terminate your service immediately. -

+

Cancel SIM Subscription

- {message && ( -
- {message} -
- )} - {error && ( -
- {error} + {step === 1 && ( +
+

You are about to cancel your SIM subscription. Please review the details below and click Next to continue.

+
+ + + + + +
+
Minimum contract period is 3 billing months (not including the free first month).
+
+ Return + +
)} -
- This is a destructive action. Your service will be terminated immediately. -
+ {step === 2 && ( +
+
+ + Online cancellations must be made from this website by the 25th of the desired cancellation month. +Once a request of a cancellation of the SONIXNET SIM is accepted from this online form, a confirmation email containing details of the SIM plan will be sent to the registered email address. +The SIM card is a rental piece of hardware and must be returned to Assist Solutions upon cancellation. +The cancellation request through this website retains to your SIM subscriptions only. To cancel any other services with Assist Solutions (home internet etc.) please contact Assist Solutions at info@asolutions.co.jp + + + The SONIXNET SIM has a minimum contract term agreement of three months (sign-up month is not included in the minimum term of three months; ie. sign-up in January = minimum term is February, March, April). + If the minimum contract term is not fulfilled, the monthly fees of the remaining months will be charged upon cancellation. + + + Cancellation of option services only (Voice Mail, Call Waiting) while keeping the base plan active is not possible from this online form. Please contact Assist Solutions Customer Support (info@asolutions.co.jp) for more information. + Upon cancelling the base plan, all additional options associated with the requested SIM plan will be cancelled. + + + Upon cancellation the SIM phone number will be lost. In order to keep the phone number active to be used with a different cellular provider, a request for an MNP transfer (administrative fee \1,000yen+tax) is necessary. The MNP cannot be requested from this online form. Please contact Assist Solutions Customer Support (info@asolutions.co.jp) for more information. + 4 +
+
+ + +
+ + +

Cancellation takes effect at the start of the selected month.

+
+
+
+ setAcceptTerms(e.target.checked)} /> + +
+
+ setConfirmMonthEnd(e.target.checked)} + disabled={!cancelMonth} + /> + +
+
+ + +
+
+ )} -
- - - Back - -
+ {step === 3 && ( +
+ + Calling charges are post payment. Your bill for the final month's calling charges will be charged on your credit card on file during the first week of the second month after the cancellation. If you would like to make the payment with a different credit card, please contact Assist Solutions at + {" "} + info@asolutions.co.jp. + + {registeredEmail && ( +
+ Your registered email address is: {registeredEmail} +
+ )} +
+ You will receive a cancellation confirmation email. If you would like to receive this email on a different address, please enter the address below. +
+
+
+ + setEmail(e.target.value)} placeholder="you@example.com" /> +
+
+ + setEmail2(e.target.value)} placeholder="you@example.com" /> +
+
+ +