Add email notification functionality to SIM management actions

- Introduced email notifications for various SIM management actions (e.g., top-ups, plan changes, cancellations) in SimManagementService.
- Updated SendGridEmailProvider to allow custom 'from' addresses in email options.
- Enhanced the SimCancelPage component to provide user feedback and confirmation regarding cancellation requests.
- Refactored email service integration to improve error handling and logging for email notifications.
This commit is contained in:
tema 2025-09-10 16:33:24 +09:00
parent adf653e5e1
commit c30afc4bec
10 changed files with 386 additions and 68 deletions

View File

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

View File

@ -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<void> {
const from = this.config.get<string>("EMAIL_FROM");
const from = options.from || this.config.get<string>("EMAIL_FROM");
if (!from) {
this.logger.warn("EMAIL_FROM is not configured; email not sent");
return;

View File

@ -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<string, unknown>
): Promise<void> {
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;
}
}

View File

@ -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],
})

View File

@ -674,7 +674,8 @@ export class FreebititService {
}
if (doContract && features.networkType) {
await this.makeAuthenticatedJsonRequest<import('./interfaces/freebit.types').FreebititContractLineChangeResponse>(
// Contract line change endpoint expects form-encoded payload (json=...)
await this.makeAuthenticatedRequest<import('./interfaces/freebit.types').FreebititContractLineChangeResponse>(
'/mvno/contractline/change/',
{
account,

View File

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

View File

@ -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<Step>(1);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [details, setDetails] = useState<SimDetails | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [acceptTerms, setAcceptTerms] = useState(false);
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
const [cancelMonth, setCancelMonth] = useState<string>(""); // YYYYMM
const [email, setEmail] = useState<string>("");
const [email2, setEmail2] = useState<string>("");
const [notes, setNotes] = useState<string>("");
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null);
useEffect(() => {
const fetchDetails = async () => {
try {
const d = await authenticatedApi.get<SimDetails>(`/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 (
<DashboardLayout>
<div className="max-w-3xl mx-auto p-6">
<div className="mb-4">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
>
Back to SIM Management
</Link>
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700"> Back to SIM Management</Link>
<div className="text-sm text-gray-500">Step {step} of 3</div>
</div>
{error && (
<div className="text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>
)}
{message && (
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-3">{message}</div>
)}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
<p className="text-sm text-gray-600 mb-6">
Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will
terminate your service immediately.
</p>
<h1 className="text-xl font-semibold text-gray-900 mb-6">Cancel SIM Subscription</h1>
{message && (
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
{message}
</div>
)}
{error && (
<div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
{step === 1 && (
<div className="space-y-6">
<p className="text-sm text-gray-600">You are about to cancel your SIM subscription. Please review the details below and click Next to continue.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoRow label="SIM #" value={details?.msisdn || "—"} />
<InfoRow label="Plan" value={details ? formatPlanShort(details.planCode) : "—"} />
<InfoRow label="Options" value={`VM ${details?.voiceMailEnabled ? 'On' : 'Off'} / CW ${details?.callWaitingEnabled ? 'On' : 'Off'}`} />
<InfoRow label="Start of Contract" value={details?.startDate || "—"} />
<InfoRow label="SIM Status" value={details?.status || "—"} />
</div>
<div className="text-xs text-red-600">Minimum contract period is 3 billing months (not including the free first month).</div>
<div className="flex justify-end gap-3">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50">Return</Link>
<button disabled={!canProceedStep2} onClick={() => setStep(2)} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50">Next</button>
</div>
</div>
)}
<div className="bg-red-50 border border-red-200 rounded p-4 mb-4 text-sm text-red-800">
This is a destructive action. Your service will be terminated immediately.
</div>
{step === 2 && (
<div className="space-y-6">
<div className="space-y-3">
<Notice title="Cancellation Procedure">
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
</Notice>
<Notice title="Minimum Contract Term">
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.
</Notice>
<Notice title="Option Services">
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.
</Notice>
<Notice title="MNP Transfer (Voice Plans)">
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.
</Notice>4
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<InfoRow label="SIM" value={details?.msisdn || "—"} />
<InfoRow label="Start Date" value={details?.startDate || "—"} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Cancellation Month</label>
<select
value={cancelMonth}
onChange={(e) => {
setCancelMonth(e.target.value);
// Require re-confirmation if month changes
setConfirmMonthEnd(false);
}}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
>
<option value="">Select month</option>
{monthOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">Cancellation takes effect at the start of the selected month.</p>
</div>
</div>
<div className="flex items-center gap-2">
<input id="acceptTerms" type="checkbox" checked={acceptTerms} onChange={(e) => setAcceptTerms(e.target.checked)} />
<label htmlFor="acceptTerms" className="text-sm text-gray-700">I have read and accepted the conditions above.</label>
</div>
<div className="flex items-start gap-2">
<input
id="confirmMonthEnd"
type="checkbox"
checked={confirmMonthEnd}
onChange={(e) => setConfirmMonthEnd(e.target.checked)}
disabled={!cancelMonth}
/>
<label htmlFor="confirmMonthEnd" className="text-sm text-gray-700">
I would like to cancel my SonixNet SIM subscription at the end of the selected month above.
</label>
</div>
<div className="flex justify-between">
<button onClick={() => setStep(1)} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50">Back</button>
<button disabled={!canProceedStep3} onClick={() => setStep(3)} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50">Next</button>
</div>
</div>
)}
<div className="flex gap-3">
<button
onClick={() => void submit()}
disabled={loading}
className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50"
>
{loading ? "Processing…" : "Cancel SIM"}
</button>
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
>
Back
</Link>
</div>
{step === 3 && (
<div className="space-y-6">
<Notice title="For Voice-enabled SIM subscriptions:">
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
{" "}
<a href="mailto:info@asolutions.co.jp" className="text-blue-600 underline">info@asolutions.co.jp</a>.
</Notice>
{registeredEmail && (
<div className="text-sm text-gray-800">
Your registered email address is: <span className="font-medium">{registeredEmail}</span>
</div>
)}
<div className="text-sm text-gray-700">
You will receive a cancellation confirmation email. If you would like to receive this email on a different address, please enter the address below.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email address</label>
<input className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">(Confirm)</label>
<input className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm" value={email2} onChange={(e) => setEmail2(e.target.value)} placeholder="you@example.com" />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">If you have any other questions/comments/requests regarding your cancellation, please note them below and an Assist Solutions staff will contact you shortly.</label>
<textarea className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm" rows={4} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="If you have any questions or requests, note them here." />
</div>
</div>
{/* Validation messages for email fields */}
{emailProvided && !emailValid && (
<div className="text-xs text-red-600">Please enter a valid email address in both fields.</div>
)}
{emailProvided && emailValid && !emailsMatch && (
<div className="text-xs text-red-600">Email addresses do not match.</div>
)}
<div className="text-sm text-gray-700">
Your cancellation request is not confirmed yet. This is the final page. To finalize your cancellation request please proceed from REQUEST CANCELLATION below.
</div>
<div className="flex justify-between">
<button onClick={() => setStep(2)} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50">Back</button>
<button
onClick={() => {
if (window.confirm('Request cancellation now? This will schedule the cancellation for ' + (runDate || '') + '.')) {
void submit();
}
}}
disabled={loading || !runDate || !canProceedStep3}
className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50"
>
{loading ? 'Processing…' : 'Request Cancellation'}
</button>
</div>
</div>
)}
</div>
</div>
</DashboardLayout>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="bg-gray-50 rounded-lg border border-gray-200 p-3">
<div className="text-xs text-gray-500">{label}</div>
<div className="text-sm font-medium text-gray-900 break-words">{value}</div>
</div>
);
}
function Notice({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="text-sm font-semibold text-amber-900 mb-1">{title}</div>
<div className="text-sm text-amber-800">{children}</div>
</div>
);
}

View File

@ -222,7 +222,7 @@ export function SimActions({
try {
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
} catch {
// Fallback to inline confirm if router not available
// Fallback to inline confirmation modal if navigation is unavailable
setShowCancelConfirm(true);
}
}}

View File

@ -1,6 +1,7 @@
"use client";
import React from "react";
import { formatPlanShort } from "@/lib/plan";
import {
DevicePhoneMobileIcon,
WifiIcon,
@ -53,15 +54,7 @@ export function SimDetailsCard({
embedded = false,
showFeaturesSummary = true,
}: SimDetailsCardProps) {
const formatPlan = (code?: string) => {
const map: Record<string, string> = {
PASI_5G: "5GB Plan",
PASI_10G: "10GB Plan",
PASI_25G: "25GB Plan",
PASI_50G: "50GB Plan",
};
return (code && map[code]) || code || "—";
};
const formatPlan = (code?: string) => formatPlanShort(code);
const getStatusIcon = (status: string) => {
switch (status) {
case "active":
@ -265,9 +258,9 @@ export function SimDetailsCard({
</div>
<div>
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
<p className="text-sm text-gray-500">
{formatPlan(simDetails.planCode)} {`${simDetails.size} SIM`}
</p>
<p className="text-sm text-gray-500">
{formatPlan(simDetails.planCode)} {`${simDetails.size} SIM`}
</p>
</div>
</div>
<div className="flex items-center space-x-3">

View File

@ -0,0 +1,21 @@
// Generic plan code formatter for SIM plans
// Examples:
// - PASI_10G -> 10G
// - PASI_25G -> 25G
// - ANY_PREFIX_50GB -> 50G
// - Fallback: return the original code when unknown
export function formatPlanShort(planCode?: string): string {
if (!planCode) return "—";
const m = planCode.match(/(?:^|[_-])(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
if (m && m[1]) {
return `${m[1]}G`;
}
// Try extracting trailing number+G anywhere in the string
const m2 = planCode.match(/(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
if (m2 && m2[1]) {
return `${m2[1]}G`;
}
return planCode;
}