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.
This commit is contained in:
barsa 2025-12-19 15:15:36 +09:00
parent 7ab5e12051
commit ab429f91dc
23 changed files with 1244 additions and 256 deletions

View File

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

View File

@ -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<void> {
try {
await this.catalogCache.invalidateAllCatalogs();

View File

@ -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<InternetEligibilityDto> {
return this.internetCatalog.getEligibilityDetailsForUser(req.user.id);
}
@Post("eligibility-request")

View File

@ -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<void> {
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<T>(
bucket: "catalog" | "static" | "volatile" | "eligibility",
key: string,

View File

@ -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<InternetPlanCatalogItem[]> {
@ -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<SalesforceAccount | null>(
const details = await this.catalogCache.getCachedEligibility<InternetEligibilityDto>(
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<string | null> {
const details = await this.getEligibilityDetailsForUser(userId);
return details.eligibility;
}
async getEligibilityDetailsForUser(userId: string): Promise<InternetEligibilityDto> {
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<SalesforceAccount | null>(
return this.catalogCache.getCachedEligibility<InternetEligibilityDto>(
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<string, unknown> = {
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<InternetEligibilityDto> {
const eligibilityField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_FIELD") ?? "Internet_Eligibility__c",
"ACCOUNT_INTERNET_ELIGIBILITY_FIELD"
);
const statusField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
"Internet_Eligibility_Status__c",
"ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD"
);
const requestedAtField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ??
"Internet_Eligibility_Request_Date_Time__c",
"ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD"
);
const checkedAtField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD") ??
"Internet_Eligibility_Checked_Date_Time__c",
"ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD"
);
const notesField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD") ??
"Internet_Eligibility_Notes__c",
"ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD"
);
const caseIdField = assertSoqlFieldName(
this.config.get<string>("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<Record<string, unknown>>;
const record = (res.records?.[0] as Record<string, unknown> | 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<string> {
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<void> {
const statusField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
"Internet_Eligibility_Status__c",
"ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD"
);
const requestedAtField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ??
"Internet_Eligibility_Request_Date_Time__c",
"ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD"
);
const caseIdField = assertSoqlFieldName(
this.config.get<string>("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, unknown>): string {

View File

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

View File

@ -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<ResidenceCardVerificationDto> {
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<Record<string, unknown>>;
const account = (accountRes.records?.[0] as Record<string, unknown> | 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<ArrayBuffer>;
}): Promise<ResidenceCardVerificationDto> {
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<string>("ACCOUNT_ID_VERIFICATION_STATUS_FIELD") ??
"Id_Verification_Status__c",
"ACCOUNT_ID_VERIFICATION_STATUS_FIELD"
),
submittedAt: assertSoqlFieldName(
this.config.get<string>("ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD") ??
"Id_Verification_Submitted_Date_Time__c",
"ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD"
),
verifiedAt: assertSoqlFieldName(
this.config.get<string>("ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD") ??
"Id_Verification_Verified_Date_Time__c",
"ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD"
),
note: assertSoqlFieldName(
this.config.get<string>("ACCOUNT_ID_VERIFICATION_NOTE_FIELD") ?? "Id_Verification_Note__c",
"ACCOUNT_ID_VERIFICATION_NOTE_FIELD"
),
rejectionMessage: assertSoqlFieldName(
this.config.get<string>("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<Record<string, unknown>>;
const version = (versionRes.records?.[0] as Record<string, unknown> | 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;
}
}
}

View File

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

View File

@ -0,0 +1,5 @@
import { ResidenceCardVerificationSettingsView } from "@/features/verification/views/ResidenceCardVerificationSettingsView";
export default function AccountResidenceCardVerificationPage() {
return <ResidenceCardVerificationSettingsView />;
}

View File

@ -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<SignupFormData>({

View File

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

View File

@ -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<InternetCatalogCollection> {
const response = await apiClient.GET<InternetCatalogCollection>("/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<InternetEligibilityDetails> {
const response = await apiClient.GET<InternetEligibilityDetails>(
"/api/catalog/internet/eligibility"
);
return getDataOrThrow(response, "Failed to load internet eligibility");

View File

@ -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<string>("");
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() {
Were verifying whether our service is available at your residence.
</p>
</div>
) : requiresAvailabilityCheck ? (
) : isNotRequested ? (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-muted shadow-[var(--cp-shadow-1)]">
<span className="font-semibold text-foreground">Availability review required</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Request an eligibility review to unlock personalized internet plans for your
residence.
</p>
</div>
) : isPending ? (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-info/25 bg-info-soft text-info shadow-[var(--cp-shadow-1)]">
<span className="font-semibold">Availability review in progress</span>
@ -182,6 +197,15 @@ export function InternetPlansContainer() {
your personalized internet plans.
</p>
</div>
) : isIneligible ? (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-warning/25 bg-warning/10 text-warning shadow-[var(--cp-shadow-1)]">
<span className="font-semibold">Not available for this address</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Our team reviewed your address and determined service isnt available right now.
</p>
</div>
) : eligibility ? (
<div className="flex flex-col items-center gap-2">
<div
@ -197,16 +221,12 @@ export function InternetPlansContainer() {
) : null}
</CatalogHero>
{requiresAvailabilityCheck && (
<AlertBanner
variant="info"
title="Were reviewing service availability for your residence"
className="mb-8"
>
{isNotRequested && (
<AlertBanner variant="info" title="Request an eligibility review" className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-sm text-foreground/80">
Our team will verify NTT serviceability and update your eligible offerings. Well
notify you on your dashboard when review is complete.
notify you when review is complete.
</p>
{hasServiceAddress ? (
<Button
@ -216,9 +236,15 @@ export function InternetPlansContainer() {
isLoading={eligibilityRequest.isPending}
loadingText="Requesting…"
onClick={() =>
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() {
</AlertBanner>
)}
{isPending && (
<AlertBanner variant="info" title="Review in progress" className="mb-8">
<div className="space-y-2">
<p className="text-sm text-foreground/80">
Well notify you when review is complete.
</p>
{requestedAt ? (
<p className="text-xs text-muted-foreground">
Requested: {new Date(requestedAt).toLocaleString()}
</p>
) : null}
</div>
</AlertBanner>
)}
{isIneligible && (
<AlertBanner variant="warning" title="Service not available" className="mb-8">
<div className="space-y-2">
{rejectionNotes ? (
<p className="text-sm text-foreground/80">{rejectionNotes}</p>
) : (
<p className="text-sm text-foreground/80">
If you believe this is incorrect, contact support and well take another look.
</p>
)}
<Button as="a" href="/account/support/new" size="sm">
Contact support
</Button>
</div>
</AlertBanner>
)}
{hasActiveInternet && (
<AlertBanner
variant="warning"
@ -260,33 +318,46 @@ export function InternetPlansContainer() {
{plans.length > 0 ? (
<>
{requiresAvailabilityCheck && (
{orderingLocked && (
<AlertBanner
variant="info"
title="Availability review in progress"
title={
isIneligible
? "Ordering unavailable"
: isNotRequested
? "Eligibility review required"
: "Availability review in progress"
}
className="mb-8"
elevated
>
<p className="text-sm text-foreground/80">
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."}
</p>
</AlertBanner>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{(requiresAvailabilityCheck ? silverPlans : plans).map(plan => (
{(orderingLocked ? silverPlans : plans).map(plan => (
<div key={plan.id}>
<InternetPlanCard
plan={plan}
installations={installations}
disabled={hasActiveInternet || requiresAvailabilityCheck}
disabled={hasActiveInternet || orderingLocked}
disabledReason={
hasActiveInternet
? "Already subscribed — contact us to add another residence"
: requiresAvailabilityCheck
? "Ordering locked until availability is confirmed"
: undefined
: isIneligible
? "Service not available for this address"
: isNotRequested
? "Request an eligibility review to continue"
: isPending
? "Ordering locked until availability is confirmed"
: undefined
}
/>
</div>

View File

@ -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() {
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
<CatalogHero
title="Create an account to continue"
title="Step 1: Create your account"
description="Well verify service availability for your address, then show personalized internet plans and configuration options."
/>
<AlertBanner
variant="info"
title="Internet availability review"
className="max-w-2xl mx-auto"
>
<AlertBanner variant="info" title="Already have an account?" className="max-w-2xl mx-auto">
<div className="space-y-3 text-sm text-foreground/80">
<p>
Internet plans depend on your residence and local infrastructure. Create an account so
we can review availability and unlock ordering.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button
as="a"
href={`/auth/signup?redirect=${encodeURIComponent("/account/shop/internet")}`}
className="whitespace-nowrap"
>
Create account
</Button>
<Button
as="a"
href={`/auth/login?redirect=${encodeURIComponent("/account/shop/internet")}`}
variant="outline"
className="whitespace-nowrap"
>
Sign in
@ -53,6 +38,10 @@ export function PublicInternetConfigureView() {
</div>
</div>
</AlertBanner>
<div className="mt-8 bg-card border border-border rounded-2xl p-6 md:p-7 shadow-[var(--cp-shadow-1)]">
<SignupForm redirectTo="/account/shop/internet" />
</div>
</div>
);
}

View File

@ -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() {
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
<CatalogHero
title="Create an account to order SIM service"
title="Step 1: Create your account"
description="Ordering requires a payment method and identity verification."
/>
<AlertBanner variant="info" title="Account required" className="max-w-2xl mx-auto">
<AlertBanner variant="info" title="Already have an account?" className="max-w-2xl mx-auto">
<div className="space-y-3 text-sm text-foreground/80">
<p>
Create an account to add your payment method and submit your residence card for review.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button
as="a"
href={`/auth/signup?redirect=${encodeURIComponent(redirectTarget)}`}
className="whitespace-nowrap"
>
Create account
</Button>
<Button
as="a"
href={`/auth/login?redirect=${encodeURIComponent(redirectTarget)}`}
variant="outline"
className="whitespace-nowrap"
>
Sign in
@ -52,6 +42,10 @@ export function PublicSimConfigureView() {
</div>
</div>
</AlertBanner>
<div className="mt-8 bg-card border border-border rounded-2xl p-6 md:p-7 shadow-[var(--cp-shadow-1)]">
<SignupForm redirectTo={redirectTarget} />
</div>
</div>
);
}

View File

@ -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() {
</Button>
</div>
</AlertBanner>
) : eligibilityNotRequested ? (
<AlertBanner variant="info" title="Eligibility review required" elevated>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<span className="text-sm text-foreground/80">
Request an eligibility review to confirm service availability for your address
before submitting an internet order.
</span>
{hasServiceAddress ? (
<Button
type="button"
size="sm"
className="sm:ml-auto"
disabled={eligibilityRequest.isPending}
isLoading={eligibilityRequest.isPending}
loadingText="Requesting…"
onClick={() =>
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
</Button>
) : (
<Button as="a" href="/account/settings" size="sm" className="sm:ml-auto">
Add address
</Button>
)}
</div>
</AlertBanner>
) : eligibilityIneligible ? (
<AlertBanner variant="warning" title="Service not available" elevated>
<div className="space-y-2">
<p className="text-sm text-foreground/80">
Our team reviewed your address and determined service isnt available right now.
</p>
{eligibilityNotes ? (
<p className="text-xs text-muted-foreground">{eligibilityNotes}</p>
) : eligibilityRequestedAt ? (
<p className="text-xs text-muted-foreground">
Last updated: {new Date(eligibilityRequestedAt).toLocaleString()}
</p>
) : null}
<Button as="a" href="/account/support/new" size="sm">
Contact support
</Button>
</div>
</AlertBanner>
) : null}
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 shadow-[var(--cp-shadow-1)]">
@ -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() {
<div className="space-y-3">
{residenceStatus === "rejected" && residenceCardQuery.data?.reviewerNotes ? (
<div className="text-sm text-foreground/80">
<div className="font-medium text-foreground">Reviewer note</div>
<div className="font-medium text-foreground">Rejection note</div>
<div>{residenceCardQuery.data.reviewerNotes}</div>
</div>
) : residenceStatus === "rejected" ? (
<p className="text-sm text-foreground/80">
Your document couldnt be approved. Please upload a new file to continue.
</p>
) : null}
<p className="text-sm text-foreground/80">
Upload a JPG, PNG, or PDF (max 5MB). Well verify it before activating SIM
@ -761,6 +864,8 @@ export function AccountCheckoutContainer() {
!isEligible ||
eligibilityLoading ||
eligibilityPending ||
eligibilityNotRequested ||
eligibilityIneligible ||
eligibilityError
}
isLoading={submitting}

View File

@ -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<string | null>(null);
@ -114,6 +121,16 @@ export function AvailabilityStep() {
<AlertBanner variant="success" title="Availability confirmed" elevated>
Your account is eligible for: <span className="font-semibold">{eligibilityValue}</span>
</AlertBanner>
) : isIneligible ? (
<AlertBanner variant="warning" title="Service not available" elevated>
Our team reviewed your address and determined service isnt available right now. Contact
support if you believe this is incorrect.
</AlertBanner>
) : isPending ? (
<AlertBanner variant="info" title="Review in progress" elevated>
Were reviewing service availability for your address. Once confirmed, you can return
and complete checkout.
</AlertBanner>
) : (
<div className="space-y-4">
<AlertBanner
@ -154,7 +171,7 @@ export function AvailabilityStep() {
leftIcon={<MapPinIcon className="w-4 h-4" />}
className="w-full"
>
Request availability check
{isNotRequested ? "Request availability check" : "Request review again"}
</Button>
)}
</div>

View File

@ -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). Well review it and notify you when its
approved.
</p>
{pathname.startsWith("/account") ? (
<Button
type="button"
size="sm"
variant="outline"
className="w-fit"
onClick={() => {
const current = `${pathname}${window.location.search ?? ""}`;
router.push(
`/account/settings/verification?returnTo=${encodeURIComponent(current)}`
);
}}
>
Open ID verification page
</Button>
) : null}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<input
type="file"

View File

@ -82,7 +82,18 @@ export function ReviewStep() {
: `/order/complete?orderId=${encodeURIComponent(result.sfOrderId)}`
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit order");
const message = err instanceof Error ? err.message : "Failed to submit order";
if (
isSimOrder &&
pathname.startsWith("/account") &&
(message.toLowerCase().includes("residence card submission required") ||
message.toLowerCase().includes("residence card submission was rejected"))
) {
const current = `${pathname}${window.location.search ?? ""}`;
router.push(`/account/settings/verification?returnTo=${encodeURIComponent(current)}`);
return;
}
setError(message);
setIsSubmitting(false);
}
};
@ -151,6 +162,22 @@ export function ReviewStep() {
>
Go to Payment step
</Button>
{pathname.startsWith("/account") ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const current = `${pathname}${window.location.search ?? ""}`;
router.push(
`/account/settings/verification?returnTo=${encodeURIComponent(current)}`
);
}}
className="whitespace-nowrap"
>
Manage ID verification
</Button>
) : null}
</div>
</AlertBanner>
)}

View File

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

View File

@ -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 () => {

View File

@ -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<File | null>(null);
const residenceFileInputRef = useRef<HTMLInputElement | null>(null);
const status = residenceCardQuery.data?.status;
const statusPill = useMemo(() => {
if (status === "verified") return <StatusPill label="Verified" variant="success" />;
if (status === "pending") return <StatusPill label="Submitted" variant="info" />;
if (status === "rejected") return <StatusPill label="Action needed" variant="warning" />;
return <StatusPill label="Not submitted" variant="warning" />;
}, [status]);
const canUpload = status !== "verified";
return (
<PageLayout
title="ID Verification"
description="Upload your residence card for SIM activation"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-2xl mx-auto space-y-6">
<SubCard
title="Residence card"
icon={<ShieldCheckIcon className="w-5 h-5 text-primary" />}
right={statusPill}
>
{residenceCardQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Checking verification status</div>
) : residenceCardQuery.isError ? (
<AlertBanner variant="warning" title="Unable to load status" size="sm" elevated>
<div className="flex items-center gap-2">
<Button type="button" size="sm" onClick={() => void residenceCardQuery.refetch()}>
Try again
</Button>
</div>
</AlertBanner>
) : status === "verified" ? (
<AlertBanner variant="success" title="Verified" size="sm" elevated>
Your residence card is on file and approved. No further action is required.
</AlertBanner>
) : status === "pending" ? (
<div className="space-y-3">
<AlertBanner variant="info" title="Submitted — under review" size="sm" elevated>
Well verify your residence card before activating SIM service.
</AlertBanner>
{residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Submitted document
</div>
{residenceCardQuery.data?.filename ? (
<div className="mt-1 text-sm font-medium text-foreground">
{residenceCardQuery.data.filename}
</div>
) : null}
{formatDateTime(residenceCardQuery.data?.submittedAt) ? (
<div className="mt-1 text-xs text-muted-foreground">
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
</div>
) : null}
</div>
) : null}
</div>
) : (
<div className="space-y-4">
<AlertBanner
variant={status === "rejected" ? "warning" : "info"}
title={status === "rejected" ? "Rejected — please resubmit" : "Upload required"}
size="sm"
elevated
>
<div className="space-y-2 text-sm text-foreground/80">
{status === "rejected" && residenceCardQuery.data?.reviewerNotes ? (
<div>
<div className="font-medium text-foreground">Rejection note</div>
<div>{residenceCardQuery.data.reviewerNotes}</div>
</div>
) : null}
<p>Upload a clear photo/scan of your residence card (JPG, PNG, or PDF).</p>
</div>
</AlertBanner>
{canUpload ? (
<div className="space-y-3">
<input
ref={residenceFileInputRef}
type="file"
accept="image/*,application/pdf"
onChange={e => 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 ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">
Selected file
</div>
<div className="text-sm font-medium text-foreground truncate">
{residenceFile.name}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
}}
>
Change
</Button>
</div>
) : null}
<div className="flex items-center justify-end gap-3">
{returnTo ? (
<Button type="button" variant="outline" onClick={() => router.push(returnTo)}>
Back to checkout
</Button>
) : null}
<Button
type="button"
disabled={!residenceFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!residenceFile) return;
submitResidenceCard.mutate(residenceFile, {
onSuccess: () => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
},
});
}}
>
Submit
</Button>
</div>
{submitResidenceCard.isError && (
<div className="text-sm text-destructive">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</div>
)}
</div>
) : null}
</div>
)}
</SubCard>
</div>
</PageLayout>
);
}
export default ResidenceCardVerificationSettingsView;

View File

@ -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 orders “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 its “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 doesnt 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**