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
|
// Salesforce Field Mappings - Account
|
||||||
ACCOUNT_INTERNET_ELIGIBILITY_FIELD: z.string().default("Internet_Eligibility__c"),
|
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"),
|
ACCOUNT_CUSTOMER_NUMBER_FIELD: z.string().default("SF_Account_No__c"),
|
||||||
|
|
||||||
// Salesforce Field Mappings - Product
|
// Salesforce Field Mappings - Product
|
||||||
|
|||||||
@ -269,10 +269,16 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (!this.isDataCallback(callbackType)) return;
|
if (!this.isDataCallback(callbackType)) return;
|
||||||
const payload = this.extractPayload(data);
|
const payload = this.extractPayload(data);
|
||||||
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]);
|
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]);
|
||||||
const eligibility = this.extractStringField(payload, [
|
const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]);
|
||||||
"Internet_Eligibility__c",
|
const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]);
|
||||||
"InternetEligibility__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) {
|
if (!accountId) {
|
||||||
this.logger.warn("Account eligibility event missing 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.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)
|
// Notify connected portals immediately (multi-instance safe via Redis pub/sub)
|
||||||
this.realtime.publish(`account:sf:${accountId}`, "catalog.eligibility.changed", {
|
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> {
|
private async invalidateAllCatalogs(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.catalogCache.invalidateAllCatalogs();
|
await this.catalogCache.invalidateAllCatalogs();
|
||||||
|
|||||||
@ -3,7 +3,10 @@ import { ZodValidationPipe } from "nestjs-zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||||
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.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";
|
import { addressSchema } from "@customer-portal/domain/customer";
|
||||||
|
|
||||||
const eligibilityRequestSchema = z.object({
|
const eligibilityRequestSchema = z.object({
|
||||||
@ -30,9 +33,8 @@ export class InternetEligibilityController {
|
|||||||
|
|
||||||
@Get("eligibility")
|
@Get("eligibility")
|
||||||
@RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap)
|
@RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap)
|
||||||
async getEligibility(@Req() req: RequestWithUser): Promise<{ eligibility: string | null }> {
|
async getEligibility(@Req() req: RequestWithUser): Promise<InternetEligibilityDto> {
|
||||||
const eligibility = await this.internetCatalog.getEligibilityForUser(req.user.id);
|
return this.internetCatalog.getEligibilityDetailsForUser(req.user.id);
|
||||||
return { eligibility };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("eligibility-request")
|
@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>(
|
private async getOrSet<T>(
|
||||||
bucket: "catalog" | "static" | "volatile" | "eligibility",
|
bucket: "catalog" | "static" | "volatile" | "eligibility",
|
||||||
key: string,
|
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 { ConfigService } from "@nestjs/config";
|
||||||
import { BaseCatalogService } from "./base-catalog.service.js";
|
import { BaseCatalogService } from "./base-catalog.service.js";
|
||||||
import { CatalogCacheService } from "./catalog-cache.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 { Logger } from "nestjs-pino";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.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 { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js";
|
||||||
|
import type { SalesforceResponse } from "@customer-portal/domain/common";
|
||||||
|
|
||||||
interface SalesforceAccount {
|
export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible";
|
||||||
Id: string;
|
|
||||||
Internet_Eligibility__c?: string;
|
export interface InternetEligibilityDto {
|
||||||
|
status: InternetEligibilityStatusDto;
|
||||||
|
eligibility: string | null;
|
||||||
|
requestId: string | null;
|
||||||
|
requestedAt: string | null;
|
||||||
|
checkedAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InternetCatalogService extends BaseCatalogService {
|
export class InternetCatalogService extends BaseCatalogService {
|
||||||
constructor(
|
constructor(
|
||||||
sf: SalesforceConnection,
|
sf: SalesforceConnection,
|
||||||
configService: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@Inject(Logger) logger: Logger,
|
@Inject(Logger) logger: Logger,
|
||||||
private mappingsService: MappingsService,
|
private mappingsService: MappingsService,
|
||||||
private catalogCache: CatalogCacheService
|
private catalogCache: CatalogCacheService
|
||||||
) {
|
) {
|
||||||
super(sf, configService, logger);
|
super(sf, config, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPlans(): Promise<InternetPlanCatalogItem[]> {
|
async getPlans(): Promise<InternetPlanCatalogItem[]> {
|
||||||
@ -173,21 +180,17 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
// Get customer's eligibility from Salesforce
|
// Get customer's eligibility from Salesforce
|
||||||
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
||||||
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
|
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
|
||||||
const account = await this.catalogCache.getCachedEligibility<SalesforceAccount | null>(
|
const details = await this.catalogCache.getCachedEligibility<InternetEligibilityDto>(
|
||||||
eligibilityKey,
|
eligibilityKey,
|
||||||
async () => {
|
async () => this.queryEligibilityDetails(sfAccountId)
|
||||||
const soql = buildAccountEligibilityQuery(sfAccountId);
|
|
||||||
const accounts = await this.executeQuery(soql, "Customer Eligibility");
|
|
||||||
return accounts.length > 0 ? (accounts[0] as unknown as SalesforceAccount) : null;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!account) {
|
if (!details) {
|
||||||
this.logger.warn(`No Salesforce account found for user ${userId}, returning all plans`);
|
this.logger.warn(`No Salesforce account found for user ${userId}, returning all plans`);
|
||||||
return allPlans;
|
return allPlans;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eligibility = account.Internet_Eligibility__c;
|
const eligibility = details.eligibility;
|
||||||
|
|
||||||
if (!eligibility) {
|
if (!eligibility) {
|
||||||
this.logger.log(`No eligibility field for user ${userId}, filtering to Home 1G plans only`);
|
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> {
|
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);
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
if (!mapping?.sfAccountId) {
|
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 sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
||||||
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
|
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
|
||||||
const account = await this.catalogCache.getCachedEligibility<SalesforceAccount | null>(
|
return this.catalogCache.getCachedEligibility<InternetEligibilityDto>(
|
||||||
eligibilityKey,
|
eligibilityKey,
|
||||||
async () => {
|
async () => this.queryEligibilityDetails(sfAccountId)
|
||||||
const soql = buildAccountEligibilityQuery(sfAccountId);
|
|
||||||
const accounts = await this.executeQuery(soql, "Customer Eligibility");
|
|
||||||
return accounts.length > 0 ? (accounts[0] as unknown as SalesforceAccount) : null;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return account?.Internet_Eligibility__c ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestEligibilityCheckForUser(
|
async requestEligibilityCheckForUser(
|
||||||
@ -250,6 +259,15 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
|
|
||||||
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
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 subject = "Internet availability check request (Portal)";
|
||||||
const descriptionLines: string[] = [
|
const descriptionLines: string[] = [
|
||||||
"Portal internet availability check requested.",
|
"Portal internet availability check requested.",
|
||||||
@ -264,31 +282,23 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
`RequestedAt: ${new Date().toISOString()}`,
|
`RequestedAt: ${new Date().toISOString()}`,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
const taskPayload: Record<string, unknown> = {
|
|
||||||
Subject: subject,
|
|
||||||
Description: descriptionLines.join("\n"),
|
|
||||||
WhatId: sfAccountId,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const create = this.sf.sobject("Task")?.create;
|
const requestId = await this.createEligibilityCaseOrTask(sfAccountId, {
|
||||||
if (!create) {
|
subject,
|
||||||
throw new Error("Salesforce Task create method not available");
|
description: descriptionLines.join("\n"),
|
||||||
}
|
});
|
||||||
|
|
||||||
const result = await create(taskPayload);
|
await this.updateAccountEligibilityRequestState(sfAccountId, requestId);
|
||||||
const id = (result as { id?: unknown })?.id;
|
|
||||||
if (typeof id !== "string" || id.trim().length === 0) {
|
await this.catalogCache.invalidateEligibility(sfAccountId);
|
||||||
throw new Error("Salesforce did not return a Task id");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log("Created Salesforce Task for internet eligibility request", {
|
this.logger.log("Created Salesforce Task for internet eligibility request", {
|
||||||
userId,
|
userId,
|
||||||
sfAccountIdTail: sfAccountId.slice(-4),
|
sfAccountIdTail: sfAccountId.slice(-4),
|
||||||
taskIdTail: id.slice(-4),
|
taskIdTail: requestId.slice(-4),
|
||||||
});
|
});
|
||||||
|
|
||||||
return id;
|
return requestId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to create Salesforce Task for internet eligibility request", {
|
this.logger.error("Failed to create Salesforce Task for internet eligibility request", {
|
||||||
userId,
|
userId,
|
||||||
@ -304,6 +314,181 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
// e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G"
|
// e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G"
|
||||||
return plan.internetOfferingType === eligibility;
|
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 {
|
function formatAddressForLog(address: Record<string, unknown>): string {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import type { Providers } from "@customer-portal/domain/subscriptions";
|
|||||||
|
|
||||||
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
|
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
|
||||||
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
|
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 { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js";
|
||||||
import { PaymentValidatorService } from "./payment-validator.service.js";
|
import { PaymentValidatorService } from "./payment-validator.service.js";
|
||||||
import { ResidenceCardService } from "@bff/modules/verification/residence-card.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 whmcs: WhmcsConnectionOrchestratorService,
|
||||||
private readonly pricebookService: OrderPricebookService,
|
private readonly pricebookService: OrderPricebookService,
|
||||||
private readonly simCatalogService: SimCatalogService,
|
private readonly simCatalogService: SimCatalogService,
|
||||||
|
private readonly internetCatalogService: InternetCatalogService,
|
||||||
private readonly paymentValidator: PaymentValidatorService,
|
private readonly paymentValidator: PaymentValidatorService,
|
||||||
private readonly residenceCards: ResidenceCardService
|
private readonly residenceCards: ResidenceCardService
|
||||||
) {}
|
) {}
|
||||||
@ -311,6 +313,23 @@ export class OrderValidator {
|
|||||||
|
|
||||||
// 4. Order-specific business validation
|
// 4. Order-specific business validation
|
||||||
if (businessValidatedBody.orderType === "Internet") {
|
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);
|
await this.validateInternetDuplication(userId, userMapping.whmcsClientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { ResidenceCardStatus, type ResidenceCardSubmission } from "@prisma/client";
|
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";
|
type ResidenceCardStatusDto = "not_submitted" | "pending" | "verified" | "rejected";
|
||||||
|
|
||||||
@ -14,42 +23,106 @@ export interface ResidenceCardVerificationDto {
|
|||||||
reviewerNotes: string | null;
|
reviewerNotes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStatus(status: ResidenceCardStatus): ResidenceCardStatusDto {
|
function mapFileTypeToMime(fileType?: string | null): string | null {
|
||||||
if (status === ResidenceCardStatus.VERIFIED) return "verified";
|
const normalized = String(fileType || "")
|
||||||
if (status === ResidenceCardStatus.REJECTED) return "rejected";
|
.trim()
|
||||||
return "pending";
|
.toLowerCase();
|
||||||
}
|
if (normalized === "pdf") return "application/pdf";
|
||||||
|
if (normalized === "png") return "image/png";
|
||||||
function toDto(record: ResidenceCardSubmission | null): ResidenceCardVerificationDto {
|
if (normalized === "jpg" || normalized === "jpeg") return "image/jpeg";
|
||||||
if (!record) {
|
return null;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ResidenceCardService {
|
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> {
|
async getStatusForUser(userId: string): Promise<ResidenceCardVerificationDto> {
|
||||||
const record = await this.prisma.residenceCardSubmission.findUnique({ where: { userId } });
|
const mapping = await this.mappings.findByUserId(userId);
|
||||||
return toDto(record);
|
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: {
|
async submitForUser(params: {
|
||||||
@ -59,29 +132,157 @@ export class ResidenceCardService {
|
|||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
content: Uint8Array<ArrayBuffer>;
|
content: Uint8Array<ArrayBuffer>;
|
||||||
}): Promise<ResidenceCardVerificationDto> {
|
}): Promise<ResidenceCardVerificationDto> {
|
||||||
const record = await this.prisma.residenceCardSubmission.upsert({
|
const mapping = await this.mappings.findByUserId(params.userId);
|
||||||
where: { userId: params.userId },
|
if (!mapping?.sfAccountId) {
|
||||||
create: {
|
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,
|
userId: params.userId,
|
||||||
status: ResidenceCardStatus.PENDING,
|
sfAccountIdTail: sfAccountId.slice(-4),
|
||||||
filename: params.filename,
|
error: getErrorMessage(error),
|
||||||
mimeType: params.mimeType,
|
});
|
||||||
sizeBytes: params.sizeBytes,
|
throw new Error("Failed to submit residence card. Please try again later.");
|
||||||
content: params.content,
|
}
|
||||||
submittedAt: new Date(),
|
|
||||||
},
|
const fields = this.getAccountFieldNames();
|
||||||
update: {
|
const update = this.sf.sobject("Account")?.update;
|
||||||
status: ResidenceCardStatus.PENDING,
|
if (!update) {
|
||||||
filename: params.filename,
|
throw new Error("Salesforce Account update method not available");
|
||||||
mimeType: params.mimeType,
|
}
|
||||||
sizeBytes: params.sizeBytes,
|
|
||||||
content: params.content,
|
await update({
|
||||||
submittedAt: new Date(),
|
Id: sfAccountId,
|
||||||
reviewedAt: null,
|
[fields.status]: "Submitted",
|
||||||
reviewerNotes: null,
|
[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 { Module } from "@nestjs/common";
|
||||||
import { PrismaModule } from "@bff/infra/database/prisma.module.js";
|
|
||||||
import { ResidenceCardController } from "./residence-card.controller.js";
|
import { ResidenceCardController } from "./residence-card.controller.js";
|
||||||
import { ResidenceCardService } from "./residence-card.service.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({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [IntegrationsModule, MappingsModule, CoreConfigModule],
|
||||||
controllers: [ResidenceCardController],
|
controllers: [ResidenceCardController],
|
||||||
providers: [ResidenceCardService],
|
providers: [ResidenceCardService],
|
||||||
exports: [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 Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { ErrorMessage } from "@/components/atoms";
|
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 { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/auth";
|
||||||
import { addressFormSchema } from "@customer-portal/domain/customer";
|
import { addressFormSchema } from "@customer-portal/domain/customer";
|
||||||
import { useZodForm } from "@/hooks/useZodForm";
|
import { useZodForm } from "@/hooks/useZodForm";
|
||||||
@ -50,6 +50,7 @@ interface SignupFormProps {
|
|||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
redirectTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
@ -60,8 +61,8 @@ const STEPS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "address",
|
key: "address",
|
||||||
title: "Delivery Address",
|
title: "Address",
|
||||||
description: "Where to ship your SIM card",
|
description: "Used for service eligibility and delivery",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "password",
|
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 searchParams = useSearchParams();
|
||||||
const { signup, loading, error, clearError } = useSignup();
|
const { signup, loading, error, clearError } = useSignupWithRedirect({ redirectTo });
|
||||||
const [step, setStep] = useState(0);
|
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 redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : "";
|
||||||
|
|
||||||
const form = useZodForm<SignupFormData>({
|
const form = useZodForm<SignupFormData>({
|
||||||
|
|||||||
@ -53,10 +53,10 @@ export function useAuth() {
|
|||||||
|
|
||||||
// Enhanced signup with redirect handling
|
// Enhanced signup with redirect handling
|
||||||
const signup = useCallback(
|
const signup = useCallback(
|
||||||
async (data: SignupRequest) => {
|
async (data: SignupRequest, options?: { redirectTo?: string }) => {
|
||||||
await signupAction(data);
|
await signupAction(data);
|
||||||
const redirectTo = getPostLoginRedirect(searchParams);
|
const dest = options?.redirectTo ?? getPostLoginRedirect(searchParams);
|
||||||
router.push(redirectTo);
|
router.push(dest);
|
||||||
},
|
},
|
||||||
[signupAction, router, searchParams]
|
[signupAction, router, searchParams]
|
||||||
);
|
);
|
||||||
@ -115,10 +115,14 @@ export function useLogin() {
|
|||||||
* Hook for signup functionality
|
* Hook for signup functionality
|
||||||
*/
|
*/
|
||||||
export function useSignup() {
|
export function useSignup() {
|
||||||
|
return useSignupWithRedirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSignupWithRedirect(options?: { redirectTo?: string }) {
|
||||||
const { signup, loading, error, clearError } = useAuth();
|
const { signup, loading, error, clearError } = useAuth();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
signup,
|
signup: (data: SignupRequest) => signup(data, options),
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
clearError,
|
clearError,
|
||||||
|
|||||||
@ -18,6 +18,17 @@ import {
|
|||||||
} from "@customer-portal/domain/catalog";
|
} from "@customer-portal/domain/catalog";
|
||||||
import type { Address } from "@customer-portal/domain/customer";
|
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 = {
|
export const catalogService = {
|
||||||
async getInternetCatalog(): Promise<InternetCatalogCollection> {
|
async getInternetCatalog(): Promise<InternetCatalogCollection> {
|
||||||
const response = await apiClient.GET<InternetCatalogCollection>("/api/catalog/internet/plans");
|
const response = await apiClient.GET<InternetCatalogCollection>("/api/catalog/internet/plans");
|
||||||
@ -76,8 +87,8 @@ export const catalogService = {
|
|||||||
return vpnCatalogProductSchema.array().parse(data);
|
return vpnCatalogProductSchema.array().parse(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getInternetEligibility(): Promise<{ eligibility: string | null }> {
|
async getInternetEligibility(): Promise<InternetEligibilityDetails> {
|
||||||
const response = await apiClient.GET<{ eligibility: string | null }>(
|
const response = await apiClient.GET<InternetEligibilityDetails>(
|
||||||
"/api/catalog/internet/eligibility"
|
"/api/catalog/internet/eligibility"
|
||||||
);
|
);
|
||||||
return getDataOrThrow(response, "Failed to load internet eligibility");
|
return getDataOrThrow(response, "Failed to load internet eligibility");
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { WifiIcon, ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline";
|
import { WifiIcon, ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline";
|
||||||
import { useInternetCatalog } from "@/features/catalog/hooks";
|
import { useInternetCatalog } from "@/features/catalog/hooks";
|
||||||
@ -35,7 +35,6 @@ export function InternetPlansContainer() {
|
|||||||
() => data?.installations ?? [],
|
() => data?.installations ?? [],
|
||||||
[data?.installations]
|
[data?.installations]
|
||||||
);
|
);
|
||||||
const [eligibility, setEligibility] = useState<string>("");
|
|
||||||
const { data: activeSubs } = useActiveSubscriptions();
|
const { data: activeSubs } = useActiveSubscriptions();
|
||||||
const hasActiveInternet = useMemo(
|
const hasActiveInternet = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -52,32 +51,38 @@ export function InternetPlansContainer() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const eligibilityValue = eligibilityQuery.data?.eligibility;
|
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(
|
const hasServiceAddress = Boolean(
|
||||||
user?.address?.address1 &&
|
user?.address?.address1 &&
|
||||||
user?.address?.city &&
|
user?.address?.city &&
|
||||||
user?.address?.postcode &&
|
user?.address?.postcode &&
|
||||||
(user?.address?.country || user?.address?.countryCode)
|
(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(() => {
|
const eligibility = useMemo(() => {
|
||||||
if (!user?.id) return;
|
if (!isEligible) return "";
|
||||||
if (eligibilityValue !== null) return;
|
return eligibilityValue.trim();
|
||||||
const key = `cp:internet-eligibility:last:${user.id}`;
|
}, [eligibilityValue, isEligible]);
|
||||||
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 getEligibilityIcon = (offeringType?: string) => {
|
const getEligibilityIcon = (offeringType?: string) => {
|
||||||
const lower = (offeringType || "").toLowerCase();
|
const lower = (offeringType || "").toLowerCase();
|
||||||
@ -172,7 +177,17 @@ export function InternetPlansContainer() {
|
|||||||
We’re verifying whether our service is available at your residence.
|
We’re verifying whether our service is available at your residence.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="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)]">
|
<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>
|
<span className="font-semibold">Availability review in progress</span>
|
||||||
@ -182,6 +197,15 @@ export function InternetPlansContainer() {
|
|||||||
your personalized internet plans.
|
your personalized internet plans.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? (
|
) : eligibility ? (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div
|
<div
|
||||||
@ -197,16 +221,12 @@ export function InternetPlansContainer() {
|
|||||||
) : null}
|
) : null}
|
||||||
</CatalogHero>
|
</CatalogHero>
|
||||||
|
|
||||||
{requiresAvailabilityCheck && (
|
{isNotRequested && (
|
||||||
<AlertBanner
|
<AlertBanner variant="info" title="Request an eligibility review" className="mb-8">
|
||||||
variant="info"
|
|
||||||
title="We’re reviewing service availability for your residence"
|
|
||||||
className="mb-8"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<p className="text-sm text-foreground/80">
|
<p className="text-sm text-foreground/80">
|
||||||
Our team will verify NTT serviceability and update your eligible offerings. We’ll
|
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>
|
</p>
|
||||||
{hasServiceAddress ? (
|
{hasServiceAddress ? (
|
||||||
<Button
|
<Button
|
||||||
@ -216,9 +236,15 @@ export function InternetPlansContainer() {
|
|||||||
isLoading={eligibilityRequest.isPending}
|
isLoading={eligibilityRequest.isPending}
|
||||||
loadingText="Requesting…"
|
loadingText="Requesting…"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
eligibilityRequest.mutate({
|
void (async () => {
|
||||||
address: user?.address ?? undefined,
|
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"
|
className="sm:ml-auto whitespace-nowrap"
|
||||||
>
|
>
|
||||||
@ -238,6 +264,38 @@ export function InternetPlansContainer() {
|
|||||||
</AlertBanner>
|
</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 && (
|
{hasActiveInternet && (
|
||||||
<AlertBanner
|
<AlertBanner
|
||||||
variant="warning"
|
variant="warning"
|
||||||
@ -260,33 +318,46 @@ export function InternetPlansContainer() {
|
|||||||
|
|
||||||
{plans.length > 0 ? (
|
{plans.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{requiresAvailabilityCheck && (
|
{orderingLocked && (
|
||||||
<AlertBanner
|
<AlertBanner
|
||||||
variant="info"
|
variant="info"
|
||||||
title="Availability review in progress"
|
title={
|
||||||
|
isIneligible
|
||||||
|
? "Ordering unavailable"
|
||||||
|
: isNotRequested
|
||||||
|
? "Eligibility review required"
|
||||||
|
: "Availability review in progress"
|
||||||
|
}
|
||||||
className="mb-8"
|
className="mb-8"
|
||||||
elevated
|
elevated
|
||||||
>
|
>
|
||||||
<p className="text-sm text-foreground/80">
|
<p className="text-sm text-foreground/80">
|
||||||
You can browse standard pricing below, but ordering stays locked until we confirm
|
{isIneligible
|
||||||
service availability for your residence.
|
? "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>
|
</p>
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
<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}>
|
<div key={plan.id}>
|
||||||
<InternetPlanCard
|
<InternetPlanCard
|
||||||
plan={plan}
|
plan={plan}
|
||||||
installations={installations}
|
installations={installations}
|
||||||
disabled={hasActiveInternet || requiresAvailabilityCheck}
|
disabled={hasActiveInternet || orderingLocked}
|
||||||
disabledReason={
|
disabledReason={
|
||||||
hasActiveInternet
|
hasActiveInternet
|
||||||
? "Already subscribed — contact us to add another residence"
|
? "Already subscribed — contact us to add another residence"
|
||||||
: requiresAvailabilityCheck
|
: isIneligible
|
||||||
? "Ordering locked until availability is confirmed"
|
? "Service not available for this address"
|
||||||
: undefined
|
: isNotRequested
|
||||||
|
? "Request an eligibility review to continue"
|
||||||
|
: isPending
|
||||||
|
? "Ordering locked until availability is confirmed"
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Button } from "@/components/atoms/button";
|
|||||||
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
||||||
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
||||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
||||||
|
import { SignupForm } from "@/features/auth/components/SignupForm/SignupForm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public Internet Configure View
|
* Public Internet Configure View
|
||||||
@ -20,32 +21,16 @@ export function PublicInternetConfigureView() {
|
|||||||
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
|
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
|
||||||
|
|
||||||
<CatalogHero
|
<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."
|
description="We’ll verify service availability for your address, then show personalized internet plans and configuration options."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertBanner
|
<AlertBanner variant="info" title="Already have an account?" className="max-w-2xl mx-auto">
|
||||||
variant="info"
|
|
||||||
title="Internet availability review"
|
|
||||||
className="max-w-2xl mx-auto"
|
|
||||||
>
|
|
||||||
<div className="space-y-3 text-sm text-foreground/80">
|
<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">
|
<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
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href={`/auth/login?redirect=${encodeURIComponent("/account/shop/internet")}`}
|
href={`/auth/login?redirect=${encodeURIComponent("/account/shop/internet")}`}
|
||||||
variant="outline"
|
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
@ -53,6 +38,10 @@ export function PublicInternetConfigureView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AlertBanner>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackL
|
|||||||
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
||||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { SignupForm } from "@/features/auth/components/SignupForm/SignupForm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public SIM Configure View
|
* Public SIM Configure View
|
||||||
@ -24,27 +25,16 @@ export function PublicSimConfigureView() {
|
|||||||
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
|
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
title="Create an account to order SIM service"
|
title="Step 1: Create your account"
|
||||||
description="Ordering requires a payment method and identity verification."
|
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">
|
<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">
|
<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
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href={`/auth/login?redirect=${encodeURIComponent(redirectTarget)}`}
|
href={`/auth/login?redirect=${encodeURIComponent(redirectTarget)}`}
|
||||||
variant="outline"
|
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
@ -52,6 +42,10 @@ export function PublicSimConfigureView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AlertBanner>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,21 +18,21 @@ import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
|||||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||||
import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants";
|
import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants";
|
||||||
import { useInternetEligibility } from "@/features/catalog/hooks/useInternetEligibility";
|
import { useInternetEligibility } from "@/features/catalog/hooks/useInternetEligibility";
|
||||||
|
import { useRequestInternetEligibilityCheck } from "@/features/catalog/hooks/useInternetEligibility";
|
||||||
import {
|
import {
|
||||||
useResidenceCardVerification,
|
useResidenceCardVerification,
|
||||||
useSubmitResidenceCard,
|
useSubmitResidenceCard,
|
||||||
} from "@/features/verification/hooks/useResidenceCardVerification";
|
} from "@/features/verification/hooks/useResidenceCardVerification";
|
||||||
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders";
|
import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders";
|
||||||
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
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() {
|
export function AccountCheckoutContainer() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { user } = useAuthSession();
|
||||||
|
|
||||||
const { cartItem, checkoutSessionId, clear } = useCheckoutStore();
|
const { cartItem, checkoutSessionId, clear } = useCheckoutStore();
|
||||||
|
|
||||||
@ -94,12 +94,42 @@ export function AccountCheckoutContainer() {
|
|||||||
|
|
||||||
const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder });
|
const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder });
|
||||||
const eligibilityValue = eligibilityQuery.data?.eligibility;
|
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 eligibilityLoading = Boolean(isInternetOrder && eligibilityQuery.isLoading);
|
||||||
|
const eligibilityNotRequested = Boolean(
|
||||||
|
isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "not_requested"
|
||||||
|
);
|
||||||
const eligibilityPending = Boolean(
|
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 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 residenceCardQuery = useResidenceCardVerification({ enabled: isSimOrder });
|
||||||
const submitResidenceCard = useSubmitResidenceCard();
|
const submitResidenceCard = useSubmitResidenceCard();
|
||||||
@ -155,11 +185,21 @@ export function AccountCheckoutContainer() {
|
|||||||
clear();
|
clear();
|
||||||
router.push(`/account/orders/${encodeURIComponent(result.sfOrderId)}?status=success`);
|
router.push(`/account/orders/${encodeURIComponent(result.sfOrderId)}?status=success`);
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [checkoutSessionId, clear, router]);
|
}, [checkoutSessionId, clear, isSimOrder, pathname, router, searchParams]);
|
||||||
|
|
||||||
if (!cartItem || !orderType) {
|
if (!cartItem || !orderType) {
|
||||||
const shopHref = pathname.startsWith("/account") ? "/account/shop" : "/shop";
|
const shopHref = pathname.startsWith("/account") ? "/account/shop" : "/shop";
|
||||||
@ -228,6 +268,65 @@ export function AccountCheckoutContainer() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AlertBanner>
|
</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}
|
) : null}
|
||||||
|
|
||||||
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 shadow-[var(--cp-shadow-1)]">
|
<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"}
|
variant={residenceStatus === "rejected" ? "warning" : "info"}
|
||||||
title={
|
title={
|
||||||
residenceStatus === "rejected"
|
residenceStatus === "rejected"
|
||||||
? "Residence card needs resubmission"
|
? "ID verification rejected"
|
||||||
: "Submit your residence card"
|
: "Submit your residence card"
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -607,9 +706,13 @@ export function AccountCheckoutContainer() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{residenceStatus === "rejected" && residenceCardQuery.data?.reviewerNotes ? (
|
{residenceStatus === "rejected" && residenceCardQuery.data?.reviewerNotes ? (
|
||||||
<div className="text-sm text-foreground/80">
|
<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>{residenceCardQuery.data.reviewerNotes}</div>
|
||||||
</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}
|
) : null}
|
||||||
<p className="text-sm text-foreground/80">
|
<p className="text-sm text-foreground/80">
|
||||||
Upload a JPG, PNG, or PDF (max 5MB). We’ll verify it before activating SIM
|
Upload a JPG, PNG, or PDF (max 5MB). We’ll verify it before activating SIM
|
||||||
@ -761,6 +864,8 @@ export function AccountCheckoutContainer() {
|
|||||||
!isEligible ||
|
!isEligible ||
|
||||||
eligibilityLoading ||
|
eligibilityLoading ||
|
||||||
eligibilityPending ||
|
eligibilityPending ||
|
||||||
|
eligibilityNotRequested ||
|
||||||
|
eligibilityIneligible ||
|
||||||
eligibilityError
|
eligibilityError
|
||||||
}
|
}
|
||||||
isLoading={submitting}
|
isLoading={submitting}
|
||||||
|
|||||||
@ -39,7 +39,14 @@ export function AvailabilityStep() {
|
|||||||
enabled: canCheckEligibility && isInternetOrder,
|
enabled: canCheckEligibility && isInternetOrder,
|
||||||
});
|
});
|
||||||
const eligibilityValue = eligibilityQuery.data?.eligibility ?? null;
|
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 availabilityRequest = useRequestInternetEligibilityCheck();
|
||||||
const [requestError, setRequestError] = useState<string | null>(null);
|
const [requestError, setRequestError] = useState<string | null>(null);
|
||||||
@ -114,6 +121,16 @@ export function AvailabilityStep() {
|
|||||||
<AlertBanner variant="success" title="Availability confirmed" elevated>
|
<AlertBanner variant="success" title="Availability confirmed" elevated>
|
||||||
Your account is eligible for: <span className="font-semibold">{eligibilityValue}</span>
|
Your account is eligible for: <span className="font-semibold">{eligibilityValue}</span>
|
||||||
</AlertBanner>
|
</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">
|
<div className="space-y-4">
|
||||||
<AlertBanner
|
<AlertBanner
|
||||||
@ -154,7 +171,7 @@ export function AvailabilityStep() {
|
|||||||
leftIcon={<MapPinIcon className="w-4 h-4" />}
|
leftIcon={<MapPinIcon className="w-4 h-4" />}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Request availability check
|
{isNotRequested ? "Request availability check" : "Request review again"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useCheckoutStore } from "../../stores/checkout.store";
|
import { useCheckoutStore } from "../../stores/checkout.store";
|
||||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
@ -27,6 +28,8 @@ import {
|
|||||||
* Opens WHMCS SSO to add payment method and polls for completion.
|
* Opens WHMCS SSO to add payment method and polls for completion.
|
||||||
*/
|
*/
|
||||||
export function PaymentStep() {
|
export function PaymentStep() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const { isAuthenticated } = useAuthSession();
|
const { isAuthenticated } = useAuthSession();
|
||||||
const {
|
const {
|
||||||
cartItem,
|
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
|
Upload a JPG, PNG, or PDF (max 5MB). We’ll review it and notify you when it’s
|
||||||
approved.
|
approved.
|
||||||
</p>
|
</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">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@ -82,7 +82,18 @@ export function ReviewStep() {
|
|||||||
: `/order/complete?orderId=${encodeURIComponent(result.sfOrderId)}`
|
: `/order/complete?orderId=${encodeURIComponent(result.sfOrderId)}`
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} 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);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -151,6 +162,22 @@ export function ReviewStep() {
|
|||||||
>
|
>
|
||||||
Go to Payment step
|
Go to Payment step
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -58,7 +58,7 @@ interface ComputeTasksParams {
|
|||||||
summary: DashboardSummary | undefined;
|
summary: DashboardSummary | undefined;
|
||||||
paymentMethods: PaymentMethodList | undefined;
|
paymentMethods: PaymentMethodList | undefined;
|
||||||
orders: OrderSummary[] | undefined;
|
orders: OrderSummary[] | undefined;
|
||||||
internetEligibility: string | null | undefined;
|
internetEligibilityStatus: "not_requested" | "pending" | "eligible" | "ineligible" | undefined;
|
||||||
formatCurrency: (amount: number, options?: { currency?: string }) => string;
|
formatCurrency: (amount: number, options?: { currency?: string }) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ function computeTasks({
|
|||||||
summary,
|
summary,
|
||||||
paymentMethods,
|
paymentMethods,
|
||||||
orders,
|
orders,
|
||||||
internetEligibility,
|
internetEligibilityStatus,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
}: ComputeTasksParams): DashboardTask[] {
|
}: ComputeTasksParams): DashboardTask[] {
|
||||||
const tasks: DashboardTask[] = [];
|
const tasks: DashboardTask[] = [];
|
||||||
@ -152,8 +152,8 @@ function computeTasks({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 4: Internet eligibility review (only when value is explicitly null)
|
// Priority 4: Internet eligibility review (only when explicitly pending)
|
||||||
if (internetEligibility === null) {
|
if (internetEligibilityStatus === "pending") {
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: "internet-eligibility-review",
|
id: "internet-eligibility-review",
|
||||||
priority: 4,
|
priority: 4,
|
||||||
@ -225,10 +225,10 @@ export function useDashboardTasks(): UseDashboardTasksResult {
|
|||||||
summary,
|
summary,
|
||||||
paymentMethods,
|
paymentMethods,
|
||||||
orders,
|
orders,
|
||||||
internetEligibility: eligibility?.eligibility,
|
internetEligibilityStatus: eligibility?.status,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
}),
|
}),
|
||||||
[summary, paymentMethods, orders, eligibility?.eligibility, formatCurrency]
|
[summary, paymentMethods, orders, eligibility?.status, formatCurrency]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -35,18 +35,19 @@ export function DashboardView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated || !user?.id) return;
|
if (!isAuthenticated || !user?.id) return;
|
||||||
const current = eligibility?.eligibility;
|
const status = eligibility?.status;
|
||||||
if (current === undefined) return; // query not ready yet
|
if (!status) return; // query not ready yet
|
||||||
|
|
||||||
const key = `cp:internet-eligibility:last:${user.id}`;
|
const key = `cp:internet-eligibility:last:${user.id}`;
|
||||||
const last = localStorage.getItem(key);
|
const last = localStorage.getItem(key);
|
||||||
|
|
||||||
if (current === null) {
|
if (status === "pending") {
|
||||||
localStorage.setItem(key, "PENDING");
|
localStorage.setItem(key, "PENDING");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof current === "string" && current.trim().length > 0) {
|
if (status === "eligible" && typeof eligibility?.eligibility === "string") {
|
||||||
|
const current = eligibility.eligibility.trim();
|
||||||
if (last === "PENDING") {
|
if (last === "PENDING") {
|
||||||
setEligibilityToast({
|
setEligibilityToast({
|
||||||
visible: true,
|
visible: true,
|
||||||
@ -61,7 +62,7 @@ export function DashboardView() {
|
|||||||
}
|
}
|
||||||
localStorage.setItem(key, current);
|
localStorage.setItem(key, current);
|
||||||
}
|
}
|
||||||
}, [eligibility?.eligibility, isAuthenticated, user?.id]);
|
}, [eligibility?.eligibility, eligibility?.status, isAuthenticated, user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
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”).
|
- Show current eligibility status (e.g. “Checking”, “Eligible for …”, “Not available”, “Action needed”).
|
||||||
- If not requested yet: show a single CTA (“Request eligibility review”).
|
- If not requested yet: show a single CTA (“Request eligibility review”).
|
||||||
- In checkout (Internet orders):
|
- In checkout (Internet orders):
|
||||||
- If eligibility is **PENDING/REQUIRED**, the submit CTA is disabled and we guide the user to the next action.
|
- If eligibility is **Pending/Not Requested**, the submit CTA is disabled and we guide the user to the next action.
|
||||||
- If **ELIGIBLE**, proceed normally.
|
- If **Eligible**, proceed normally.
|
||||||
|
|
||||||
### Target Salesforce Model
|
### Target Salesforce Model
|
||||||
|
|
||||||
**Account fields (canonical, cached by portal):**
|
**Account fields (canonical, cached by portal):**
|
||||||
|
|
||||||
- `InternetEligibilityStatus__c` (picklist)
|
- `Internet Eligibility Status` (picklist)
|
||||||
- Suggested values:
|
- Suggested values:
|
||||||
- `REQUIRED` (no check requested / missing address)
|
- `Not Requested` (no check requested yet; address may be missing or unconfirmed)
|
||||||
- `PENDING` (case open, awaiting review)
|
- `Pending` (case open, awaiting review)
|
||||||
- `ELIGIBLE` (approved)
|
- `Eligible` (approved)
|
||||||
- `INELIGIBLE` (rejected)
|
- `Ineligible` (rejected)
|
||||||
- `InternetEligibilityResult__c` (text/picklist; optional)
|
- `Internet Eligibility` (text/picklist; optional result)
|
||||||
- Example: `Home`, `Apartment`, or a more structured code that maps to portal offerings.
|
- Example: `Home`, `Apartment`, or a more structured code that maps to portal offerings.
|
||||||
- `InternetEligibilityCheckedAt__c` (datetime; optional)
|
- Recommended supporting fields (optional but strongly recommended):
|
||||||
- `InternetEligibilityNotes__c` (long text; optional)
|
- `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):**
|
**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:
|
2. BFF validates:
|
||||||
- account has a service address (or includes the address in request)
|
- account has a service address (or includes the address in request)
|
||||||
- throttling/rate limits
|
- 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:
|
4. Portal reads `GET /api/eligibility/internet` and shows:
|
||||||
- `PENDING` → “Review in progress”
|
- `Pending` → “Review in progress”
|
||||||
- `ELIGIBLE` → “Eligible for …”
|
- `Eligible` → “Eligible for: {Internet Eligibility}”
|
||||||
- `INELIGIBLE` → “Not available” + next steps (support/contact)
|
- `INEligible` → “Not available” + next steps (support/contact)
|
||||||
5. When Salesforce updates the Account fields:
|
5. When Salesforce updates the Account fields:
|
||||||
- Portal cache invalidates via CDC/eventing (preferred), or via polling fallback.
|
- Portal cache invalidates via CDC/eventing (preferred), or via polling fallback.
|
||||||
|
|
||||||
### Recommended status → UI mapping
|
### Recommended status → UI mapping
|
||||||
|
|
||||||
| Status | Shop page | Checkout gating |
|
| Status | Shop page | Checkout gating |
|
||||||
| ------------ | ------------------------------------------------ | --------------- |
|
| --------------- | ------------------------------------------------ | --------------- |
|
||||||
| `REQUIRED` | Show “Add/confirm address” then “Request review” | Block submit |
|
| `Not Requested` | Show “Add/confirm address” then “Request review” | Block submit |
|
||||||
| `PENDING` | Show “Review in progress” | Block submit |
|
| `Pending` | Show “Review in progress” | Block submit |
|
||||||
| `ELIGIBLE` | Show “Eligible for: …” | Allow submit |
|
| `Eligible` | Show “Eligible for: {Internet Eligibility}” | Allow submit |
|
||||||
| `INELIGIBLE` | Show “Not available” + support CTA | Block 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)
|
## SIM ID Verification (Residence Card / Identity Document)
|
||||||
|
|
||||||
### Target UX
|
### Target UX
|
||||||
|
|
||||||
- In SIM checkout (and any future SIM order flow):
|
- In SIM checkout (and any future SIM order flow):
|
||||||
- If status is `VERIFIED`: show “Verified” and **no upload/change UI**.
|
- 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 `Submitted`: show what was submitted (filename + submitted time) and optionally allow “Replace file”.
|
||||||
- If `REQUIRED`: require upload before order submission.
|
- If `Not Submitted`: require upload before order submission.
|
||||||
|
- If `Rejected`: show rejection message and require resubmission.
|
||||||
- In order detail pages:
|
- 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
|
### Target Salesforce Model
|
||||||
|
|
||||||
**Account fields (canonical):**
|
**Account fields (canonical):**
|
||||||
|
|
||||||
- `IdVerificationStatus__c` (picklist)
|
- `Id Verification Status` (picklist)
|
||||||
- Suggested values (3-state):
|
- Values:
|
||||||
- `REQUIRED`
|
- `Not Submitted`
|
||||||
- `SUBMITTED`
|
- `Submitted`
|
||||||
- `VERIFIED`
|
- `Verified`
|
||||||
- `IdVerificationSubmittedAt__c` (datetime; optional)
|
- `Rejected`
|
||||||
- `IdVerificationVerifiedAt__c` (datetime; optional)
|
- `Id Verification Submitted Date Time` (datetime; optional)
|
||||||
- `IdVerificationNotes__c` (long text; optional)
|
- `Id Verification Verified Date Time` (datetime; optional)
|
||||||
|
- `Id Verification Note` (long text; optional)
|
||||||
|
- `Id Verification Rejection Message` (long text; optional)
|
||||||
|
|
||||||
**Files (document storage):**
|
**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:
|
2. If user uploads a file:
|
||||||
- Portal calls `POST /api/verification/id` with multipart file.
|
- Portal calls `POST /api/verification/id` with multipart file.
|
||||||
- BFF uploads File to Salesforce Account and sets:
|
- BFF uploads File to Salesforce Account and sets:
|
||||||
- `IdVerificationStatus__c = SUBMITTED`
|
- `Id Verification Status = Submitted`
|
||||||
- `IdVerificationSubmittedAt__c = now()`
|
- `Id Verification Submitted Date Time = now()`
|
||||||
3. Internal review updates status to `VERIFIED` (and sets verified timestamp/notes).
|
3. Internal review updates status to `Verified` (and sets verified timestamp/notes).
|
||||||
4. Portal displays:
|
4. Portal displays:
|
||||||
- `VERIFIED` → no edit, no upload
|
- `Verified` → no edit, no upload
|
||||||
- `SUBMITTED` → show file metadata + optional replace
|
- `Submitted` → show file metadata + optional replace
|
||||||
- `REQUIRED` → upload required before SIM activation
|
- `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)
|
### Gating rules (SIM checkout)
|
||||||
|
|
||||||
| Status | Can submit order? | What portal shows |
|
| Status | Can submit order? | What portal shows |
|
||||||
| ----------- | ----------------: | -------------------------------------- |
|
| --------------- | ----------------: | ----------------------------------------- |
|
||||||
| `REQUIRED` | No | Upload required |
|
| `Not Submitted` | No | Upload required |
|
||||||
| `SUBMITTED` | Yes | Submitted summary + (optional) replace |
|
| `Submitted` | Yes | Submitted summary + (optional) replace |
|
||||||
| `VERIFIED` | Yes | Verified badge only |
|
| `Verified` | Yes | Verified badge only |
|
||||||
|
| `Rejected` | No | Rejection message + resubmission required |
|
||||||
|
|
||||||
## Where to show status (recommended)
|
## 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`
|
- Internet: eligibility banner/status on `/account/shop/internet`
|
||||||
- SIM: verification requirement banner/status on `/account/shop/sim` (optional)
|
- SIM: verification requirement banner/status on `/account/shop/sim` (optional)
|
||||||
- **Checkout**
|
- **Checkout**
|
||||||
- Show the relevant status inline in the “Checkout requirements” section.
|
- Show the relevant status inline near the confirm/requirements cards.
|
||||||
- **Orders**
|
- **Orders**
|
||||||
- Show “Eligibility / ID verification” status on the order detail page so users can track progress after submitting.
|
- Show “Eligibility / ID verification” status on the order detail page so users can track progress after submitting.
|
||||||
- **Dashboard**
|
- **Dashboard**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user