Enhance Internet Eligibility Features and Update Catalog Integration

- Added new fields for internet eligibility in the environment validation schema to support Salesforce integration.
- Updated CatalogCdcSubscriber to extract and handle additional eligibility details from Salesforce events.
- Refactored InternetEligibilityController to return detailed eligibility information, improving user experience.
- Enhanced CatalogCacheService with a new method for setting eligibility details, optimizing cache management.
- Updated InternetCatalogService to retrieve and process comprehensive eligibility data, ensuring accurate service availability checks.
- Improved public-facing components to reflect the new eligibility status and provide clearer user guidance during the checkout process.
This commit is contained in:
barsa 2025-12-19 15:15:36 +09:00
parent 7ab5e12051
commit ab429f91dc
23 changed files with 1244 additions and 256 deletions

View File

@ -132,6 +132,27 @@ export const envSchema = z.object({
// Salesforce Field Mappings - Account // 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

View File

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

View File

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

View File

@ -184,6 +184,19 @@ export class CatalogCacheService {
} }
} }
/**
* Set eligibility details payload for an account.
* Used by Salesforce Platform Events to push updates into the cache without re-querying Salesforce.
*/
async setEligibilityDetails(accountId: string, payload: unknown): Promise<void> {
const key = this.buildEligibilityKey("", accountId);
if (this.ELIGIBILITY_TTL === null) {
await this.cache.set(key, payload);
} else {
await this.cache.set(key, payload, this.ELIGIBILITY_TTL);
}
}
private async getOrSet<T>( private async getOrSet<T>(
bucket: "catalog" | "static" | "volatile" | "eligibility", bucket: "catalog" | "static" | "volatile" | "eligibility",
key: string, key: string,

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { 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 {

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import { useState, useCallback } from "react";
import Link from "next/link"; import 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>({

View File

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

View File

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

View File

@ -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() {
Were verifying whether our service is available at your residence. Were 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 isnt 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="Were 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. Well Our team will verify NTT serviceability and update your eligible offerings. Well
notify you on your dashboard when review is complete. notify you when review is complete.
</p> </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">
Well notify you when review is complete.
</p>
{requestedAt ? (
<p className="text-xs text-muted-foreground">
Requested: {new Date(requestedAt).toLocaleString()}
</p>
) : null}
</div>
</AlertBanner>
)}
{isIneligible && (
<AlertBanner variant="warning" title="Service not available" className="mb-8">
<div className="space-y-2">
{rejectionNotes ? (
<p className="text-sm text-foreground/80">{rejectionNotes}</p>
) : (
<p className="text-sm text-foreground/80">
If you believe this is incorrect, contact support and well take another look.
</p>
)}
<Button as="a" href="/account/support/new" size="sm">
Contact support
</Button>
</div>
</AlertBanner>
)}
{hasActiveInternet && ( {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>

View File

@ -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="Well verify service availability for your address, then show personalized internet plans and configuration options." description="Well 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>
); );
} }

View File

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

View File

@ -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 isnt available right now.
</p>
{eligibilityNotes ? (
<p className="text-xs text-muted-foreground">{eligibilityNotes}</p>
) : eligibilityRequestedAt ? (
<p className="text-xs text-muted-foreground">
Last updated: {new Date(eligibilityRequestedAt).toLocaleString()}
</p>
) : null}
<Button as="a" href="/account/support/new" size="sm">
Contact support
</Button>
</div>
</AlertBanner>
) : null} ) : 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 couldnt 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). Well verify it before activating SIM Upload a JPG, PNG, or PDF (max 5MB). Well 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}

View File

@ -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 isnt available right now. Contact
support if you believe this is incorrect.
</AlertBanner>
) : isPending ? (
<AlertBanner variant="info" title="Review in progress" elevated>
Were reviewing service availability for your address. Once confirmed, you can return
and complete checkout.
</AlertBanner>
) : ( ) : (
<div className="space-y-4"> <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>

View File

@ -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). Well review it and notify you when its Upload a JPG, PNG, or PDF (max 5MB). Well review it and notify you when its
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"

View 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>
)} )}

View File

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

View File

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

View File

@ -0,0 +1,195 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
import { StatusPill } from "@/components/atoms/status-pill";
import {
useResidenceCardVerification,
useSubmitResidenceCard,
} from "@/features/verification/hooks/useResidenceCardVerification";
function formatDateTime(iso?: string | null): string | null {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
date
);
}
export function ResidenceCardVerificationSettingsView() {
const router = useRouter();
const searchParams = useSearchParams();
const returnTo = searchParams?.get("returnTo")?.trim() || "";
const residenceCardQuery = useResidenceCardVerification({ enabled: true });
const submitResidenceCard = useSubmitResidenceCard();
const [residenceFile, setResidenceFile] = useState<File | null>(null);
const residenceFileInputRef = useRef<HTMLInputElement | null>(null);
const status = residenceCardQuery.data?.status;
const statusPill = useMemo(() => {
if (status === "verified") return <StatusPill label="Verified" variant="success" />;
if (status === "pending") return <StatusPill label="Submitted" variant="info" />;
if (status === "rejected") return <StatusPill label="Action needed" variant="warning" />;
return <StatusPill label="Not submitted" variant="warning" />;
}, [status]);
const canUpload = status !== "verified";
return (
<PageLayout
title="ID Verification"
description="Upload your residence card for SIM activation"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-2xl mx-auto space-y-6">
<SubCard
title="Residence card"
icon={<ShieldCheckIcon className="w-5 h-5 text-primary" />}
right={statusPill}
>
{residenceCardQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Checking verification status</div>
) : residenceCardQuery.isError ? (
<AlertBanner variant="warning" title="Unable to load status" size="sm" elevated>
<div className="flex items-center gap-2">
<Button type="button" size="sm" onClick={() => void residenceCardQuery.refetch()}>
Try again
</Button>
</div>
</AlertBanner>
) : status === "verified" ? (
<AlertBanner variant="success" title="Verified" size="sm" elevated>
Your residence card is on file and approved. No further action is required.
</AlertBanner>
) : status === "pending" ? (
<div className="space-y-3">
<AlertBanner variant="info" title="Submitted — under review" size="sm" elevated>
Well verify your residence card before activating SIM service.
</AlertBanner>
{residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Submitted document
</div>
{residenceCardQuery.data?.filename ? (
<div className="mt-1 text-sm font-medium text-foreground">
{residenceCardQuery.data.filename}
</div>
) : null}
{formatDateTime(residenceCardQuery.data?.submittedAt) ? (
<div className="mt-1 text-xs text-muted-foreground">
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
</div>
) : null}
</div>
) : null}
</div>
) : (
<div className="space-y-4">
<AlertBanner
variant={status === "rejected" ? "warning" : "info"}
title={status === "rejected" ? "Rejected — please resubmit" : "Upload required"}
size="sm"
elevated
>
<div className="space-y-2 text-sm text-foreground/80">
{status === "rejected" && residenceCardQuery.data?.reviewerNotes ? (
<div>
<div className="font-medium text-foreground">Rejection note</div>
<div>{residenceCardQuery.data.reviewerNotes}</div>
</div>
) : null}
<p>Upload a clear photo/scan of your residence card (JPG, PNG, or PDF).</p>
</div>
</AlertBanner>
{canUpload ? (
<div className="space-y-3">
<input
ref={residenceFileInputRef}
type="file"
accept="image/*,application/pdf"
onChange={e => setResidenceFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80"
/>
{residenceFile ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">
Selected file
</div>
<div className="text-sm font-medium text-foreground truncate">
{residenceFile.name}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
}}
>
Change
</Button>
</div>
) : null}
<div className="flex items-center justify-end gap-3">
{returnTo ? (
<Button type="button" variant="outline" onClick={() => router.push(returnTo)}>
Back to checkout
</Button>
) : null}
<Button
type="button"
disabled={!residenceFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!residenceFile) return;
submitResidenceCard.mutate(residenceFile, {
onSuccess: () => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
},
});
}}
>
Submit
</Button>
</div>
{submitResidenceCard.isError && (
<div className="text-sm text-destructive">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</div>
)}
</div>
) : null}
</div>
)}
</SubCard>
</div>
</PageLayout>
);
}
export default ResidenceCardVerificationSettingsView;

View File

@ -31,23 +31,29 @@ It also explains how these checks gate checkout and where the portal should disp
- Show current eligibility status (e.g. “Checking”, “Eligible for …”, “Not available”, “Action needed”). - 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 orders “ID review” is approved:
- sets Account `Id Verification Status = Verified`
- sets `Id Verification Verified Date Time`
- links (or copies) the approved File to the Account (so its “on file” for future orders)
- optionally writes a note to `Id Verification Note`
**Portal implications:**
- Current order detail page can show: “ID verification: Submitted/Verified” sourced from the Account, plus (optionally) a link to the specific file attached to the Order.
- SIM checkout becomes very simple:
- `Not Submitted`: upload required
- `Submitted`: allow order submission, show “Submitted”
- `Verified`: no upload UI, no “change” UI
### Rejections + resubmission (recommended UX)
Even if you keep a 3-state Account field, the portal can still display “rejected” as a _derived_ UI state using notes/timestamps.
Recommended approach:
- Use `Id Verification Note` and `Id Verification Rejection Message` for reviewer feedback.
- When a document is not acceptable, set:
- `Id Verification Status = Rejected` (so the portal blocks future SIM submissions until a new file is provided)
- `Id Verification Rejection Message` = rejection reason (“image too blurry”, “expired”, etc.)
Portal UI behavior:
- If `Id Verification Status = Rejected` and `Id Verification Rejection Message` is non-empty:
- show “ID verification rejected” + “Rejection note”
- show an “Id Verification Rejection Message” block that tells the customer what to do next
- Example content:
- “Your ID verification was rejected. Please upload a new, clear photo/scan of your residence card.”
- “Make sure all corners are visible, the text is readable, and the document is not expired.”
- “After you resubmit, review will restart.”
- When the user uploads again:
- overwrite the prior file (or create a new version) and set status back to `Submitted`
- clear/replace the notes so the UI doesnt keep showing stale rejection reasons
### Gating rules (SIM checkout) ### 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**