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:
parent
7ab5e12051
commit
ab429f91dc
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { ResidenceCardVerificationSettingsView } from "@/features/verification/views/ResidenceCardVerificationSettingsView";
|
||||
|
||||
export default function AccountResidenceCardVerificationPage() {
|
||||
return <ResidenceCardVerificationSettingsView />;
|
||||
}
|
||||
@ -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>({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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() {
|
||||
We’re 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 isn’t 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="We’re 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. We’ll
|
||||
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">
|
||||
We’ll 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 we’ll 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>
|
||||
|
||||
@ -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="We’ll 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 isn’t 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 couldn’t 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). We’ll verify it before activating SIM
|
||||
@ -761,6 +864,8 @@ export function AccountCheckoutContainer() {
|
||||
!isEligible ||
|
||||
eligibilityLoading ||
|
||||
eligibilityPending ||
|
||||
eligibilityNotRequested ||
|
||||
eligibilityIneligible ||
|
||||
eligibilityError
|
||||
}
|
||||
isLoading={submitting}
|
||||
|
||||
@ -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 isn’t available right now. Contact
|
||||
support if you believe this is incorrect.
|
||||
</AlertBanner>
|
||||
) : isPending ? (
|
||||
<AlertBanner variant="info" title="Review in progress" elevated>
|
||||
We’re 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>
|
||||
|
||||
@ -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.
|
||||
</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"
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
We’ll 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;
|
||||
@ -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**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user