From ab429f91dc54448ee46a9dea1d0e62e12ce23b4d Mon Sep 17 00:00:00 2001
From: barsa
Date: Fri, 19 Dec 2025 15:15:36 +0900
Subject: [PATCH] Enhance Internet Eligibility Features and Update Catalog
Integration
- Added new fields for internet eligibility in the environment validation schema to support Salesforce integration.
- Updated CatalogCdcSubscriber to extract and handle additional eligibility details from Salesforce events.
- Refactored InternetEligibilityController to return detailed eligibility information, improving user experience.
- Enhanced CatalogCacheService with a new method for setting eligibility details, optimizing cache management.
- Updated InternetCatalogService to retrieve and process comprehensive eligibility data, ensuring accurate service availability checks.
- Improved public-facing components to reflect the new eligibility status and provide clearer user guidance during the checkout process.
---
apps/bff/src/core/config/env.validation.ts | 21 ++
.../events/catalog-cdc.subscriber.ts | 41 ++-
.../internet-eligibility.controller.ts | 10 +-
.../catalog/services/catalog-cache.service.ts | 13 +
.../services/internet-catalog.service.ts | 267 ++++++++++++---
.../services/order-validator.service.ts | 19 ++
.../verification/residence-card.service.ts | 309 +++++++++++++++---
.../verification/verification.module.ts | 6 +-
.../account/settings/verification/page.tsx | 5 +
.../auth/components/SignupForm/SignupForm.tsx | 14 +-
.../src/features/auth/hooks/use-auth.ts | 12 +-
.../catalog/services/catalog.service.ts | 15 +-
.../features/catalog/views/InternetPlans.tsx | 153 ++++++---
.../catalog/views/PublicInternetConfigure.tsx | 25 +-
.../catalog/views/PublicSimConfigure.tsx | 20 +-
.../components/AccountCheckoutContainer.tsx | 123 ++++++-
.../components/steps/AvailabilityStep.tsx | 21 +-
.../checkout/components/steps/PaymentStep.tsx | 19 ++
.../checkout/components/steps/ReviewStep.tsx | 29 +-
.../dashboard/hooks/useDashboardTasks.ts | 12 +-
.../dashboard/views/DashboardView.tsx | 11 +-
.../ResidenceCardVerificationSettingsView.tsx | 195 +++++++++++
.../eligibility-and-verification.md | 160 ++++++---
23 files changed, 1244 insertions(+), 256 deletions(-)
create mode 100644 apps/portal/src/app/account/settings/verification/page.tsx
create mode 100644 apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx
diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts
index abdb2964..a6969389 100644
--- a/apps/bff/src/core/config/env.validation.ts
+++ b/apps/bff/src/core/config/env.validation.ts
@@ -132,6 +132,27 @@ export const envSchema = z.object({
// Salesforce Field Mappings - Account
ACCOUNT_INTERNET_ELIGIBILITY_FIELD: z.string().default("Internet_Eligibility__c"),
+ ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD: z.string().default("Internet_Eligibility_Status__c"),
+ ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD: z
+ .string()
+ .default("Internet_Eligibility_Request_Date_Time__c"),
+ ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z
+ .string()
+ .default("Internet_Eligibility_Checked_Date_Time__c"),
+ ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD: z.string().default("Internet_Eligibility_Notes__c"),
+ ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD: z.string().default("Internet_Eligibility_Case_Id__c"),
+
+ ACCOUNT_ID_VERIFICATION_STATUS_FIELD: z.string().default("Id_Verification_Status__c"),
+ ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD: z
+ .string()
+ .default("Id_Verification_Submitted_Date_Time__c"),
+ ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD: z
+ .string()
+ .default("Id_Verification_Verified_Date_Time__c"),
+ ACCOUNT_ID_VERIFICATION_NOTE_FIELD: z.string().default("Id_Verification_Note__c"),
+ ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD: z
+ .string()
+ .default("Id_Verification_Rejection_Message__c"),
ACCOUNT_CUSTOMER_NUMBER_FIELD: z.string().default("SF_Account_No__c"),
// Salesforce Field Mappings - Product
diff --git a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts
index 8ba18a2b..99fdba30 100644
--- a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts
+++ b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts
@@ -269,10 +269,16 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
if (!this.isDataCallback(callbackType)) return;
const payload = this.extractPayload(data);
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]);
- const eligibility = this.extractStringField(payload, [
- "Internet_Eligibility__c",
- "InternetEligibility__c",
+ const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]);
+ const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]);
+ const requestedAt = this.extractStringField(payload, [
+ "Internet_Eligibility_Request_Date_Time__c",
]);
+ const checkedAt = this.extractStringField(payload, [
+ "Internet_Eligibility_Checked_Date_Time__c",
+ ]);
+ const notes = this.extractStringField(payload, ["Internet_Eligibility_Notes__c"]);
+ const requestId = this.extractStringField(payload, ["Internet_Eligibility_Case_Id__c"]);
if (!accountId) {
this.logger.warn("Account eligibility event missing AccountId", {
@@ -288,7 +294,19 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
});
await this.catalogCache.invalidateEligibility(accountId);
- await this.catalogCache.setEligibilityValue(accountId, eligibility ?? null);
+ const hasDetails = Boolean(
+ status || eligibility || requestedAt || checkedAt || notes || requestId
+ );
+ if (hasDetails) {
+ await this.catalogCache.setEligibilityDetails(accountId, {
+ status: this.mapEligibilityStatus(status, eligibility),
+ eligibility: eligibility ?? null,
+ requestId: requestId ?? null,
+ requestedAt: requestedAt ?? null,
+ checkedAt: checkedAt ?? null,
+ notes: notes ?? null,
+ });
+ }
// Notify connected portals immediately (multi-instance safe via Redis pub/sub)
this.realtime.publish(`account:sf:${accountId}`, "catalog.eligibility.changed", {
@@ -296,6 +314,21 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
});
}
+ private mapEligibilityStatus(
+ statusRaw: string | undefined,
+ eligibilityRaw: string | undefined
+ ): "not_requested" | "pending" | "eligible" | "ineligible" {
+ const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : "";
+ const eligibility = typeof eligibilityRaw === "string" ? eligibilityRaw.trim() : "";
+
+ if (normalizedStatus === "pending" || normalizedStatus === "checking") return "pending";
+ if (normalizedStatus === "eligible") return "eligible";
+ if (normalizedStatus === "ineligible" || normalizedStatus === "not available")
+ return "ineligible";
+ if (eligibility.length > 0) return "eligible";
+ return "not_requested";
+ }
+
private async invalidateAllCatalogs(): Promise {
try {
await this.catalogCache.invalidateAllCatalogs();
diff --git a/apps/bff/src/modules/catalog/internet-eligibility.controller.ts b/apps/bff/src/modules/catalog/internet-eligibility.controller.ts
index 03b35a9d..d20f4765 100644
--- a/apps/bff/src/modules/catalog/internet-eligibility.controller.ts
+++ b/apps/bff/src/modules/catalog/internet-eligibility.controller.ts
@@ -3,7 +3,10 @@ import { ZodValidationPipe } from "nestjs-zod";
import { z } from "zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
-import { InternetCatalogService } from "./services/internet-catalog.service.js";
+import {
+ InternetCatalogService,
+ type InternetEligibilityDto,
+} from "./services/internet-catalog.service.js";
import { addressSchema } from "@customer-portal/domain/customer";
const eligibilityRequestSchema = z.object({
@@ -30,9 +33,8 @@ export class InternetEligibilityController {
@Get("eligibility")
@RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap)
- async getEligibility(@Req() req: RequestWithUser): Promise<{ eligibility: string | null }> {
- const eligibility = await this.internetCatalog.getEligibilityForUser(req.user.id);
- return { eligibility };
+ async getEligibility(@Req() req: RequestWithUser): Promise {
+ return this.internetCatalog.getEligibilityDetailsForUser(req.user.id);
}
@Post("eligibility-request")
diff --git a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts
index 3ed8508c..7a6013a6 100644
--- a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts
+++ b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts
@@ -184,6 +184,19 @@ export class CatalogCacheService {
}
}
+ /**
+ * Set eligibility details payload for an account.
+ * Used by Salesforce Platform Events to push updates into the cache without re-querying Salesforce.
+ */
+ async setEligibilityDetails(accountId: string, payload: unknown): Promise {
+ const key = this.buildEligibilityKey("", accountId);
+ if (this.ELIGIBILITY_TTL === null) {
+ await this.cache.set(key, payload);
+ } else {
+ await this.cache.set(key, payload, this.ELIGIBILITY_TTL);
+ }
+ }
+
private async getOrSet(
bucket: "catalog" | "static" | "volatile" | "eligibility",
key: string,
diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts
index 17b01fd0..9f478d25 100644
--- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts
+++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, Inject } from "@nestjs/common";
+import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { BaseCatalogService } from "./base-catalog.service.js";
import { CatalogCacheService } from "./catalog-cache.service.js";
@@ -19,24 +19,31 @@ import { SalesforceConnection } from "@bff/integrations/salesforce/services/sale
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
-import { buildAccountEligibilityQuery } from "@bff/integrations/salesforce/utils/catalog-query-builder.js";
+import { assertSoqlFieldName } from "@bff/integrations/salesforce/utils/soql.util.js";
import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js";
+import type { SalesforceResponse } from "@customer-portal/domain/common";
-interface SalesforceAccount {
- Id: string;
- Internet_Eligibility__c?: string;
+export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible";
+
+export interface InternetEligibilityDto {
+ status: InternetEligibilityStatusDto;
+ eligibility: string | null;
+ requestId: string | null;
+ requestedAt: string | null;
+ checkedAt: string | null;
+ notes: string | null;
}
@Injectable()
export class InternetCatalogService extends BaseCatalogService {
constructor(
sf: SalesforceConnection,
- configService: ConfigService,
+ private readonly config: ConfigService,
@Inject(Logger) logger: Logger,
private mappingsService: MappingsService,
private catalogCache: CatalogCacheService
) {
- super(sf, configService, logger);
+ super(sf, config, logger);
}
async getPlans(): Promise {
@@ -173,21 +180,17 @@ export class InternetCatalogService extends BaseCatalogService {
// Get customer's eligibility from Salesforce
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
- const account = await this.catalogCache.getCachedEligibility(
+ const details = await this.catalogCache.getCachedEligibility(
eligibilityKey,
- async () => {
- const soql = buildAccountEligibilityQuery(sfAccountId);
- const accounts = await this.executeQuery(soql, "Customer Eligibility");
- return accounts.length > 0 ? (accounts[0] as unknown as SalesforceAccount) : null;
- }
+ async () => this.queryEligibilityDetails(sfAccountId)
);
- if (!account) {
+ if (!details) {
this.logger.warn(`No Salesforce account found for user ${userId}, returning all plans`);
return allPlans;
}
- const eligibility = account.Internet_Eligibility__c;
+ const eligibility = details.eligibility;
if (!eligibility) {
this.logger.log(`No eligibility field for user ${userId}, filtering to Home 1G plans only`);
@@ -220,23 +223,29 @@ export class InternetCatalogService extends BaseCatalogService {
}
async getEligibilityForUser(userId: string): Promise {
+ const details = await this.getEligibilityDetailsForUser(userId);
+ return details.eligibility;
+ }
+
+ async getEligibilityDetailsForUser(userId: string): Promise {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.sfAccountId) {
- return null;
+ return {
+ status: "not_requested",
+ eligibility: null,
+ requestId: null,
+ requestedAt: null,
+ checkedAt: null,
+ notes: null,
+ };
}
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
- const account = await this.catalogCache.getCachedEligibility(
+ return this.catalogCache.getCachedEligibility(
eligibilityKey,
- async () => {
- const soql = buildAccountEligibilityQuery(sfAccountId);
- const accounts = await this.executeQuery(soql, "Customer Eligibility");
- return accounts.length > 0 ? (accounts[0] as unknown as SalesforceAccount) : null;
- }
+ async () => this.queryEligibilityDetails(sfAccountId)
);
-
- return account?.Internet_Eligibility__c ?? null;
}
async requestEligibilityCheckForUser(
@@ -250,6 +259,15 @@ export class InternetCatalogService extends BaseCatalogService {
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
+ if (
+ !request.address ||
+ !request.address.address1 ||
+ !request.address.city ||
+ !request.address.postcode
+ ) {
+ throw new BadRequestException("Service address is required to request eligibility review.");
+ }
+
const subject = "Internet availability check request (Portal)";
const descriptionLines: string[] = [
"Portal internet availability check requested.",
@@ -264,31 +282,23 @@ export class InternetCatalogService extends BaseCatalogService {
`RequestedAt: ${new Date().toISOString()}`,
].filter(Boolean);
- const taskPayload: Record = {
- Subject: subject,
- Description: descriptionLines.join("\n"),
- WhatId: sfAccountId,
- };
-
try {
- const create = this.sf.sobject("Task")?.create;
- if (!create) {
- throw new Error("Salesforce Task create method not available");
- }
+ const requestId = await this.createEligibilityCaseOrTask(sfAccountId, {
+ subject,
+ description: descriptionLines.join("\n"),
+ });
- const result = await create(taskPayload);
- const id = (result as { id?: unknown })?.id;
- if (typeof id !== "string" || id.trim().length === 0) {
- throw new Error("Salesforce did not return a Task id");
- }
+ await this.updateAccountEligibilityRequestState(sfAccountId, requestId);
+
+ await this.catalogCache.invalidateEligibility(sfAccountId);
this.logger.log("Created Salesforce Task for internet eligibility request", {
userId,
sfAccountIdTail: sfAccountId.slice(-4),
- taskIdTail: id.slice(-4),
+ taskIdTail: requestId.slice(-4),
});
- return id;
+ return requestId;
} catch (error) {
this.logger.error("Failed to create Salesforce Task for internet eligibility request", {
userId,
@@ -304,6 +314,181 @@ export class InternetCatalogService extends BaseCatalogService {
// e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G"
return plan.internetOfferingType === eligibility;
}
+
+ private async queryEligibilityDetails(sfAccountId: string): Promise {
+ const eligibilityField = assertSoqlFieldName(
+ this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_FIELD") ?? "Internet_Eligibility__c",
+ "ACCOUNT_INTERNET_ELIGIBILITY_FIELD"
+ );
+ const statusField = assertSoqlFieldName(
+ this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
+ "Internet_Eligibility_Status__c",
+ "ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD"
+ );
+ const requestedAtField = assertSoqlFieldName(
+ this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ??
+ "Internet_Eligibility_Request_Date_Time__c",
+ "ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD"
+ );
+ const checkedAtField = assertSoqlFieldName(
+ this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD") ??
+ "Internet_Eligibility_Checked_Date_Time__c",
+ "ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD"
+ );
+ const notesField = assertSoqlFieldName(
+ this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD") ??
+ "Internet_Eligibility_Notes__c",
+ "ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD"
+ );
+ const caseIdField = assertSoqlFieldName(
+ this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ??
+ "Internet_Eligibility_Case_Id__c",
+ "ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD"
+ );
+
+ const soql = `
+ SELECT Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField}, ${notesField}, ${caseIdField}
+ FROM Account
+ WHERE Id = '${sfAccountId}'
+ LIMIT 1
+ `;
+
+ const res = (await this.sf.query(soql, {
+ label: "catalog:internet:eligibility_details",
+ })) as SalesforceResponse>;
+ const record = (res.records?.[0] as Record | undefined) ?? undefined;
+ if (!record) {
+ return {
+ status: "not_requested",
+ eligibility: null,
+ requestId: null,
+ requestedAt: null,
+ checkedAt: null,
+ notes: null,
+ };
+ }
+
+ const eligibilityRaw = record[eligibilityField];
+ const eligibility =
+ typeof eligibilityRaw === "string" && eligibilityRaw.trim().length > 0
+ ? eligibilityRaw.trim()
+ : null;
+
+ const statusRaw = record[statusField];
+ const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : "";
+
+ const status: InternetEligibilityStatusDto =
+ normalizedStatus === "pending" || normalizedStatus === "checking"
+ ? "pending"
+ : normalizedStatus === "eligible"
+ ? "eligible"
+ : normalizedStatus === "ineligible" || normalizedStatus === "not available"
+ ? "ineligible"
+ : eligibility
+ ? "eligible"
+ : "not_requested";
+
+ const requestIdRaw = record[caseIdField];
+ const requestId =
+ typeof requestIdRaw === "string" && requestIdRaw.trim() ? requestIdRaw.trim() : null;
+
+ const requestedAtRaw = record[requestedAtField];
+ const checkedAtRaw = record[checkedAtField];
+ const notesRaw = record[notesField];
+
+ const requestedAt =
+ typeof requestedAtRaw === "string"
+ ? requestedAtRaw
+ : requestedAtRaw instanceof Date
+ ? requestedAtRaw.toISOString()
+ : null;
+ const checkedAt =
+ typeof checkedAtRaw === "string"
+ ? checkedAtRaw
+ : checkedAtRaw instanceof Date
+ ? checkedAtRaw.toISOString()
+ : null;
+ const notes = typeof notesRaw === "string" && notesRaw.trim() ? notesRaw.trim() : null;
+
+ return { status, eligibility, requestId, requestedAt, checkedAt, notes };
+ }
+
+ private async createEligibilityCaseOrTask(
+ sfAccountId: string,
+ payload: { subject: string; description: string }
+ ): Promise {
+ const caseCreate = this.sf.sobject("Case")?.create;
+ if (caseCreate) {
+ try {
+ const result = await caseCreate({
+ Subject: payload.subject,
+ Description: payload.description,
+ Origin: "Portal",
+ AccountId: sfAccountId,
+ });
+ const id = (result as { id?: unknown })?.id;
+ if (typeof id === "string" && id.trim().length > 0) {
+ return id;
+ }
+ } catch (error) {
+ this.logger.warn(
+ "Failed to create Salesforce Case for eligibility request; falling back to Task",
+ {
+ sfAccountIdTail: sfAccountId.slice(-4),
+ error: getErrorMessage(error),
+ }
+ );
+ }
+ }
+
+ const taskCreate = this.sf.sobject("Task")?.create;
+ if (!taskCreate) {
+ throw new Error("Salesforce Case/Task create methods not available");
+ }
+ const result = await taskCreate({
+ Subject: payload.subject,
+ Description: payload.description,
+ WhatId: sfAccountId,
+ });
+ const id = (result as { id?: unknown })?.id;
+ if (typeof id !== "string" || id.trim().length === 0) {
+ throw new Error("Salesforce did not return a request id");
+ }
+ return id;
+ }
+
+ private async updateAccountEligibilityRequestState(
+ sfAccountId: string,
+ requestId: string
+ ): Promise {
+ const statusField = assertSoqlFieldName(
+ this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
+ "Internet_Eligibility_Status__c",
+ "ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD"
+ );
+ const requestedAtField = assertSoqlFieldName(
+ this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ??
+ "Internet_Eligibility_Request_Date_Time__c",
+ "ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD"
+ );
+ const caseIdField = assertSoqlFieldName(
+ this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ??
+ "Internet_Eligibility_Case_Id__c",
+ "ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD"
+ );
+
+ const update = this.sf.sobject("Account")?.update;
+ if (!update) {
+ throw new Error("Salesforce Account update method not available");
+ }
+
+ await update({
+ Id: sfAccountId,
+ [statusField]: "Pending",
+ [requestedAtField]: new Date().toISOString(),
+ [caseIdField]: requestId,
+ });
+ }
}
function formatAddressForLog(address: Record): string {
diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts
index 6c58372a..1b0c0255 100644
--- a/apps/bff/src/modules/orders/services/order-validator.service.ts
+++ b/apps/bff/src/modules/orders/services/order-validator.service.ts
@@ -14,6 +14,7 @@ import type { Providers } from "@customer-portal/domain/subscriptions";
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
+import { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service.js";
import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js";
import { PaymentValidatorService } from "./payment-validator.service.js";
import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js";
@@ -32,6 +33,7 @@ export class OrderValidator {
private readonly whmcs: WhmcsConnectionOrchestratorService,
private readonly pricebookService: OrderPricebookService,
private readonly simCatalogService: SimCatalogService,
+ private readonly internetCatalogService: InternetCatalogService,
private readonly paymentValidator: PaymentValidatorService,
private readonly residenceCards: ResidenceCardService
) {}
@@ -311,6 +313,23 @@ export class OrderValidator {
// 4. Order-specific business validation
if (businessValidatedBody.orderType === "Internet") {
+ const eligibility = await this.internetCatalogService.getEligibilityDetailsForUser(userId);
+ if (eligibility.status === "not_requested") {
+ throw new BadRequestException(
+ "Internet eligibility review is required before ordering. Please request an eligibility review from the Internet shop page and try again."
+ );
+ }
+ if (eligibility.status === "pending") {
+ throw new BadRequestException(
+ "Internet eligibility review is still in progress. Please wait for review to complete and try again."
+ );
+ }
+ if (eligibility.status === "ineligible") {
+ throw new BadRequestException(
+ "Internet service is not available for your address. Please contact support if you believe this is incorrect."
+ );
+ }
+
await this.validateInternetDuplication(userId, userMapping.whmcsClientId);
}
diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts
index 74a19b5d..3a826de7 100644
--- a/apps/bff/src/modules/verification/residence-card.service.ts
+++ b/apps/bff/src/modules/verification/residence-card.service.ts
@@ -1,6 +1,15 @@
-import { Injectable } from "@nestjs/common";
-import { PrismaService } from "@bff/infra/database/prisma.service.js";
-import { ResidenceCardStatus, type ResidenceCardSubmission } from "@prisma/client";
+import { Injectable, Inject } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { Logger } from "nestjs-pino";
+import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
+import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
+import {
+ assertSalesforceId,
+ assertSoqlFieldName,
+} from "@bff/integrations/salesforce/utils/soql.util.js";
+import type { SalesforceResponse } from "@customer-portal/domain/common";
+import { getErrorMessage } from "@bff/core/utils/error.util.js";
+import { basename, extname } from "node:path";
type ResidenceCardStatusDto = "not_submitted" | "pending" | "verified" | "rejected";
@@ -14,42 +23,106 @@ export interface ResidenceCardVerificationDto {
reviewerNotes: string | null;
}
-function mapStatus(status: ResidenceCardStatus): ResidenceCardStatusDto {
- if (status === ResidenceCardStatus.VERIFIED) return "verified";
- if (status === ResidenceCardStatus.REJECTED) return "rejected";
- return "pending";
-}
-
-function toDto(record: ResidenceCardSubmission | null): ResidenceCardVerificationDto {
- if (!record) {
- return {
- status: "not_submitted",
- filename: null,
- mimeType: null,
- sizeBytes: null,
- submittedAt: null,
- reviewedAt: null,
- reviewerNotes: null,
- };
- }
- return {
- status: mapStatus(record.status),
- filename: record.filename ?? null,
- mimeType: record.mimeType ?? null,
- sizeBytes: typeof record.sizeBytes === "number" ? record.sizeBytes : null,
- submittedAt: record.submittedAt ? record.submittedAt.toISOString() : null,
- reviewedAt: record.reviewedAt ? record.reviewedAt.toISOString() : null,
- reviewerNotes: record.reviewerNotes ?? null,
- };
+function mapFileTypeToMime(fileType?: string | null): string | null {
+ const normalized = String(fileType || "")
+ .trim()
+ .toLowerCase();
+ if (normalized === "pdf") return "application/pdf";
+ if (normalized === "png") return "image/png";
+ if (normalized === "jpg" || normalized === "jpeg") return "image/jpeg";
+ return null;
}
@Injectable()
export class ResidenceCardService {
- constructor(private readonly prisma: PrismaService) {}
+ constructor(
+ private readonly sf: SalesforceConnection,
+ private readonly mappings: MappingsService,
+ private readonly config: ConfigService,
+ @Inject(Logger) private readonly logger: Logger
+ ) {}
async getStatusForUser(userId: string): Promise {
- const record = await this.prisma.residenceCardSubmission.findUnique({ where: { userId } });
- return toDto(record);
+ const mapping = await this.mappings.findByUserId(userId);
+ const sfAccountId = mapping?.sfAccountId
+ ? assertSalesforceId(mapping.sfAccountId, "sfAccountId")
+ : null;
+ if (!sfAccountId) {
+ return {
+ status: "not_submitted",
+ filename: null,
+ mimeType: null,
+ sizeBytes: null,
+ submittedAt: null,
+ reviewedAt: null,
+ reviewerNotes: null,
+ };
+ }
+
+ const fields = this.getAccountFieldNames();
+ const soql = `
+ SELECT Id, ${fields.status}, ${fields.submittedAt}, ${fields.verifiedAt}, ${fields.note}, ${fields.rejectionMessage}
+ FROM Account
+ WHERE Id = '${sfAccountId}'
+ LIMIT 1
+ `;
+
+ const accountRes = (await this.sf.query(soql, {
+ label: "verification:residence_card:account",
+ })) as SalesforceResponse>;
+
+ const account = (accountRes.records?.[0] as Record | undefined) ?? undefined;
+ const statusRaw = account ? account[fields.status] : undefined;
+ const statusText = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : "";
+
+ const status: ResidenceCardStatusDto =
+ statusText === "verified"
+ ? "verified"
+ : statusText === "rejected"
+ ? "rejected"
+ : statusText === "submitted"
+ ? "pending"
+ : statusText === "not submitted" || statusText === "not_submitted" || statusText === ""
+ ? "not_submitted"
+ : "pending";
+
+ const submittedAtRaw = account ? account[fields.submittedAt] : undefined;
+ const verifiedAtRaw = account ? account[fields.verifiedAt] : undefined;
+ const noteRaw = account ? account[fields.note] : undefined;
+ const rejectionRaw = account ? account[fields.rejectionMessage] : undefined;
+
+ const submittedAt =
+ typeof submittedAtRaw === "string"
+ ? submittedAtRaw
+ : submittedAtRaw instanceof Date
+ ? submittedAtRaw.toISOString()
+ : null;
+ const reviewedAt =
+ typeof verifiedAtRaw === "string"
+ ? verifiedAtRaw
+ : verifiedAtRaw instanceof Date
+ ? verifiedAtRaw.toISOString()
+ : null;
+
+ const reviewerNotes =
+ typeof rejectionRaw === "string" && rejectionRaw.trim().length > 0
+ ? rejectionRaw.trim()
+ : typeof noteRaw === "string" && noteRaw.trim().length > 0
+ ? noteRaw.trim()
+ : null;
+
+ const fileMeta =
+ status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId);
+
+ return {
+ status,
+ filename: fileMeta?.filename ?? null,
+ mimeType: fileMeta?.mimeType ?? null,
+ sizeBytes: typeof fileMeta?.sizeBytes === "number" ? fileMeta.sizeBytes : null,
+ submittedAt: submittedAt ?? fileMeta?.submittedAt ?? null,
+ reviewedAt,
+ reviewerNotes,
+ };
}
async submitForUser(params: {
@@ -59,29 +132,157 @@ export class ResidenceCardService {
sizeBytes: number;
content: Uint8Array;
}): Promise {
- const record = await this.prisma.residenceCardSubmission.upsert({
- where: { userId: params.userId },
- create: {
+ const mapping = await this.mappings.findByUserId(params.userId);
+ if (!mapping?.sfAccountId) {
+ throw new Error("No Salesforce mapping found for current user");
+ }
+ const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
+
+ const fileBuffer = Buffer.from(params.content as unknown as Uint8Array);
+ const versionData = fileBuffer.toString("base64");
+ const extension = extname(params.filename || "").replace(/^\./, "");
+ const title = basename(params.filename || "residence-card", extension ? `.${extension}` : "");
+
+ const create = this.sf.sobject("ContentVersion")?.create;
+ if (!create) {
+ throw new Error("Salesforce ContentVersion create method not available");
+ }
+
+ try {
+ const result = await create({
+ Title: title || "residence-card",
+ PathOnClient: params.filename || "residence-card",
+ VersionData: versionData,
+ FirstPublishLocationId: sfAccountId,
+ });
+ const id = (result as { id?: unknown })?.id;
+ if (typeof id !== "string" || id.trim().length === 0) {
+ throw new Error("Salesforce did not return a ContentVersion id");
+ }
+ } catch (error) {
+ this.logger.error("Failed to upload residence card to Salesforce Files", {
userId: params.userId,
- status: ResidenceCardStatus.PENDING,
- filename: params.filename,
- mimeType: params.mimeType,
- sizeBytes: params.sizeBytes,
- content: params.content,
- submittedAt: new Date(),
- },
- update: {
- status: ResidenceCardStatus.PENDING,
- filename: params.filename,
- mimeType: params.mimeType,
- sizeBytes: params.sizeBytes,
- content: params.content,
- submittedAt: new Date(),
- reviewedAt: null,
- reviewerNotes: null,
- },
+ sfAccountIdTail: sfAccountId.slice(-4),
+ error: getErrorMessage(error),
+ });
+ throw new Error("Failed to submit residence card. Please try again later.");
+ }
+
+ const fields = this.getAccountFieldNames();
+ const update = this.sf.sobject("Account")?.update;
+ if (!update) {
+ throw new Error("Salesforce Account update method not available");
+ }
+
+ await update({
+ Id: sfAccountId,
+ [fields.status]: "Submitted",
+ [fields.submittedAt]: new Date().toISOString(),
+ [fields.rejectionMessage]: null,
+ [fields.note]: null,
});
- return toDto(record);
+ return this.getStatusForUser(params.userId);
+ }
+
+ private getAccountFieldNames(): {
+ status: string;
+ submittedAt: string;
+ verifiedAt: string;
+ note: string;
+ rejectionMessage: string;
+ } {
+ return {
+ status: assertSoqlFieldName(
+ this.config.get("ACCOUNT_ID_VERIFICATION_STATUS_FIELD") ??
+ "Id_Verification_Status__c",
+ "ACCOUNT_ID_VERIFICATION_STATUS_FIELD"
+ ),
+ submittedAt: assertSoqlFieldName(
+ this.config.get("ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD") ??
+ "Id_Verification_Submitted_Date_Time__c",
+ "ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD"
+ ),
+ verifiedAt: assertSoqlFieldName(
+ this.config.get("ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD") ??
+ "Id_Verification_Verified_Date_Time__c",
+ "ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD"
+ ),
+ note: assertSoqlFieldName(
+ this.config.get("ACCOUNT_ID_VERIFICATION_NOTE_FIELD") ?? "Id_Verification_Note__c",
+ "ACCOUNT_ID_VERIFICATION_NOTE_FIELD"
+ ),
+ rejectionMessage: assertSoqlFieldName(
+ this.config.get("ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD") ??
+ "Id_Verification_Rejection_Message__c",
+ "ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD"
+ ),
+ };
+ }
+
+ private async getLatestAccountFileMetadata(accountId: string): Promise<{
+ filename: string | null;
+ mimeType: string | null;
+ sizeBytes: number | null;
+ submittedAt: string | null;
+ } | null> {
+ try {
+ const linkSoql = `
+ SELECT ContentDocumentId
+ FROM ContentDocumentLink
+ WHERE LinkedEntityId = '${accountId}'
+ ORDER BY SystemModstamp DESC
+ LIMIT 1
+ `;
+ const linkRes = (await this.sf.query(linkSoql, {
+ label: "verification:residence_card:latest_link",
+ })) as SalesforceResponse<{ ContentDocumentId?: string }>;
+ const documentId = linkRes.records?.[0]?.ContentDocumentId;
+ if (!documentId) return null;
+
+ const versionSoql = `
+ SELECT Title, FileExtension, FileType, ContentSize, CreatedDate
+ FROM ContentVersion
+ WHERE ContentDocumentId = '${documentId}'
+ ORDER BY CreatedDate DESC
+ LIMIT 1
+ `;
+ const versionRes = (await this.sf.query(versionSoql, {
+ label: "verification:residence_card:latest_version",
+ })) as SalesforceResponse>;
+ const version = (versionRes.records?.[0] as Record | undefined) ?? undefined;
+ if (!version) return null;
+
+ const title = typeof version.Title === "string" ? version.Title.trim() : "";
+ const ext = typeof version.FileExtension === "string" ? version.FileExtension.trim() : "";
+ const fileType = typeof version.FileType === "string" ? version.FileType.trim() : "";
+ const sizeBytes = typeof version.ContentSize === "number" ? version.ContentSize : null;
+ const createdDateRaw = version.CreatedDate;
+ const submittedAt =
+ typeof createdDateRaw === "string"
+ ? createdDateRaw
+ : createdDateRaw instanceof Date
+ ? createdDateRaw.toISOString()
+ : null;
+
+ const filename = title
+ ? ext && !title.toLowerCase().endsWith(`.${ext.toLowerCase()}`)
+ ? `${title}.${ext}`
+ : title
+ : null;
+
+ return {
+ filename,
+ mimeType: mapFileTypeToMime(fileType) ?? mapFileTypeToMime(ext) ?? null,
+ sizeBytes,
+ submittedAt,
+ };
+ } catch (error) {
+ this.logger.warn("Failed to load residence card file metadata from Salesforce", {
+ accountIdTail: accountId.slice(-4),
+ error: getErrorMessage(error),
+ });
+ return null;
+ }
}
}
diff --git a/apps/bff/src/modules/verification/verification.module.ts b/apps/bff/src/modules/verification/verification.module.ts
index ac3e871d..7b704b31 100644
--- a/apps/bff/src/modules/verification/verification.module.ts
+++ b/apps/bff/src/modules/verification/verification.module.ts
@@ -1,10 +1,12 @@
import { Module } from "@nestjs/common";
-import { PrismaModule } from "@bff/infra/database/prisma.module.js";
import { ResidenceCardController } from "./residence-card.controller.js";
import { ResidenceCardService } from "./residence-card.service.js";
+import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
+import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
+import { CoreConfigModule } from "@bff/core/config/config.module.js";
@Module({
- imports: [PrismaModule],
+ imports: [IntegrationsModule, MappingsModule, CoreConfigModule],
controllers: [ResidenceCardController],
providers: [ResidenceCardService],
exports: [ResidenceCardService],
diff --git a/apps/portal/src/app/account/settings/verification/page.tsx b/apps/portal/src/app/account/settings/verification/page.tsx
new file mode 100644
index 00000000..ae82dfac
--- /dev/null
+++ b/apps/portal/src/app/account/settings/verification/page.tsx
@@ -0,0 +1,5 @@
+import { ResidenceCardVerificationSettingsView } from "@/features/verification/views/ResidenceCardVerificationSettingsView";
+
+export default function AccountResidenceCardVerificationPage() {
+ return ;
+}
diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx
index dd1f005b..d0879ec6 100644
--- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx
+++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx
@@ -9,7 +9,7 @@ import { useState, useCallback } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { ErrorMessage } from "@/components/atoms";
-import { useSignup } from "../../hooks/use-auth";
+import { useSignupWithRedirect } from "../../hooks/use-auth";
import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/auth";
import { addressFormSchema } from "@customer-portal/domain/customer";
import { useZodForm } from "@/hooks/useZodForm";
@@ -50,6 +50,7 @@ interface SignupFormProps {
onSuccess?: () => void;
onError?: (error: string) => void;
className?: string;
+ redirectTo?: string;
}
const STEPS = [
@@ -60,8 +61,8 @@ const STEPS = [
},
{
key: "address",
- title: "Delivery Address",
- description: "Where to ship your SIM card",
+ title: "Address",
+ description: "Used for service eligibility and delivery",
},
{
key: "password",
@@ -114,11 +115,12 @@ const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAn
}),
};
-export function SignupForm({ onSuccess, onError, className = "" }: SignupFormProps) {
+export function SignupForm({ onSuccess, onError, className = "", redirectTo }: SignupFormProps) {
const searchParams = useSearchParams();
- const { signup, loading, error, clearError } = useSignup();
+ const { signup, loading, error, clearError } = useSignupWithRedirect({ redirectTo });
const [step, setStep] = useState(0);
- const redirect = searchParams?.get("next") || searchParams?.get("redirect");
+ const redirectFromQuery = searchParams?.get("next") || searchParams?.get("redirect");
+ const redirect = redirectTo || redirectFromQuery;
const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : "";
const form = useZodForm({
diff --git a/apps/portal/src/features/auth/hooks/use-auth.ts b/apps/portal/src/features/auth/hooks/use-auth.ts
index cc1ff97b..07782dcf 100644
--- a/apps/portal/src/features/auth/hooks/use-auth.ts
+++ b/apps/portal/src/features/auth/hooks/use-auth.ts
@@ -53,10 +53,10 @@ export function useAuth() {
// Enhanced signup with redirect handling
const signup = useCallback(
- async (data: SignupRequest) => {
+ async (data: SignupRequest, options?: { redirectTo?: string }) => {
await signupAction(data);
- const redirectTo = getPostLoginRedirect(searchParams);
- router.push(redirectTo);
+ const dest = options?.redirectTo ?? getPostLoginRedirect(searchParams);
+ router.push(dest);
},
[signupAction, router, searchParams]
);
@@ -115,10 +115,14 @@ export function useLogin() {
* Hook for signup functionality
*/
export function useSignup() {
+ return useSignupWithRedirect();
+}
+
+export function useSignupWithRedirect(options?: { redirectTo?: string }) {
const { signup, loading, error, clearError } = useAuth();
return {
- signup,
+ signup: (data: SignupRequest) => signup(data, options),
loading,
error,
clearError,
diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts
index 0952db05..1c662894 100644
--- a/apps/portal/src/features/catalog/services/catalog.service.ts
+++ b/apps/portal/src/features/catalog/services/catalog.service.ts
@@ -18,6 +18,17 @@ import {
} from "@customer-portal/domain/catalog";
import type { Address } from "@customer-portal/domain/customer";
+export type InternetEligibilityStatus = "not_requested" | "pending" | "eligible" | "ineligible";
+
+export interface InternetEligibilityDetails {
+ status: InternetEligibilityStatus;
+ eligibility: string | null;
+ requestId: string | null;
+ requestedAt: string | null;
+ checkedAt: string | null;
+ notes: string | null;
+}
+
export const catalogService = {
async getInternetCatalog(): Promise {
const response = await apiClient.GET("/api/catalog/internet/plans");
@@ -76,8 +87,8 @@ export const catalogService = {
return vpnCatalogProductSchema.array().parse(data);
},
- async getInternetEligibility(): Promise<{ eligibility: string | null }> {
- const response = await apiClient.GET<{ eligibility: string | null }>(
+ async getInternetEligibility(): Promise {
+ const response = await apiClient.GET(
"/api/catalog/internet/eligibility"
);
return getDataOrThrow(response, "Failed to load internet eligibility");
diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx
index bf60f36b..e9617d7d 100644
--- a/apps/portal/src/features/catalog/views/InternetPlans.tsx
+++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useMemo } from "react";
+import { useMemo } from "react";
import { PageLayout } from "@/components/templates/PageLayout";
import { WifiIcon, ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline";
import { useInternetCatalog } from "@/features/catalog/hooks";
@@ -35,7 +35,6 @@ export function InternetPlansContainer() {
() => data?.installations ?? [],
[data?.installations]
);
- const [eligibility, setEligibility] = useState("");
const { data: activeSubs } = useActiveSubscriptions();
const hasActiveInternet = useMemo(
() =>
@@ -52,32 +51,38 @@ export function InternetPlansContainer() {
);
const eligibilityValue = eligibilityQuery.data?.eligibility;
- const requiresAvailabilityCheck = eligibilityQuery.isSuccess && eligibilityValue === null;
+ const eligibilityStatus = eligibilityQuery.data?.status;
+ const requestedAt = eligibilityQuery.data?.requestedAt;
+ const rejectionNotes = eligibilityQuery.data?.notes;
+
+ const isEligible =
+ eligibilityStatus === "eligible" &&
+ typeof eligibilityValue === "string" &&
+ eligibilityValue.trim().length > 0;
+ const isPending = eligibilityStatus === "pending";
+ const isNotRequested = eligibilityStatus === "not_requested";
+ const isIneligible = eligibilityStatus === "ineligible";
+ const orderingLocked = isPending || isNotRequested || isIneligible;
const hasServiceAddress = Boolean(
user?.address?.address1 &&
user?.address?.city &&
user?.address?.postcode &&
(user?.address?.country || user?.address?.countryCode)
);
+ const addressLabel = useMemo(() => {
+ const a = user?.address;
+ if (!a) return "";
+ return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode]
+ .filter(Boolean)
+ .map(part => String(part).trim())
+ .filter(part => part.length > 0)
+ .join(", ");
+ }, [user?.address]);
- useEffect(() => {
- if (!user?.id) return;
- if (eligibilityValue !== null) return;
- const key = `cp:internet-eligibility:last:${user.id}`;
- if (!localStorage.getItem(key)) {
- localStorage.setItem(key, "PENDING");
- }
- }, [eligibilityValue, user?.id]);
-
- useEffect(() => {
- if (eligibilityQuery.isSuccess) {
- if (typeof eligibilityValue === "string" && eligibilityValue.trim().length > 0) {
- setEligibility(eligibilityValue);
- return;
- }
- setEligibility("");
- }
- }, [eligibilityQuery.isSuccess, eligibilityValue]);
+ const eligibility = useMemo(() => {
+ if (!isEligible) return "";
+ return eligibilityValue.trim();
+ }, [eligibilityValue, isEligible]);
const getEligibilityIcon = (offeringType?: string) => {
const lower = (offeringType || "").toLowerCase();
@@ -172,7 +177,17 @@ export function InternetPlansContainer() {
We’re verifying whether our service is available at your residence.
- ) : requiresAvailabilityCheck ? (
+ ) : isNotRequested ? (
+
+
+ Availability review required
+
+
+ Request an eligibility review to unlock personalized internet plans for your
+ residence.
+
+
+ ) : isPending ? (
Availability review in progress
@@ -182,6 +197,15 @@ export function InternetPlansContainer() {
your personalized internet plans.
+ ) : isIneligible ? (
+
+
+ Not available for this address
+
+
+ Our team reviewed your address and determined service isn’t available right now.
+
+
) : eligibility ? (
- {requiresAvailabilityCheck && (
-
+ {isNotRequested && (
+
Our team will verify NTT serviceability and update your eligible offerings. We’ll
- notify you on your dashboard when review is complete.
+ notify you when review is complete.
{hasServiceAddress ? (
- eligibilityRequest.mutate({
- address: user?.address ?? undefined,
- })
+ void (async () => {
+ const confirmed =
+ typeof window === "undefined" ||
+ window.confirm(
+ `Request an eligibility review for this address?\n\n${addressLabel}`
+ );
+ if (!confirmed) return;
+ eligibilityRequest.mutate({ address: user?.address ?? undefined });
+ })()
}
className="sm:ml-auto whitespace-nowrap"
>
@@ -238,6 +264,38 @@ export function InternetPlansContainer() {
)}
+ {isPending && (
+
+
+
+ We’ll notify you when review is complete.
+
+ {requestedAt ? (
+
+ Requested: {new Date(requestedAt).toLocaleString()}
+
+ ) : null}
+
+
+ )}
+
+ {isIneligible && (
+
+
+ {rejectionNotes ? (
+
{rejectionNotes}
+ ) : (
+
+ If you believe this is incorrect, contact support and we’ll take another look.
+
+ )}
+
+ Contact support
+
+
+
+ )}
+
{hasActiveInternet && (
0 ? (
<>
- {requiresAvailabilityCheck && (
+ {orderingLocked && (
- You can browse standard pricing below, but ordering stays locked until we confirm
- service availability for your residence.
+ {isIneligible
+ ? "Service is not available for your address."
+ : isNotRequested
+ ? "Request an eligibility review to unlock ordering for your residence."
+ : "You can browse standard pricing below, but ordering stays locked until we confirm service availability for your residence."}
)}
- {(requiresAvailabilityCheck ? silverPlans : plans).map(plan => (
+ {(orderingLocked ? silverPlans : plans).map(plan => (
diff --git a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx
index 4f29f16b..35fb72d5 100644
--- a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx
+++ b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx
@@ -5,6 +5,7 @@ import { Button } from "@/components/atoms/button";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
+import { SignupForm } from "@/features/auth/components/SignupForm/SignupForm";
/**
* Public Internet Configure View
@@ -20,32 +21,16 @@ export function PublicInternetConfigureView() {
-
+
-
- Internet plans depend on your residence and local infrastructure. Create an account so
- we can review availability and unlock ordering.
-
-
- Create account
-
Sign in
@@ -53,6 +38,10 @@ export function PublicInternetConfigureView() {
+
+
+
+
);
}
diff --git a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx
index fb3da05c..1ad04e29 100644
--- a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx
+++ b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx
@@ -6,6 +6,7 @@ import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackL
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { useSearchParams } from "next/navigation";
+import { SignupForm } from "@/features/auth/components/SignupForm/SignupForm";
/**
* Public SIM Configure View
@@ -24,27 +25,16 @@ export function PublicSimConfigureView() {
-
+
-
- Create an account to add your payment method and submit your residence card for review.
-
-
- Create account
-
Sign in
@@ -52,6 +42,10 @@ export function PublicSimConfigureView() {
+
+
+
+
);
}
diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx
index c17f2af1..5e3e2567 100644
--- a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx
+++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx
@@ -18,21 +18,21 @@ import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants";
import { useInternetEligibility } from "@/features/catalog/hooks/useInternetEligibility";
+import { useRequestInternetEligibilityCheck } from "@/features/catalog/hooks/useInternetEligibility";
import {
useResidenceCardVerification,
useSubmitResidenceCard,
} from "@/features/verification/hooks/useResidenceCardVerification";
+import { useAuthSession } from "@/features/auth/services/auth.store";
import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders";
import type { PaymentMethod } from "@customer-portal/domain/payments";
-const isNonEmptyString = (value: unknown): value is string =>
- typeof value === "string" && value.trim().length > 0;
-
export function AccountCheckoutContainer() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
+ const { user } = useAuthSession();
const { cartItem, checkoutSessionId, clear } = useCheckoutStore();
@@ -94,12 +94,42 @@ export function AccountCheckoutContainer() {
const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder });
const eligibilityValue = eligibilityQuery.data?.eligibility;
+ const eligibilityStatus = eligibilityQuery.data?.status;
+ const eligibilityRequestedAt = eligibilityQuery.data?.requestedAt;
+ const eligibilityNotes = eligibilityQuery.data?.notes;
+ const eligibilityRequest = useRequestInternetEligibilityCheck();
const eligibilityLoading = Boolean(isInternetOrder && eligibilityQuery.isLoading);
+ const eligibilityNotRequested = Boolean(
+ isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "not_requested"
+ );
const eligibilityPending = Boolean(
- isInternetOrder && eligibilityQuery.isSuccess && eligibilityValue === null
+ isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "pending"
+ );
+ const eligibilityIneligible = Boolean(
+ isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "ineligible"
);
const eligibilityError = Boolean(isInternetOrder && eligibilityQuery.isError);
- const isEligible = !isInternetOrder || isNonEmptyString(eligibilityValue);
+ const isEligible =
+ !isInternetOrder ||
+ (eligibilityStatus === "eligible" &&
+ typeof eligibilityValue === "string" &&
+ eligibilityValue.trim().length > 0);
+
+ const hasServiceAddress = Boolean(
+ user?.address?.address1 &&
+ user?.address?.city &&
+ user?.address?.postcode &&
+ (user?.address?.country || user?.address?.countryCode)
+ );
+ const addressLabel = useMemo(() => {
+ const a = user?.address;
+ if (!a) return "";
+ return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode]
+ .filter(Boolean)
+ .map(part => String(part).trim())
+ .filter(part => part.length > 0)
+ .join(", ");
+ }, [user?.address]);
const residenceCardQuery = useResidenceCardVerification({ enabled: isSimOrder });
const submitResidenceCard = useSubmitResidenceCard();
@@ -155,11 +185,21 @@ export function AccountCheckoutContainer() {
clear();
router.push(`/account/orders/${encodeURIComponent(result.sfOrderId)}?status=success`);
} catch (error) {
- setSubmitError(error instanceof Error ? error.message : "Order submission failed");
+ const message = error instanceof Error ? error.message : "Order submission failed";
+ if (
+ isSimOrder &&
+ (message.toLowerCase().includes("residence card submission required") ||
+ message.toLowerCase().includes("residence card submission was rejected"))
+ ) {
+ const next = `${pathname}${searchParams?.toString() ? `?${searchParams.toString()}` : ""}`;
+ router.push(`/account/settings/verification?returnTo=${encodeURIComponent(next)}`);
+ return;
+ }
+ setSubmitError(message);
} finally {
setSubmitting(false);
}
- }, [checkoutSessionId, clear, router]);
+ }, [checkoutSessionId, clear, isSimOrder, pathname, router, searchParams]);
if (!cartItem || !orderType) {
const shopHref = pathname.startsWith("/account") ? "/account/shop" : "/shop";
@@ -228,6 +268,65 @@ export function AccountCheckoutContainer() {
+ ) : eligibilityNotRequested ? (
+
+
+
+ Request an eligibility review to confirm service availability for your address
+ before submitting an internet order.
+
+ {hasServiceAddress ? (
+
+ void (async () => {
+ const confirmed =
+ typeof window === "undefined" ||
+ window.confirm(
+ `Request an eligibility review for this address?\n\n${addressLabel}`
+ );
+ if (!confirmed) return;
+ eligibilityRequest.mutate({
+ address: user?.address ?? undefined,
+ notes: cartItem?.planSku
+ ? `Requested during checkout. Selected plan SKU: ${cartItem.planSku}`
+ : "Requested during checkout.",
+ });
+ })()
+ }
+ >
+ Request review
+
+ ) : (
+
+ Add address
+
+ )}
+
+
+ ) : eligibilityIneligible ? (
+
+
+
+ Our team reviewed your address and determined service isn’t available right now.
+
+ {eligibilityNotes ? (
+
{eligibilityNotes}
+ ) : eligibilityRequestedAt ? (
+
+ Last updated: {new Date(eligibilityRequestedAt).toLocaleString()}
+
+ ) : null}
+
+ Contact support
+
+
+
) : null}
@@ -598,7 +697,7 @@ export function AccountCheckoutContainer() {
variant={residenceStatus === "rejected" ? "warning" : "info"}
title={
residenceStatus === "rejected"
- ? "Residence card needs resubmission"
+ ? "ID verification rejected"
: "Submit your residence card"
}
size="sm"
@@ -607,9 +706,13 @@ export function AccountCheckoutContainer() {
{residenceStatus === "rejected" && residenceCardQuery.data?.reviewerNotes ? (
-
Reviewer note
+
Rejection note
{residenceCardQuery.data.reviewerNotes}
+ ) : residenceStatus === "rejected" ? (
+
+ Your document couldn’t be approved. Please upload a new file to continue.
+
) : null}
Upload a JPG, PNG, or PDF (max 5MB). We’ll verify it before activating SIM
@@ -761,6 +864,8 @@ export function AccountCheckoutContainer() {
!isEligible ||
eligibilityLoading ||
eligibilityPending ||
+ eligibilityNotRequested ||
+ eligibilityIneligible ||
eligibilityError
}
isLoading={submitting}
diff --git a/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx b/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx
index 0e835ba7..0d72fb97 100644
--- a/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx
+++ b/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx
@@ -39,7 +39,14 @@ export function AvailabilityStep() {
enabled: canCheckEligibility && isInternetOrder,
});
const eligibilityValue = eligibilityQuery.data?.eligibility ?? null;
- const isEligible = useMemo(() => isNonEmptyString(eligibilityValue), [eligibilityValue]);
+ const eligibilityStatus = eligibilityQuery.data?.status;
+ const isEligible = useMemo(
+ () => eligibilityStatus === "eligible" && isNonEmptyString(eligibilityValue),
+ [eligibilityStatus, eligibilityValue]
+ );
+ const isPending = eligibilityStatus === "pending";
+ const isNotRequested = eligibilityStatus === "not_requested";
+ const isIneligible = eligibilityStatus === "ineligible";
const availabilityRequest = useRequestInternetEligibilityCheck();
const [requestError, setRequestError] = useState(null);
@@ -114,6 +121,16 @@ export function AvailabilityStep() {
Your account is eligible for: {eligibilityValue}
+ ) : isIneligible ? (
+
+ Our team reviewed your address and determined service isn’t available right now. Contact
+ support if you believe this is incorrect.
+
+ ) : isPending ? (
+
+ We’re reviewing service availability for your address. Once confirmed, you can return
+ and complete checkout.
+
) : (
}
className="w-full"
>
- Request availability check
+ {isNotRequested ? "Request availability check" : "Request review again"}
)}
diff --git a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx
index 840a97ad..d7090a5c 100644
--- a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx
+++ b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
+import { usePathname, useRouter } from "next/navigation";
import { useCheckoutStore } from "../../stores/checkout.store";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { Button } from "@/components/atoms/button";
@@ -27,6 +28,8 @@ import {
* Opens WHMCS SSO to add payment method and polls for completion.
*/
export function PaymentStep() {
+ const router = useRouter();
+ const pathname = usePathname();
const { isAuthenticated } = useAuthSession();
const {
cartItem,
@@ -281,6 +284,22 @@ export function PaymentStep() {
Upload a JPG, PNG, or PDF (max 5MB). We’ll review it and notify you when it’s
approved.
+ {pathname.startsWith("/account") ? (
+
{
+ const current = `${pathname}${window.location.search ?? ""}`;
+ router.push(
+ `/account/settings/verification?returnTo=${encodeURIComponent(current)}`
+ );
+ }}
+ >
+ Open ID verification page
+
+ ) : null}
Go to Payment step
+ {pathname.startsWith("/account") ? (
+ {
+ const current = `${pathname}${window.location.search ?? ""}`;
+ router.push(
+ `/account/settings/verification?returnTo=${encodeURIComponent(current)}`
+ );
+ }}
+ className="whitespace-nowrap"
+ >
+ Manage ID verification
+
+ ) : null}
)}
diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts
index 6c09aac6..e079a781 100644
--- a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts
+++ b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts
@@ -58,7 +58,7 @@ interface ComputeTasksParams {
summary: DashboardSummary | undefined;
paymentMethods: PaymentMethodList | undefined;
orders: OrderSummary[] | undefined;
- internetEligibility: string | null | undefined;
+ internetEligibilityStatus: "not_requested" | "pending" | "eligible" | "ineligible" | undefined;
formatCurrency: (amount: number, options?: { currency?: string }) => string;
}
@@ -69,7 +69,7 @@ function computeTasks({
summary,
paymentMethods,
orders,
- internetEligibility,
+ internetEligibilityStatus,
formatCurrency,
}: ComputeTasksParams): DashboardTask[] {
const tasks: DashboardTask[] = [];
@@ -152,8 +152,8 @@ function computeTasks({
}
}
- // Priority 4: Internet eligibility review (only when value is explicitly null)
- if (internetEligibility === null) {
+ // Priority 4: Internet eligibility review (only when explicitly pending)
+ if (internetEligibilityStatus === "pending") {
tasks.push({
id: "internet-eligibility-review",
priority: 4,
@@ -225,10 +225,10 @@ export function useDashboardTasks(): UseDashboardTasksResult {
summary,
paymentMethods,
orders,
- internetEligibility: eligibility?.eligibility,
+ internetEligibilityStatus: eligibility?.status,
formatCurrency,
}),
- [summary, paymentMethods, orders, eligibility?.eligibility, formatCurrency]
+ [summary, paymentMethods, orders, eligibility?.status, formatCurrency]
);
return {
diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx
index eefb193f..332e6ca0 100644
--- a/apps/portal/src/features/dashboard/views/DashboardView.tsx
+++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx
@@ -35,18 +35,19 @@ export function DashboardView() {
useEffect(() => {
if (!isAuthenticated || !user?.id) return;
- const current = eligibility?.eligibility;
- if (current === undefined) return; // query not ready yet
+ const status = eligibility?.status;
+ if (!status) return; // query not ready yet
const key = `cp:internet-eligibility:last:${user.id}`;
const last = localStorage.getItem(key);
- if (current === null) {
+ if (status === "pending") {
localStorage.setItem(key, "PENDING");
return;
}
- if (typeof current === "string" && current.trim().length > 0) {
+ if (status === "eligible" && typeof eligibility?.eligibility === "string") {
+ const current = eligibility.eligibility.trim();
if (last === "PENDING") {
setEligibilityToast({
visible: true,
@@ -61,7 +62,7 @@ export function DashboardView() {
}
localStorage.setItem(key, current);
}
- }, [eligibility?.eligibility, isAuthenticated, user?.id]);
+ }, [eligibility?.eligibility, eligibility?.status, isAuthenticated, user?.id]);
useEffect(() => {
return () => {
diff --git a/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx b/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx
new file mode 100644
index 00000000..c58ae699
--- /dev/null
+++ b/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { useMemo, useRef, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { PageLayout } from "@/components/templates/PageLayout";
+import { ShieldCheckIcon } from "@heroicons/react/24/outline";
+import { SubCard } from "@/components/molecules/SubCard/SubCard";
+import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
+import { Button } from "@/components/atoms/button";
+import { StatusPill } from "@/components/atoms/status-pill";
+import {
+ useResidenceCardVerification,
+ useSubmitResidenceCard,
+} from "@/features/verification/hooks/useResidenceCardVerification";
+
+function formatDateTime(iso?: string | null): string | null {
+ if (!iso) return null;
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return null;
+ return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
+ date
+ );
+}
+
+export function ResidenceCardVerificationSettingsView() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const returnTo = searchParams?.get("returnTo")?.trim() || "";
+
+ const residenceCardQuery = useResidenceCardVerification({ enabled: true });
+ const submitResidenceCard = useSubmitResidenceCard();
+
+ const [residenceFile, setResidenceFile] = useState
(null);
+ const residenceFileInputRef = useRef(null);
+
+ const status = residenceCardQuery.data?.status;
+ const statusPill = useMemo(() => {
+ if (status === "verified") return ;
+ if (status === "pending") return ;
+ if (status === "rejected") return ;
+ return ;
+ }, [status]);
+
+ const canUpload = status !== "verified";
+
+ return (
+ }
+ >
+
+
}
+ right={statusPill}
+ >
+ {residenceCardQuery.isLoading ? (
+
Checking verification status…
+ ) : residenceCardQuery.isError ? (
+
+
+ void residenceCardQuery.refetch()}>
+ Try again
+
+
+
+ ) : status === "verified" ? (
+
+ Your residence card is on file and approved. No further action is required.
+
+ ) : status === "pending" ? (
+
+
+ We’ll verify your residence card before activating SIM service.
+
+
+ {residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
+
+
+ Submitted document
+
+ {residenceCardQuery.data?.filename ? (
+
+ {residenceCardQuery.data.filename}
+
+ ) : null}
+ {formatDateTime(residenceCardQuery.data?.submittedAt) ? (
+
+ Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
+
+ ) : null}
+
+ ) : null}
+
+ ) : (
+
+
+
+ {status === "rejected" && residenceCardQuery.data?.reviewerNotes ? (
+
+
Rejection note
+
{residenceCardQuery.data.reviewerNotes}
+
+ ) : null}
+
Upload a clear photo/scan of your residence card (JPG, PNG, or PDF).
+
+
+
+ {canUpload ? (
+
+
setResidenceFile(e.target.files?.[0] ?? null)}
+ className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80"
+ />
+
+ {residenceFile ? (
+
+
+
+ Selected file
+
+
+ {residenceFile.name}
+
+
+
{
+ setResidenceFile(null);
+ if (residenceFileInputRef.current) {
+ residenceFileInputRef.current.value = "";
+ }
+ }}
+ >
+ Change
+
+
+ ) : null}
+
+
+ {returnTo ? (
+ router.push(returnTo)}>
+ Back to checkout
+
+ ) : null}
+ {
+ if (!residenceFile) return;
+ submitResidenceCard.mutate(residenceFile, {
+ onSuccess: () => {
+ setResidenceFile(null);
+ if (residenceFileInputRef.current) {
+ residenceFileInputRef.current.value = "";
+ }
+ },
+ });
+ }}
+ >
+ Submit
+
+
+
+ {submitResidenceCard.isError && (
+
+ {submitResidenceCard.error instanceof Error
+ ? submitResidenceCard.error.message
+ : "Failed to submit residence card."}
+
+ )}
+
+ ) : null}
+
+ )}
+
+
+
+ );
+}
+
+export default ResidenceCardVerificationSettingsView;
diff --git a/docs/portal-guides/eligibility-and-verification.md b/docs/portal-guides/eligibility-and-verification.md
index 390b0531..457bcdc5 100644
--- a/docs/portal-guides/eligibility-and-verification.md
+++ b/docs/portal-guides/eligibility-and-verification.md
@@ -31,23 +31,29 @@ It also explains how these checks gate checkout and where the portal should disp
- Show current eligibility status (e.g. “Checking”, “Eligible for …”, “Not available”, “Action needed”).
- If not requested yet: show a single CTA (“Request eligibility review”).
- In checkout (Internet orders):
- - If eligibility is **PENDING/REQUIRED**, the submit CTA is disabled and we guide the user to the next action.
- - If **ELIGIBLE**, proceed normally.
+ - If eligibility is **Pending/Not Requested**, the submit CTA is disabled and we guide the user to the next action.
+ - If **Eligible**, proceed normally.
### Target Salesforce Model
**Account fields (canonical, cached by portal):**
-- `InternetEligibilityStatus__c` (picklist)
+- `Internet Eligibility Status` (picklist)
- Suggested values:
- - `REQUIRED` (no check requested / missing address)
- - `PENDING` (case open, awaiting review)
- - `ELIGIBLE` (approved)
- - `INELIGIBLE` (rejected)
-- `InternetEligibilityResult__c` (text/picklist; optional)
+ - `Not Requested` (no check requested yet; address may be missing or unconfirmed)
+ - `Pending` (case open, awaiting review)
+ - `Eligible` (approved)
+ - `Ineligible` (rejected)
+- `Internet Eligibility` (text/picklist; optional result)
- Example: `Home`, `Apartment`, or a more structured code that maps to portal offerings.
-- `InternetEligibilityCheckedAt__c` (datetime; optional)
-- `InternetEligibilityNotes__c` (long text; optional)
+- Recommended supporting fields (optional but strongly recommended):
+ - `Internet Eligibility Request Date Time` (datetime)
+ - Set by the portal/BFF when the customer requests an eligibility check.
+ - Useful for UX (“Requested on …”) and for internal SLAs/reporting.
+ - `Internet Eligibility Checked Date Time` (datetime)
+ - Updated by Salesforce automation when the review completes (approved or rejected).
+ - `Internet Eligibility_Notes` (long text)
+ - `Internet Eligibility_Case_Id` (text / lookup, if you want fast linking to the case from the portal)
**Case (workflow + audit trail):**
@@ -62,46 +68,63 @@ It also explains how these checks gate checkout and where the portal should disp
2. BFF validates:
- account has a service address (or includes the address in request)
- throttling/rate limits
-3. BFF creates Salesforce Case and sets `InternetEligibilityStatus__c = PENDING`.
+3. BFF creates Salesforce Case and sets `Internet Eligibility Status = Pending`.
+ - Also sets `Internet Eligibility Request Date Time = now()` (first request timestamp).
4. Portal reads `GET /api/eligibility/internet` and shows:
- - `PENDING` → “Review in progress”
- - `ELIGIBLE` → “Eligible for …”
- - `INELIGIBLE` → “Not available” + next steps (support/contact)
+ - `Pending` → “Review in progress”
+ - `Eligible` → “Eligible for: {Internet Eligibility}”
+ - `INEligible` → “Not available” + next steps (support/contact)
5. When Salesforce updates the Account fields:
- Portal cache invalidates via CDC/eventing (preferred), or via polling fallback.
### Recommended status → UI mapping
-| Status | Shop page | Checkout gating |
-| ------------ | ------------------------------------------------ | --------------- |
-| `REQUIRED` | Show “Add/confirm address” then “Request review” | Block submit |
-| `PENDING` | Show “Review in progress” | Block submit |
-| `ELIGIBLE` | Show “Eligible for: …” | Allow submit |
-| `INELIGIBLE` | Show “Not available” + support CTA | Block submit |
+| Status | Shop page | Checkout gating |
+| --------------- | ------------------------------------------------ | --------------- |
+| `Not Requested` | Show “Add/confirm address” then “Request review” | Block submit |
+| `Pending` | Show “Review in progress” | Block submit |
+| `Eligible` | Show “Eligible for: {Internet Eligibility}” | Allow submit |
+| `INEligible` | Show “Not available” + support CTA | Block submit |
+
+### Notes on “Not Requested” vs “Pending”
+
+- Use `Not Requested` when the customer has never requested a check (or their address is missing).
+- Use `Pending` immediately after creating the Salesforce Case.
+- The portal should treat both as “blocked for ordering” but with different next actions:
+ - `Not Requested`: show CTA to request review (and/or prompt to add address).
+ - `Pending`: show status only (no repeated CTA spam), plus optional “View case” link if you expose it.
+
+Recommended UI details:
+
+- If `Internet Eligibility Request Date Time` is present, show “Requested on {date}”.
+- If `Internet Eligibility Checked Date Time` is present, show “Last checked on {date}”.
## SIM ID Verification (Residence Card / Identity Document)
### Target UX
- In SIM checkout (and any future SIM order flow):
- - If status is `VERIFIED`: show “Verified” and **no upload/change UI**.
- - If `SUBMITTED`: show what was submitted (filename + submitted time) and optionally allow “Replace file”.
- - If `REQUIRED`: require upload before order submission.
+ - If status is `Verified`: show “Verified” and **no upload/change UI**.
+ - If `Submitted`: show what was submitted (filename + submitted time) and optionally allow “Replace file”.
+ - If `Not Submitted`: require upload before order submission.
+ - If `Rejected`: show rejection message and require resubmission.
- In order detail pages:
- - Show a simple “ID verification: Required / Submitted / Verified” row.
+ - Show a simple “ID verification: Not submitted / Submitted / Verified” row.
### Target Salesforce Model
**Account fields (canonical):**
-- `IdVerificationStatus__c` (picklist)
- - Suggested values (3-state):
- - `REQUIRED`
- - `SUBMITTED`
- - `VERIFIED`
-- `IdVerificationSubmittedAt__c` (datetime; optional)
-- `IdVerificationVerifiedAt__c` (datetime; optional)
-- `IdVerificationNotes__c` (long text; optional)
+- `Id Verification Status` (picklist)
+ - Values:
+ - `Not Submitted`
+ - `Submitted`
+ - `Verified`
+ - `Rejected`
+- `Id Verification Submitted Date Time` (datetime; optional)
+- `Id Verification Verified Date Time` (datetime; optional)
+- `Id Verification Note` (long text; optional)
+- `Id Verification Rejection Message` (long text; optional)
**Files (document storage):**
@@ -115,21 +138,70 @@ It also explains how these checks gate checkout and where the portal should disp
2. If user uploads a file:
- Portal calls `POST /api/verification/id` with multipart file.
- BFF uploads File to Salesforce Account and sets:
- - `IdVerificationStatus__c = SUBMITTED`
- - `IdVerificationSubmittedAt__c = now()`
-3. Internal review updates status to `VERIFIED` (and sets verified timestamp/notes).
+ - `Id Verification Status = Submitted`
+ - `Id Verification Submitted Date Time = now()`
+3. Internal review updates status to `Verified` (and sets verified timestamp/notes).
4. Portal displays:
- - `VERIFIED` → no edit, no upload
- - `SUBMITTED` → show file metadata + optional replace
- - `REQUIRED` → upload required before SIM activation
+ - `Verified` → no edit, no upload
+ - `Submitted` → show file metadata + optional replace
+ - `Not Submitted` → upload required before SIM activation
+ - `Rejected` → show rejection message and require resubmission
+
+### Order-first review model (recommended)
+
+For operational convenience, it can be better to review ID **from the specific SIM order**, then roll that result up to the Account so future SIM orders are frictionless.
+
+**How it works:**
+
+1. Customer uploads the ID document during SIM checkout.
+2. BFF attaches the file to the **Salesforce Order** (or an Order-related Case) as the review target.
+3. BFF sets Account `Id Verification Status = Submitted` immediately (so the portal can show progress).
+4. A Salesforce Flow (or automation) is triggered when the order’s “ID review” is approved:
+ - sets Account `Id Verification Status = Verified`
+ - sets `Id Verification Verified Date Time`
+ - links (or copies) the approved File to the Account (so it’s “on file” for future orders)
+ - optionally writes a note to `Id Verification Note`
+
+**Portal implications:**
+
+- Current order detail page can show: “ID verification: Submitted/Verified” sourced from the Account, plus (optionally) a link to the specific file attached to the Order.
+- SIM checkout becomes very simple:
+ - `Not Submitted`: upload required
+ - `Submitted`: allow order submission, show “Submitted”
+ - `Verified`: no upload UI, no “change” UI
+
+### Rejections + resubmission (recommended UX)
+
+Even if you keep a 3-state Account field, the portal can still display “rejected” as a _derived_ UI state using notes/timestamps.
+
+Recommended approach:
+
+- Use `Id Verification Note` and `Id Verification Rejection Message` for reviewer feedback.
+- When a document is not acceptable, set:
+ - `Id Verification Status = Rejected` (so the portal blocks future SIM submissions until a new file is provided)
+ - `Id Verification Rejection Message` = rejection reason (“image too blurry”, “expired”, etc.)
+
+Portal UI behavior:
+
+- If `Id Verification Status = Rejected` and `Id Verification Rejection Message` is non-empty:
+ - show “ID verification rejected” + “Rejection note”
+ - show an “Id Verification Rejection Message” block that tells the customer what to do next
+ - Example content:
+ - “Your ID verification was rejected. Please upload a new, clear photo/scan of your residence card.”
+ - “Make sure all corners are visible, the text is readable, and the document is not expired.”
+ - “After you resubmit, review will restart.”
+- When the user uploads again:
+ - overwrite the prior file (or create a new version) and set status back to `Submitted`
+ - clear/replace the notes so the UI doesn’t keep showing stale rejection reasons
### Gating rules (SIM checkout)
-| Status | Can submit order? | What portal shows |
-| ----------- | ----------------: | -------------------------------------- |
-| `REQUIRED` | No | Upload required |
-| `SUBMITTED` | Yes | Submitted summary + (optional) replace |
-| `VERIFIED` | Yes | Verified badge only |
+| Status | Can submit order? | What portal shows |
+| --------------- | ----------------: | ----------------------------------------- |
+| `Not Submitted` | No | Upload required |
+| `Submitted` | Yes | Submitted summary + (optional) replace |
+| `Verified` | Yes | Verified badge only |
+| `Rejected` | No | Rejection message + resubmission required |
## Where to show status (recommended)
@@ -137,7 +209,7 @@ It also explains how these checks gate checkout and where the portal should disp
- Internet: eligibility banner/status on `/account/shop/internet`
- SIM: verification requirement banner/status on `/account/shop/sim` (optional)
- **Checkout**
- - Show the relevant status inline in the “Checkout requirements” section.
+ - Show the relevant status inline near the confirm/requirements cards.
- **Orders**
- Show “Eligibility / ID verification” status on the order detail page so users can track progress after submitting.
- **Dashboard**