From ab429f91dc54448ee46a9dea1d0e62e12ce23b4d Mon Sep 17 00:00:00 2001 From: barsa Date: Fri, 19 Dec 2025 15:15:36 +0900 Subject: [PATCH] Enhance Internet Eligibility Features and Update Catalog Integration - Added new fields for internet eligibility in the environment validation schema to support Salesforce integration. - Updated CatalogCdcSubscriber to extract and handle additional eligibility details from Salesforce events. - Refactored InternetEligibilityController to return detailed eligibility information, improving user experience. - Enhanced CatalogCacheService with a new method for setting eligibility details, optimizing cache management. - Updated InternetCatalogService to retrieve and process comprehensive eligibility data, ensuring accurate service availability checks. - Improved public-facing components to reflect the new eligibility status and provide clearer user guidance during the checkout process. --- apps/bff/src/core/config/env.validation.ts | 21 ++ .../events/catalog-cdc.subscriber.ts | 41 ++- .../internet-eligibility.controller.ts | 10 +- .../catalog/services/catalog-cache.service.ts | 13 + .../services/internet-catalog.service.ts | 267 ++++++++++++--- .../services/order-validator.service.ts | 19 ++ .../verification/residence-card.service.ts | 309 +++++++++++++++--- .../verification/verification.module.ts | 6 +- .../account/settings/verification/page.tsx | 5 + .../auth/components/SignupForm/SignupForm.tsx | 14 +- .../src/features/auth/hooks/use-auth.ts | 12 +- .../catalog/services/catalog.service.ts | 15 +- .../features/catalog/views/InternetPlans.tsx | 153 ++++++--- .../catalog/views/PublicInternetConfigure.tsx | 25 +- .../catalog/views/PublicSimConfigure.tsx | 20 +- .../components/AccountCheckoutContainer.tsx | 123 ++++++- .../components/steps/AvailabilityStep.tsx | 21 +- .../checkout/components/steps/PaymentStep.tsx | 19 ++ .../checkout/components/steps/ReviewStep.tsx | 29 +- .../dashboard/hooks/useDashboardTasks.ts | 12 +- .../dashboard/views/DashboardView.tsx | 11 +- .../ResidenceCardVerificationSettingsView.tsx | 195 +++++++++++ .../eligibility-and-verification.md | 160 ++++++--- 23 files changed, 1244 insertions(+), 256 deletions(-) create mode 100644 apps/portal/src/app/account/settings/verification/page.tsx create mode 100644 apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index abdb2964..a6969389 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -132,6 +132,27 @@ export const envSchema = z.object({ // Salesforce Field Mappings - Account ACCOUNT_INTERNET_ELIGIBILITY_FIELD: z.string().default("Internet_Eligibility__c"), + ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD: z.string().default("Internet_Eligibility_Status__c"), + ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD: z + .string() + .default("Internet_Eligibility_Request_Date_Time__c"), + ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z + .string() + .default("Internet_Eligibility_Checked_Date_Time__c"), + ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD: z.string().default("Internet_Eligibility_Notes__c"), + ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD: z.string().default("Internet_Eligibility_Case_Id__c"), + + ACCOUNT_ID_VERIFICATION_STATUS_FIELD: z.string().default("Id_Verification_Status__c"), + ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD: z + .string() + .default("Id_Verification_Submitted_Date_Time__c"), + ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD: z + .string() + .default("Id_Verification_Verified_Date_Time__c"), + ACCOUNT_ID_VERIFICATION_NOTE_FIELD: z.string().default("Id_Verification_Note__c"), + ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD: z + .string() + .default("Id_Verification_Rejection_Message__c"), ACCOUNT_CUSTOMER_NUMBER_FIELD: z.string().default("SF_Account_No__c"), // Salesforce Field Mappings - Product diff --git a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts index 8ba18a2b..99fdba30 100644 --- a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts @@ -269,10 +269,16 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy { if (!this.isDataCallback(callbackType)) return; const payload = this.extractPayload(data); const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]); - const eligibility = this.extractStringField(payload, [ - "Internet_Eligibility__c", - "InternetEligibility__c", + const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]); + const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]); + const requestedAt = this.extractStringField(payload, [ + "Internet_Eligibility_Request_Date_Time__c", ]); + const checkedAt = this.extractStringField(payload, [ + "Internet_Eligibility_Checked_Date_Time__c", + ]); + const notes = this.extractStringField(payload, ["Internet_Eligibility_Notes__c"]); + const requestId = this.extractStringField(payload, ["Internet_Eligibility_Case_Id__c"]); if (!accountId) { this.logger.warn("Account eligibility event missing AccountId", { @@ -288,7 +294,19 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy { }); await this.catalogCache.invalidateEligibility(accountId); - await this.catalogCache.setEligibilityValue(accountId, eligibility ?? null); + const hasDetails = Boolean( + status || eligibility || requestedAt || checkedAt || notes || requestId + ); + if (hasDetails) { + await this.catalogCache.setEligibilityDetails(accountId, { + status: this.mapEligibilityStatus(status, eligibility), + eligibility: eligibility ?? null, + requestId: requestId ?? null, + requestedAt: requestedAt ?? null, + checkedAt: checkedAt ?? null, + notes: notes ?? null, + }); + } // Notify connected portals immediately (multi-instance safe via Redis pub/sub) this.realtime.publish(`account:sf:${accountId}`, "catalog.eligibility.changed", { @@ -296,6 +314,21 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy { }); } + private mapEligibilityStatus( + statusRaw: string | undefined, + eligibilityRaw: string | undefined + ): "not_requested" | "pending" | "eligible" | "ineligible" { + const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : ""; + const eligibility = typeof eligibilityRaw === "string" ? eligibilityRaw.trim() : ""; + + if (normalizedStatus === "pending" || normalizedStatus === "checking") return "pending"; + if (normalizedStatus === "eligible") return "eligible"; + if (normalizedStatus === "ineligible" || normalizedStatus === "not available") + return "ineligible"; + if (eligibility.length > 0) return "eligible"; + return "not_requested"; + } + private async invalidateAllCatalogs(): Promise { try { await this.catalogCache.invalidateAllCatalogs(); diff --git a/apps/bff/src/modules/catalog/internet-eligibility.controller.ts b/apps/bff/src/modules/catalog/internet-eligibility.controller.ts index 03b35a9d..d20f4765 100644 --- a/apps/bff/src/modules/catalog/internet-eligibility.controller.ts +++ b/apps/bff/src/modules/catalog/internet-eligibility.controller.ts @@ -3,7 +3,10 @@ import { ZodValidationPipe } from "nestjs-zod"; import { z } from "zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; -import { InternetCatalogService } from "./services/internet-catalog.service.js"; +import { + InternetCatalogService, + type InternetEligibilityDto, +} from "./services/internet-catalog.service.js"; import { addressSchema } from "@customer-portal/domain/customer"; const eligibilityRequestSchema = z.object({ @@ -30,9 +33,8 @@ export class InternetEligibilityController { @Get("eligibility") @RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap) - async getEligibility(@Req() req: RequestWithUser): Promise<{ eligibility: string | null }> { - const eligibility = await this.internetCatalog.getEligibilityForUser(req.user.id); - return { eligibility }; + async getEligibility(@Req() req: RequestWithUser): Promise { + return this.internetCatalog.getEligibilityDetailsForUser(req.user.id); } @Post("eligibility-request") diff --git a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts index 3ed8508c..7a6013a6 100644 --- a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts +++ b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts @@ -184,6 +184,19 @@ export class CatalogCacheService { } } + /** + * Set eligibility details payload for an account. + * Used by Salesforce Platform Events to push updates into the cache without re-querying Salesforce. + */ + async setEligibilityDetails(accountId: string, payload: unknown): Promise { + const key = this.buildEligibilityKey("", accountId); + if (this.ELIGIBILITY_TTL === null) { + await this.cache.set(key, payload); + } else { + await this.cache.set(key, payload, this.ELIGIBILITY_TTL); + } + } + private async getOrSet( bucket: "catalog" | "static" | "volatile" | "eligibility", key: string, diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index 17b01fd0..9f478d25 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { BaseCatalogService } from "./base-catalog.service.js"; import { CatalogCacheService } from "./catalog-cache.service.js"; @@ -19,24 +19,31 @@ import { SalesforceConnection } from "@bff/integrations/salesforce/services/sale import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; -import { buildAccountEligibilityQuery } from "@bff/integrations/salesforce/utils/catalog-query-builder.js"; +import { assertSoqlFieldName } from "@bff/integrations/salesforce/utils/soql.util.js"; import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js"; +import type { SalesforceResponse } from "@customer-portal/domain/common"; -interface SalesforceAccount { - Id: string; - Internet_Eligibility__c?: string; +export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible"; + +export interface InternetEligibilityDto { + status: InternetEligibilityStatusDto; + eligibility: string | null; + requestId: string | null; + requestedAt: string | null; + checkedAt: string | null; + notes: string | null; } @Injectable() export class InternetCatalogService extends BaseCatalogService { constructor( sf: SalesforceConnection, - configService: ConfigService, + private readonly config: ConfigService, @Inject(Logger) logger: Logger, private mappingsService: MappingsService, private catalogCache: CatalogCacheService ) { - super(sf, configService, logger); + super(sf, config, logger); } async getPlans(): Promise { @@ -173,21 +180,17 @@ export class InternetCatalogService extends BaseCatalogService { // Get customer's eligibility from Salesforce const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId); - const account = await this.catalogCache.getCachedEligibility( + const details = await this.catalogCache.getCachedEligibility( eligibilityKey, - async () => { - const soql = buildAccountEligibilityQuery(sfAccountId); - const accounts = await this.executeQuery(soql, "Customer Eligibility"); - return accounts.length > 0 ? (accounts[0] as unknown as SalesforceAccount) : null; - } + async () => this.queryEligibilityDetails(sfAccountId) ); - if (!account) { + if (!details) { this.logger.warn(`No Salesforce account found for user ${userId}, returning all plans`); return allPlans; } - const eligibility = account.Internet_Eligibility__c; + const eligibility = details.eligibility; if (!eligibility) { this.logger.log(`No eligibility field for user ${userId}, filtering to Home 1G plans only`); @@ -220,23 +223,29 @@ export class InternetCatalogService extends BaseCatalogService { } async getEligibilityForUser(userId: string): Promise { + const details = await this.getEligibilityDetailsForUser(userId); + return details.eligibility; + } + + async getEligibilityDetailsForUser(userId: string): Promise { const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.sfAccountId) { - return null; + return { + status: "not_requested", + eligibility: null, + requestId: null, + requestedAt: null, + checkedAt: null, + notes: null, + }; } const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId); - const account = await this.catalogCache.getCachedEligibility( + return this.catalogCache.getCachedEligibility( eligibilityKey, - async () => { - const soql = buildAccountEligibilityQuery(sfAccountId); - const accounts = await this.executeQuery(soql, "Customer Eligibility"); - return accounts.length > 0 ? (accounts[0] as unknown as SalesforceAccount) : null; - } + async () => this.queryEligibilityDetails(sfAccountId) ); - - return account?.Internet_Eligibility__c ?? null; } async requestEligibilityCheckForUser( @@ -250,6 +259,15 @@ export class InternetCatalogService extends BaseCatalogService { const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); + if ( + !request.address || + !request.address.address1 || + !request.address.city || + !request.address.postcode + ) { + throw new BadRequestException("Service address is required to request eligibility review."); + } + const subject = "Internet availability check request (Portal)"; const descriptionLines: string[] = [ "Portal internet availability check requested.", @@ -264,31 +282,23 @@ export class InternetCatalogService extends BaseCatalogService { `RequestedAt: ${new Date().toISOString()}`, ].filter(Boolean); - const taskPayload: Record = { - Subject: subject, - Description: descriptionLines.join("\n"), - WhatId: sfAccountId, - }; - try { - const create = this.sf.sobject("Task")?.create; - if (!create) { - throw new Error("Salesforce Task create method not available"); - } + const requestId = await this.createEligibilityCaseOrTask(sfAccountId, { + subject, + description: descriptionLines.join("\n"), + }); - const result = await create(taskPayload); - const id = (result as { id?: unknown })?.id; - if (typeof id !== "string" || id.trim().length === 0) { - throw new Error("Salesforce did not return a Task id"); - } + await this.updateAccountEligibilityRequestState(sfAccountId, requestId); + + await this.catalogCache.invalidateEligibility(sfAccountId); this.logger.log("Created Salesforce Task for internet eligibility request", { userId, sfAccountIdTail: sfAccountId.slice(-4), - taskIdTail: id.slice(-4), + taskIdTail: requestId.slice(-4), }); - return id; + return requestId; } catch (error) { this.logger.error("Failed to create Salesforce Task for internet eligibility request", { userId, @@ -304,6 +314,181 @@ export class InternetCatalogService extends BaseCatalogService { // e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G" return plan.internetOfferingType === eligibility; } + + private async queryEligibilityDetails(sfAccountId: string): Promise { + const eligibilityField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_FIELD") ?? "Internet_Eligibility__c", + "ACCOUNT_INTERNET_ELIGIBILITY_FIELD" + ); + const statusField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ?? + "Internet_Eligibility_Status__c", + "ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD" + ); + const requestedAtField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ?? + "Internet_Eligibility_Request_Date_Time__c", + "ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD" + ); + const checkedAtField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD") ?? + "Internet_Eligibility_Checked_Date_Time__c", + "ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD" + ); + const notesField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD") ?? + "Internet_Eligibility_Notes__c", + "ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD" + ); + const caseIdField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ?? + "Internet_Eligibility_Case_Id__c", + "ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD" + ); + + const soql = ` + SELECT Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField}, ${notesField}, ${caseIdField} + FROM Account + WHERE Id = '${sfAccountId}' + LIMIT 1 + `; + + const res = (await this.sf.query(soql, { + label: "catalog:internet:eligibility_details", + })) as SalesforceResponse>; + const record = (res.records?.[0] as Record | undefined) ?? undefined; + if (!record) { + return { + status: "not_requested", + eligibility: null, + requestId: null, + requestedAt: null, + checkedAt: null, + notes: null, + }; + } + + const eligibilityRaw = record[eligibilityField]; + const eligibility = + typeof eligibilityRaw === "string" && eligibilityRaw.trim().length > 0 + ? eligibilityRaw.trim() + : null; + + const statusRaw = record[statusField]; + const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : ""; + + const status: InternetEligibilityStatusDto = + normalizedStatus === "pending" || normalizedStatus === "checking" + ? "pending" + : normalizedStatus === "eligible" + ? "eligible" + : normalizedStatus === "ineligible" || normalizedStatus === "not available" + ? "ineligible" + : eligibility + ? "eligible" + : "not_requested"; + + const requestIdRaw = record[caseIdField]; + const requestId = + typeof requestIdRaw === "string" && requestIdRaw.trim() ? requestIdRaw.trim() : null; + + const requestedAtRaw = record[requestedAtField]; + const checkedAtRaw = record[checkedAtField]; + const notesRaw = record[notesField]; + + const requestedAt = + typeof requestedAtRaw === "string" + ? requestedAtRaw + : requestedAtRaw instanceof Date + ? requestedAtRaw.toISOString() + : null; + const checkedAt = + typeof checkedAtRaw === "string" + ? checkedAtRaw + : checkedAtRaw instanceof Date + ? checkedAtRaw.toISOString() + : null; + const notes = typeof notesRaw === "string" && notesRaw.trim() ? notesRaw.trim() : null; + + return { status, eligibility, requestId, requestedAt, checkedAt, notes }; + } + + private async createEligibilityCaseOrTask( + sfAccountId: string, + payload: { subject: string; description: string } + ): Promise { + const caseCreate = this.sf.sobject("Case")?.create; + if (caseCreate) { + try { + const result = await caseCreate({ + Subject: payload.subject, + Description: payload.description, + Origin: "Portal", + AccountId: sfAccountId, + }); + const id = (result as { id?: unknown })?.id; + if (typeof id === "string" && id.trim().length > 0) { + return id; + } + } catch (error) { + this.logger.warn( + "Failed to create Salesforce Case for eligibility request; falling back to Task", + { + sfAccountIdTail: sfAccountId.slice(-4), + error: getErrorMessage(error), + } + ); + } + } + + const taskCreate = this.sf.sobject("Task")?.create; + if (!taskCreate) { + throw new Error("Salesforce Case/Task create methods not available"); + } + const result = await taskCreate({ + Subject: payload.subject, + Description: payload.description, + WhatId: sfAccountId, + }); + const id = (result as { id?: unknown })?.id; + if (typeof id !== "string" || id.trim().length === 0) { + throw new Error("Salesforce did not return a request id"); + } + return id; + } + + private async updateAccountEligibilityRequestState( + sfAccountId: string, + requestId: string + ): Promise { + const statusField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ?? + "Internet_Eligibility_Status__c", + "ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD" + ); + const requestedAtField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ?? + "Internet_Eligibility_Request_Date_Time__c", + "ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD" + ); + const caseIdField = assertSoqlFieldName( + this.config.get("ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD") ?? + "Internet_Eligibility_Case_Id__c", + "ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD" + ); + + const update = this.sf.sobject("Account")?.update; + if (!update) { + throw new Error("Salesforce Account update method not available"); + } + + await update({ + Id: sfAccountId, + [statusField]: "Pending", + [requestedAtField]: new Date().toISOString(), + [caseIdField]: requestId, + }); + } } function formatAddressForLog(address: Record): string { diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index 6c58372a..1b0c0255 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -14,6 +14,7 @@ import type { Providers } from "@customer-portal/domain/subscriptions"; type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw; import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js"; +import { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service.js"; import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js"; import { PaymentValidatorService } from "./payment-validator.service.js"; import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js"; @@ -32,6 +33,7 @@ export class OrderValidator { private readonly whmcs: WhmcsConnectionOrchestratorService, private readonly pricebookService: OrderPricebookService, private readonly simCatalogService: SimCatalogService, + private readonly internetCatalogService: InternetCatalogService, private readonly paymentValidator: PaymentValidatorService, private readonly residenceCards: ResidenceCardService ) {} @@ -311,6 +313,23 @@ export class OrderValidator { // 4. Order-specific business validation if (businessValidatedBody.orderType === "Internet") { + const eligibility = await this.internetCatalogService.getEligibilityDetailsForUser(userId); + if (eligibility.status === "not_requested") { + throw new BadRequestException( + "Internet eligibility review is required before ordering. Please request an eligibility review from the Internet shop page and try again." + ); + } + if (eligibility.status === "pending") { + throw new BadRequestException( + "Internet eligibility review is still in progress. Please wait for review to complete and try again." + ); + } + if (eligibility.status === "ineligible") { + throw new BadRequestException( + "Internet service is not available for your address. Please contact support if you believe this is incorrect." + ); + } + await this.validateInternetDuplication(userId, userMapping.whmcsClientId); } diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts index 74a19b5d..3a826de7 100644 --- a/apps/bff/src/modules/verification/residence-card.service.ts +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -1,6 +1,15 @@ -import { Injectable } from "@nestjs/common"; -import { PrismaService } from "@bff/infra/database/prisma.service.js"; -import { ResidenceCardStatus, type ResidenceCardSubmission } from "@prisma/client"; +import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; +import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { + assertSalesforceId, + assertSoqlFieldName, +} from "@bff/integrations/salesforce/utils/soql.util.js"; +import type { SalesforceResponse } from "@customer-portal/domain/common"; +import { getErrorMessage } from "@bff/core/utils/error.util.js"; +import { basename, extname } from "node:path"; type ResidenceCardStatusDto = "not_submitted" | "pending" | "verified" | "rejected"; @@ -14,42 +23,106 @@ export interface ResidenceCardVerificationDto { reviewerNotes: string | null; } -function mapStatus(status: ResidenceCardStatus): ResidenceCardStatusDto { - if (status === ResidenceCardStatus.VERIFIED) return "verified"; - if (status === ResidenceCardStatus.REJECTED) return "rejected"; - return "pending"; -} - -function toDto(record: ResidenceCardSubmission | null): ResidenceCardVerificationDto { - if (!record) { - return { - status: "not_submitted", - filename: null, - mimeType: null, - sizeBytes: null, - submittedAt: null, - reviewedAt: null, - reviewerNotes: null, - }; - } - return { - status: mapStatus(record.status), - filename: record.filename ?? null, - mimeType: record.mimeType ?? null, - sizeBytes: typeof record.sizeBytes === "number" ? record.sizeBytes : null, - submittedAt: record.submittedAt ? record.submittedAt.toISOString() : null, - reviewedAt: record.reviewedAt ? record.reviewedAt.toISOString() : null, - reviewerNotes: record.reviewerNotes ?? null, - }; +function mapFileTypeToMime(fileType?: string | null): string | null { + const normalized = String(fileType || "") + .trim() + .toLowerCase(); + if (normalized === "pdf") return "application/pdf"; + if (normalized === "png") return "image/png"; + if (normalized === "jpg" || normalized === "jpeg") return "image/jpeg"; + return null; } @Injectable() export class ResidenceCardService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly sf: SalesforceConnection, + private readonly mappings: MappingsService, + private readonly config: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) {} async getStatusForUser(userId: string): Promise { - const record = await this.prisma.residenceCardSubmission.findUnique({ where: { userId } }); - return toDto(record); + const mapping = await this.mappings.findByUserId(userId); + const sfAccountId = mapping?.sfAccountId + ? assertSalesforceId(mapping.sfAccountId, "sfAccountId") + : null; + if (!sfAccountId) { + return { + status: "not_submitted", + filename: null, + mimeType: null, + sizeBytes: null, + submittedAt: null, + reviewedAt: null, + reviewerNotes: null, + }; + } + + const fields = this.getAccountFieldNames(); + const soql = ` + SELECT Id, ${fields.status}, ${fields.submittedAt}, ${fields.verifiedAt}, ${fields.note}, ${fields.rejectionMessage} + FROM Account + WHERE Id = '${sfAccountId}' + LIMIT 1 + `; + + const accountRes = (await this.sf.query(soql, { + label: "verification:residence_card:account", + })) as SalesforceResponse>; + + const account = (accountRes.records?.[0] as Record | undefined) ?? undefined; + const statusRaw = account ? account[fields.status] : undefined; + const statusText = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : ""; + + const status: ResidenceCardStatusDto = + statusText === "verified" + ? "verified" + : statusText === "rejected" + ? "rejected" + : statusText === "submitted" + ? "pending" + : statusText === "not submitted" || statusText === "not_submitted" || statusText === "" + ? "not_submitted" + : "pending"; + + const submittedAtRaw = account ? account[fields.submittedAt] : undefined; + const verifiedAtRaw = account ? account[fields.verifiedAt] : undefined; + const noteRaw = account ? account[fields.note] : undefined; + const rejectionRaw = account ? account[fields.rejectionMessage] : undefined; + + const submittedAt = + typeof submittedAtRaw === "string" + ? submittedAtRaw + : submittedAtRaw instanceof Date + ? submittedAtRaw.toISOString() + : null; + const reviewedAt = + typeof verifiedAtRaw === "string" + ? verifiedAtRaw + : verifiedAtRaw instanceof Date + ? verifiedAtRaw.toISOString() + : null; + + const reviewerNotes = + typeof rejectionRaw === "string" && rejectionRaw.trim().length > 0 + ? rejectionRaw.trim() + : typeof noteRaw === "string" && noteRaw.trim().length > 0 + ? noteRaw.trim() + : null; + + const fileMeta = + status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId); + + return { + status, + filename: fileMeta?.filename ?? null, + mimeType: fileMeta?.mimeType ?? null, + sizeBytes: typeof fileMeta?.sizeBytes === "number" ? fileMeta.sizeBytes : null, + submittedAt: submittedAt ?? fileMeta?.submittedAt ?? null, + reviewedAt, + reviewerNotes, + }; } async submitForUser(params: { @@ -59,29 +132,157 @@ export class ResidenceCardService { sizeBytes: number; content: Uint8Array; }): Promise { - const record = await this.prisma.residenceCardSubmission.upsert({ - where: { userId: params.userId }, - create: { + const mapping = await this.mappings.findByUserId(params.userId); + if (!mapping?.sfAccountId) { + throw new Error("No Salesforce mapping found for current user"); + } + const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); + + const fileBuffer = Buffer.from(params.content as unknown as Uint8Array); + const versionData = fileBuffer.toString("base64"); + const extension = extname(params.filename || "").replace(/^\./, ""); + const title = basename(params.filename || "residence-card", extension ? `.${extension}` : ""); + + const create = this.sf.sobject("ContentVersion")?.create; + if (!create) { + throw new Error("Salesforce ContentVersion create method not available"); + } + + try { + const result = await create({ + Title: title || "residence-card", + PathOnClient: params.filename || "residence-card", + VersionData: versionData, + FirstPublishLocationId: sfAccountId, + }); + const id = (result as { id?: unknown })?.id; + if (typeof id !== "string" || id.trim().length === 0) { + throw new Error("Salesforce did not return a ContentVersion id"); + } + } catch (error) { + this.logger.error("Failed to upload residence card to Salesforce Files", { userId: params.userId, - status: ResidenceCardStatus.PENDING, - filename: params.filename, - mimeType: params.mimeType, - sizeBytes: params.sizeBytes, - content: params.content, - submittedAt: new Date(), - }, - update: { - status: ResidenceCardStatus.PENDING, - filename: params.filename, - mimeType: params.mimeType, - sizeBytes: params.sizeBytes, - content: params.content, - submittedAt: new Date(), - reviewedAt: null, - reviewerNotes: null, - }, + sfAccountIdTail: sfAccountId.slice(-4), + error: getErrorMessage(error), + }); + throw new Error("Failed to submit residence card. Please try again later."); + } + + const fields = this.getAccountFieldNames(); + const update = this.sf.sobject("Account")?.update; + if (!update) { + throw new Error("Salesforce Account update method not available"); + } + + await update({ + Id: sfAccountId, + [fields.status]: "Submitted", + [fields.submittedAt]: new Date().toISOString(), + [fields.rejectionMessage]: null, + [fields.note]: null, }); - return toDto(record); + return this.getStatusForUser(params.userId); + } + + private getAccountFieldNames(): { + status: string; + submittedAt: string; + verifiedAt: string; + note: string; + rejectionMessage: string; + } { + return { + status: assertSoqlFieldName( + this.config.get("ACCOUNT_ID_VERIFICATION_STATUS_FIELD") ?? + "Id_Verification_Status__c", + "ACCOUNT_ID_VERIFICATION_STATUS_FIELD" + ), + submittedAt: assertSoqlFieldName( + this.config.get("ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD") ?? + "Id_Verification_Submitted_Date_Time__c", + "ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD" + ), + verifiedAt: assertSoqlFieldName( + this.config.get("ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD") ?? + "Id_Verification_Verified_Date_Time__c", + "ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD" + ), + note: assertSoqlFieldName( + this.config.get("ACCOUNT_ID_VERIFICATION_NOTE_FIELD") ?? "Id_Verification_Note__c", + "ACCOUNT_ID_VERIFICATION_NOTE_FIELD" + ), + rejectionMessage: assertSoqlFieldName( + this.config.get("ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD") ?? + "Id_Verification_Rejection_Message__c", + "ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD" + ), + }; + } + + private async getLatestAccountFileMetadata(accountId: string): Promise<{ + filename: string | null; + mimeType: string | null; + sizeBytes: number | null; + submittedAt: string | null; + } | null> { + try { + const linkSoql = ` + SELECT ContentDocumentId + FROM ContentDocumentLink + WHERE LinkedEntityId = '${accountId}' + ORDER BY SystemModstamp DESC + LIMIT 1 + `; + const linkRes = (await this.sf.query(linkSoql, { + label: "verification:residence_card:latest_link", + })) as SalesforceResponse<{ ContentDocumentId?: string }>; + const documentId = linkRes.records?.[0]?.ContentDocumentId; + if (!documentId) return null; + + const versionSoql = ` + SELECT Title, FileExtension, FileType, ContentSize, CreatedDate + FROM ContentVersion + WHERE ContentDocumentId = '${documentId}' + ORDER BY CreatedDate DESC + LIMIT 1 + `; + const versionRes = (await this.sf.query(versionSoql, { + label: "verification:residence_card:latest_version", + })) as SalesforceResponse>; + const version = (versionRes.records?.[0] as Record | undefined) ?? undefined; + if (!version) return null; + + const title = typeof version.Title === "string" ? version.Title.trim() : ""; + const ext = typeof version.FileExtension === "string" ? version.FileExtension.trim() : ""; + const fileType = typeof version.FileType === "string" ? version.FileType.trim() : ""; + const sizeBytes = typeof version.ContentSize === "number" ? version.ContentSize : null; + const createdDateRaw = version.CreatedDate; + const submittedAt = + typeof createdDateRaw === "string" + ? createdDateRaw + : createdDateRaw instanceof Date + ? createdDateRaw.toISOString() + : null; + + const filename = title + ? ext && !title.toLowerCase().endsWith(`.${ext.toLowerCase()}`) + ? `${title}.${ext}` + : title + : null; + + return { + filename, + mimeType: mapFileTypeToMime(fileType) ?? mapFileTypeToMime(ext) ?? null, + sizeBytes, + submittedAt, + }; + } catch (error) { + this.logger.warn("Failed to load residence card file metadata from Salesforce", { + accountIdTail: accountId.slice(-4), + error: getErrorMessage(error), + }); + return null; + } } } diff --git a/apps/bff/src/modules/verification/verification.module.ts b/apps/bff/src/modules/verification/verification.module.ts index ac3e871d..7b704b31 100644 --- a/apps/bff/src/modules/verification/verification.module.ts +++ b/apps/bff/src/modules/verification/verification.module.ts @@ -1,10 +1,12 @@ import { Module } from "@nestjs/common"; -import { PrismaModule } from "@bff/infra/database/prisma.module.js"; import { ResidenceCardController } from "./residence-card.controller.js"; import { ResidenceCardService } from "./residence-card.service.js"; +import { IntegrationsModule } from "@bff/integrations/integrations.module.js"; +import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; +import { CoreConfigModule } from "@bff/core/config/config.module.js"; @Module({ - imports: [PrismaModule], + imports: [IntegrationsModule, MappingsModule, CoreConfigModule], controllers: [ResidenceCardController], providers: [ResidenceCardService], exports: [ResidenceCardService], diff --git a/apps/portal/src/app/account/settings/verification/page.tsx b/apps/portal/src/app/account/settings/verification/page.tsx new file mode 100644 index 00000000..ae82dfac --- /dev/null +++ b/apps/portal/src/app/account/settings/verification/page.tsx @@ -0,0 +1,5 @@ +import { ResidenceCardVerificationSettingsView } from "@/features/verification/views/ResidenceCardVerificationSettingsView"; + +export default function AccountResidenceCardVerificationPage() { + return ; +} diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx index dd1f005b..d0879ec6 100644 --- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx @@ -9,7 +9,7 @@ import { useState, useCallback } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { ErrorMessage } from "@/components/atoms"; -import { useSignup } from "../../hooks/use-auth"; +import { useSignupWithRedirect } from "../../hooks/use-auth"; import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/auth"; import { addressFormSchema } from "@customer-portal/domain/customer"; import { useZodForm } from "@/hooks/useZodForm"; @@ -50,6 +50,7 @@ interface SignupFormProps { onSuccess?: () => void; onError?: (error: string) => void; className?: string; + redirectTo?: string; } const STEPS = [ @@ -60,8 +61,8 @@ const STEPS = [ }, { key: "address", - title: "Delivery Address", - description: "Where to ship your SIM card", + title: "Address", + description: "Used for service eligibility and delivery", }, { key: "password", @@ -114,11 +115,12 @@ const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAn }), }; -export function SignupForm({ onSuccess, onError, className = "" }: SignupFormProps) { +export function SignupForm({ onSuccess, onError, className = "", redirectTo }: SignupFormProps) { const searchParams = useSearchParams(); - const { signup, loading, error, clearError } = useSignup(); + const { signup, loading, error, clearError } = useSignupWithRedirect({ redirectTo }); const [step, setStep] = useState(0); - const redirect = searchParams?.get("next") || searchParams?.get("redirect"); + const redirectFromQuery = searchParams?.get("next") || searchParams?.get("redirect"); + const redirect = redirectTo || redirectFromQuery; const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""; const form = useZodForm({ diff --git a/apps/portal/src/features/auth/hooks/use-auth.ts b/apps/portal/src/features/auth/hooks/use-auth.ts index cc1ff97b..07782dcf 100644 --- a/apps/portal/src/features/auth/hooks/use-auth.ts +++ b/apps/portal/src/features/auth/hooks/use-auth.ts @@ -53,10 +53,10 @@ export function useAuth() { // Enhanced signup with redirect handling const signup = useCallback( - async (data: SignupRequest) => { + async (data: SignupRequest, options?: { redirectTo?: string }) => { await signupAction(data); - const redirectTo = getPostLoginRedirect(searchParams); - router.push(redirectTo); + const dest = options?.redirectTo ?? getPostLoginRedirect(searchParams); + router.push(dest); }, [signupAction, router, searchParams] ); @@ -115,10 +115,14 @@ export function useLogin() { * Hook for signup functionality */ export function useSignup() { + return useSignupWithRedirect(); +} + +export function useSignupWithRedirect(options?: { redirectTo?: string }) { const { signup, loading, error, clearError } = useAuth(); return { - signup, + signup: (data: SignupRequest) => signup(data, options), loading, error, clearError, diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts index 0952db05..1c662894 100644 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -18,6 +18,17 @@ import { } from "@customer-portal/domain/catalog"; import type { Address } from "@customer-portal/domain/customer"; +export type InternetEligibilityStatus = "not_requested" | "pending" | "eligible" | "ineligible"; + +export interface InternetEligibilityDetails { + status: InternetEligibilityStatus; + eligibility: string | null; + requestId: string | null; + requestedAt: string | null; + checkedAt: string | null; + notes: string | null; +} + export const catalogService = { async getInternetCatalog(): Promise { const response = await apiClient.GET("/api/catalog/internet/plans"); @@ -76,8 +87,8 @@ export const catalogService = { return vpnCatalogProductSchema.array().parse(data); }, - async getInternetEligibility(): Promise<{ eligibility: string | null }> { - const response = await apiClient.GET<{ eligibility: string | null }>( + async getInternetEligibility(): Promise { + const response = await apiClient.GET( "/api/catalog/internet/eligibility" ); return getDataOrThrow(response, "Failed to load internet eligibility"); diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index bf60f36b..e9617d7d 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { PageLayout } from "@/components/templates/PageLayout"; import { WifiIcon, ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline"; import { useInternetCatalog } from "@/features/catalog/hooks"; @@ -35,7 +35,6 @@ export function InternetPlansContainer() { () => data?.installations ?? [], [data?.installations] ); - const [eligibility, setEligibility] = useState(""); const { data: activeSubs } = useActiveSubscriptions(); const hasActiveInternet = useMemo( () => @@ -52,32 +51,38 @@ export function InternetPlansContainer() { ); const eligibilityValue = eligibilityQuery.data?.eligibility; - const requiresAvailabilityCheck = eligibilityQuery.isSuccess && eligibilityValue === null; + const eligibilityStatus = eligibilityQuery.data?.status; + const requestedAt = eligibilityQuery.data?.requestedAt; + const rejectionNotes = eligibilityQuery.data?.notes; + + const isEligible = + eligibilityStatus === "eligible" && + typeof eligibilityValue === "string" && + eligibilityValue.trim().length > 0; + const isPending = eligibilityStatus === "pending"; + const isNotRequested = eligibilityStatus === "not_requested"; + const isIneligible = eligibilityStatus === "ineligible"; + const orderingLocked = isPending || isNotRequested || isIneligible; const hasServiceAddress = Boolean( user?.address?.address1 && user?.address?.city && user?.address?.postcode && (user?.address?.country || user?.address?.countryCode) ); + const addressLabel = useMemo(() => { + const a = user?.address; + if (!a) return ""; + return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode] + .filter(Boolean) + .map(part => String(part).trim()) + .filter(part => part.length > 0) + .join(", "); + }, [user?.address]); - useEffect(() => { - if (!user?.id) return; - if (eligibilityValue !== null) return; - const key = `cp:internet-eligibility:last:${user.id}`; - if (!localStorage.getItem(key)) { - localStorage.setItem(key, "PENDING"); - } - }, [eligibilityValue, user?.id]); - - useEffect(() => { - if (eligibilityQuery.isSuccess) { - if (typeof eligibilityValue === "string" && eligibilityValue.trim().length > 0) { - setEligibility(eligibilityValue); - return; - } - setEligibility(""); - } - }, [eligibilityQuery.isSuccess, eligibilityValue]); + const eligibility = useMemo(() => { + if (!isEligible) return ""; + return eligibilityValue.trim(); + }, [eligibilityValue, isEligible]); const getEligibilityIcon = (offeringType?: string) => { const lower = (offeringType || "").toLowerCase(); @@ -172,7 +177,17 @@ export function InternetPlansContainer() { We’re verifying whether our service is available at your residence.

- ) : requiresAvailabilityCheck ? ( + ) : isNotRequested ? ( +
+
+ Availability review required +
+

+ Request an eligibility review to unlock personalized internet plans for your + residence. +

+
+ ) : isPending ? (
Availability review in progress @@ -182,6 +197,15 @@ export function InternetPlansContainer() { your personalized internet plans.

+ ) : isIneligible ? ( +
+
+ Not available for this address +
+

+ Our team reviewed your address and determined service isn’t available right now. +

+
) : eligibility ? (
- {requiresAvailabilityCheck && ( - + {isNotRequested && ( +

Our team will verify NTT serviceability and update your eligible offerings. We’ll - notify you on your dashboard when review is complete. + notify you when review is complete.

{hasServiceAddress ? ( +
+
+ )} + {hasActiveInternet && ( 0 ? ( <> - {requiresAvailabilityCheck && ( + {orderingLocked && (

- You can browse standard pricing below, but ordering stays locked until we confirm - service availability for your residence. + {isIneligible + ? "Service is not available for your address." + : isNotRequested + ? "Request an eligibility review to unlock ordering for your residence." + : "You can browse standard pricing below, but ordering stays locked until we confirm service availability for your residence."}

)}
- {(requiresAvailabilityCheck ? silverPlans : plans).map(plan => ( + {(orderingLocked ? silverPlans : plans).map(plan => (
diff --git a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx index 4f29f16b..35fb72d5 100644 --- a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx +++ b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/atoms/button"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; +import { SignupForm } from "@/features/auth/components/SignupForm/SignupForm"; /** * Public Internet Configure View @@ -20,32 +21,16 @@ export function PublicInternetConfigureView() { - +
-

- Internet plans depend on your residence and local infrastructure. Create an account so - we can review availability and unlock ordering. -

-
+ +
+ +
); } diff --git a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx index fb3da05c..1ad04e29 100644 --- a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx +++ b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx @@ -6,6 +6,7 @@ import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackL import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; import { useSearchParams } from "next/navigation"; +import { SignupForm } from "@/features/auth/components/SignupForm/SignupForm"; /** * Public SIM Configure View @@ -24,27 +25,16 @@ export function PublicSimConfigureView() { - +
-

- Create an account to add your payment method and submit your residence card for review. -

-
+ +
+ +
); } diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx index c17f2af1..5e3e2567 100644 --- a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx @@ -18,21 +18,21 @@ import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants"; import { useInternetEligibility } from "@/features/catalog/hooks/useInternetEligibility"; +import { useRequestInternetEligibilityCheck } from "@/features/catalog/hooks/useInternetEligibility"; import { useResidenceCardVerification, useSubmitResidenceCard, } from "@/features/verification/hooks/useResidenceCardVerification"; +import { useAuthSession } from "@/features/auth/services/auth.store"; import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders"; import type { PaymentMethod } from "@customer-portal/domain/payments"; -const isNonEmptyString = (value: unknown): value is string => - typeof value === "string" && value.trim().length > 0; - export function AccountCheckoutContainer() { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const { user } = useAuthSession(); const { cartItem, checkoutSessionId, clear } = useCheckoutStore(); @@ -94,12 +94,42 @@ export function AccountCheckoutContainer() { const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder }); const eligibilityValue = eligibilityQuery.data?.eligibility; + const eligibilityStatus = eligibilityQuery.data?.status; + const eligibilityRequestedAt = eligibilityQuery.data?.requestedAt; + const eligibilityNotes = eligibilityQuery.data?.notes; + const eligibilityRequest = useRequestInternetEligibilityCheck(); const eligibilityLoading = Boolean(isInternetOrder && eligibilityQuery.isLoading); + const eligibilityNotRequested = Boolean( + isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "not_requested" + ); const eligibilityPending = Boolean( - isInternetOrder && eligibilityQuery.isSuccess && eligibilityValue === null + isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "pending" + ); + const eligibilityIneligible = Boolean( + isInternetOrder && eligibilityQuery.isSuccess && eligibilityStatus === "ineligible" ); const eligibilityError = Boolean(isInternetOrder && eligibilityQuery.isError); - const isEligible = !isInternetOrder || isNonEmptyString(eligibilityValue); + const isEligible = + !isInternetOrder || + (eligibilityStatus === "eligible" && + typeof eligibilityValue === "string" && + eligibilityValue.trim().length > 0); + + const hasServiceAddress = Boolean( + user?.address?.address1 && + user?.address?.city && + user?.address?.postcode && + (user?.address?.country || user?.address?.countryCode) + ); + const addressLabel = useMemo(() => { + const a = user?.address; + if (!a) return ""; + return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode] + .filter(Boolean) + .map(part => String(part).trim()) + .filter(part => part.length > 0) + .join(", "); + }, [user?.address]); const residenceCardQuery = useResidenceCardVerification({ enabled: isSimOrder }); const submitResidenceCard = useSubmitResidenceCard(); @@ -155,11 +185,21 @@ export function AccountCheckoutContainer() { clear(); router.push(`/account/orders/${encodeURIComponent(result.sfOrderId)}?status=success`); } catch (error) { - setSubmitError(error instanceof Error ? error.message : "Order submission failed"); + const message = error instanceof Error ? error.message : "Order submission failed"; + if ( + isSimOrder && + (message.toLowerCase().includes("residence card submission required") || + message.toLowerCase().includes("residence card submission was rejected")) + ) { + const next = `${pathname}${searchParams?.toString() ? `?${searchParams.toString()}` : ""}`; + router.push(`/account/settings/verification?returnTo=${encodeURIComponent(next)}`); + return; + } + setSubmitError(message); } finally { setSubmitting(false); } - }, [checkoutSessionId, clear, router]); + }, [checkoutSessionId, clear, isSimOrder, pathname, router, searchParams]); if (!cartItem || !orderType) { const shopHref = pathname.startsWith("/account") ? "/account/shop" : "/shop"; @@ -228,6 +268,65 @@ export function AccountCheckoutContainer() {
+ ) : eligibilityNotRequested ? ( + +
+ + Request an eligibility review to confirm service availability for your address + before submitting an internet order. + + {hasServiceAddress ? ( + + ) : ( + + )} +
+
+ ) : eligibilityIneligible ? ( + +
+

+ Our team reviewed your address and determined service isn’t available right now. +

+ {eligibilityNotes ? ( +

{eligibilityNotes}

+ ) : eligibilityRequestedAt ? ( +

+ Last updated: {new Date(eligibilityRequestedAt).toLocaleString()} +

+ ) : null} + +
+
) : null}
@@ -598,7 +697,7 @@ export function AccountCheckoutContainer() { variant={residenceStatus === "rejected" ? "warning" : "info"} title={ residenceStatus === "rejected" - ? "Residence card needs resubmission" + ? "ID verification rejected" : "Submit your residence card" } size="sm" @@ -607,9 +706,13 @@ export function AccountCheckoutContainer() {
{residenceStatus === "rejected" && residenceCardQuery.data?.reviewerNotes ? (
-
Reviewer note
+
Rejection note
{residenceCardQuery.data.reviewerNotes}
+ ) : residenceStatus === "rejected" ? ( +

+ Your document couldn’t be approved. Please upload a new file to continue. +

) : null}

Upload a JPG, PNG, or PDF (max 5MB). We’ll verify it before activating SIM @@ -761,6 +864,8 @@ export function AccountCheckoutContainer() { !isEligible || eligibilityLoading || eligibilityPending || + eligibilityNotRequested || + eligibilityIneligible || eligibilityError } isLoading={submitting} diff --git a/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx b/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx index 0e835ba7..0d72fb97 100644 --- a/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/AvailabilityStep.tsx @@ -39,7 +39,14 @@ export function AvailabilityStep() { enabled: canCheckEligibility && isInternetOrder, }); const eligibilityValue = eligibilityQuery.data?.eligibility ?? null; - const isEligible = useMemo(() => isNonEmptyString(eligibilityValue), [eligibilityValue]); + const eligibilityStatus = eligibilityQuery.data?.status; + const isEligible = useMemo( + () => eligibilityStatus === "eligible" && isNonEmptyString(eligibilityValue), + [eligibilityStatus, eligibilityValue] + ); + const isPending = eligibilityStatus === "pending"; + const isNotRequested = eligibilityStatus === "not_requested"; + const isIneligible = eligibilityStatus === "ineligible"; const availabilityRequest = useRequestInternetEligibilityCheck(); const [requestError, setRequestError] = useState(null); @@ -114,6 +121,16 @@ export function AvailabilityStep() { Your account is eligible for: {eligibilityValue} + ) : isIneligible ? ( + + Our team reviewed your address and determined service isn’t available right now. Contact + support if you believe this is incorrect. + + ) : isPending ? ( + + We’re reviewing service availability for your address. Once confirmed, you can return + and complete checkout. + ) : (

} className="w-full" > - Request availability check + {isNotRequested ? "Request availability check" : "Request review again"} )}
diff --git a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx index 840a97ad..d7090a5c 100644 --- a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { usePathname, useRouter } from "next/navigation"; import { useCheckoutStore } from "../../stores/checkout.store"; import { useAuthSession } from "@/features/auth/services/auth.store"; import { Button } from "@/components/atoms/button"; @@ -27,6 +28,8 @@ import { * Opens WHMCS SSO to add payment method and polls for completion. */ export function PaymentStep() { + const router = useRouter(); + const pathname = usePathname(); const { isAuthenticated } = useAuthSession(); const { cartItem, @@ -281,6 +284,22 @@ export function PaymentStep() { Upload a JPG, PNG, or PDF (max 5MB). We’ll review it and notify you when it’s approved.

+ {pathname.startsWith("/account") ? ( + + ) : null}
Go to Payment step + {pathname.startsWith("/account") ? ( + + ) : null}
)} diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts index 6c09aac6..e079a781 100644 --- a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts +++ b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts @@ -58,7 +58,7 @@ interface ComputeTasksParams { summary: DashboardSummary | undefined; paymentMethods: PaymentMethodList | undefined; orders: OrderSummary[] | undefined; - internetEligibility: string | null | undefined; + internetEligibilityStatus: "not_requested" | "pending" | "eligible" | "ineligible" | undefined; formatCurrency: (amount: number, options?: { currency?: string }) => string; } @@ -69,7 +69,7 @@ function computeTasks({ summary, paymentMethods, orders, - internetEligibility, + internetEligibilityStatus, formatCurrency, }: ComputeTasksParams): DashboardTask[] { const tasks: DashboardTask[] = []; @@ -152,8 +152,8 @@ function computeTasks({ } } - // Priority 4: Internet eligibility review (only when value is explicitly null) - if (internetEligibility === null) { + // Priority 4: Internet eligibility review (only when explicitly pending) + if (internetEligibilityStatus === "pending") { tasks.push({ id: "internet-eligibility-review", priority: 4, @@ -225,10 +225,10 @@ export function useDashboardTasks(): UseDashboardTasksResult { summary, paymentMethods, orders, - internetEligibility: eligibility?.eligibility, + internetEligibilityStatus: eligibility?.status, formatCurrency, }), - [summary, paymentMethods, orders, eligibility?.eligibility, formatCurrency] + [summary, paymentMethods, orders, eligibility?.status, formatCurrency] ); return { diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index eefb193f..332e6ca0 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -35,18 +35,19 @@ export function DashboardView() { useEffect(() => { if (!isAuthenticated || !user?.id) return; - const current = eligibility?.eligibility; - if (current === undefined) return; // query not ready yet + const status = eligibility?.status; + if (!status) return; // query not ready yet const key = `cp:internet-eligibility:last:${user.id}`; const last = localStorage.getItem(key); - if (current === null) { + if (status === "pending") { localStorage.setItem(key, "PENDING"); return; } - if (typeof current === "string" && current.trim().length > 0) { + if (status === "eligible" && typeof eligibility?.eligibility === "string") { + const current = eligibility.eligibility.trim(); if (last === "PENDING") { setEligibilityToast({ visible: true, @@ -61,7 +62,7 @@ export function DashboardView() { } localStorage.setItem(key, current); } - }, [eligibility?.eligibility, isAuthenticated, user?.id]); + }, [eligibility?.eligibility, eligibility?.status, isAuthenticated, user?.id]); useEffect(() => { return () => { diff --git a/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx b/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx new file mode 100644 index 00000000..c58ae699 --- /dev/null +++ b/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { PageLayout } from "@/components/templates/PageLayout"; +import { ShieldCheckIcon } from "@heroicons/react/24/outline"; +import { SubCard } from "@/components/molecules/SubCard/SubCard"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { Button } from "@/components/atoms/button"; +import { StatusPill } from "@/components/atoms/status-pill"; +import { + useResidenceCardVerification, + useSubmitResidenceCard, +} from "@/features/verification/hooks/useResidenceCardVerification"; + +function formatDateTime(iso?: string | null): string | null { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format( + date + ); +} + +export function ResidenceCardVerificationSettingsView() { + const router = useRouter(); + const searchParams = useSearchParams(); + const returnTo = searchParams?.get("returnTo")?.trim() || ""; + + const residenceCardQuery = useResidenceCardVerification({ enabled: true }); + const submitResidenceCard = useSubmitResidenceCard(); + + const [residenceFile, setResidenceFile] = useState(null); + const residenceFileInputRef = useRef(null); + + const status = residenceCardQuery.data?.status; + const statusPill = useMemo(() => { + if (status === "verified") return ; + if (status === "pending") return ; + if (status === "rejected") return ; + return ; + }, [status]); + + const canUpload = status !== "verified"; + + return ( + } + > +
+ } + right={statusPill} + > + {residenceCardQuery.isLoading ? ( +
Checking verification status…
+ ) : residenceCardQuery.isError ? ( + +
+ +
+
+ ) : status === "verified" ? ( + + Your residence card is on file and approved. No further action is required. + + ) : status === "pending" ? ( +
+ + We’ll verify your residence card before activating SIM service. + + + {residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? ( +
+
+ Submitted document +
+ {residenceCardQuery.data?.filename ? ( +
+ {residenceCardQuery.data.filename} +
+ ) : null} + {formatDateTime(residenceCardQuery.data?.submittedAt) ? ( +
+ Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)} +
+ ) : null} +
+ ) : null} +
+ ) : ( +
+ +
+ {status === "rejected" && residenceCardQuery.data?.reviewerNotes ? ( +
+
Rejection note
+
{residenceCardQuery.data.reviewerNotes}
+
+ ) : null} +

Upload a clear photo/scan of your residence card (JPG, PNG, or PDF).

+
+
+ + {canUpload ? ( +
+ setResidenceFile(e.target.files?.[0] ?? null)} + className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" + /> + + {residenceFile ? ( +
+
+
+ Selected file +
+
+ {residenceFile.name} +
+
+ +
+ ) : null} + +
+ {returnTo ? ( + + ) : null} + +
+ + {submitResidenceCard.isError && ( +
+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +
+ )} +
+ ) : null} +
+ )} +
+
+
+ ); +} + +export default ResidenceCardVerificationSettingsView; diff --git a/docs/portal-guides/eligibility-and-verification.md b/docs/portal-guides/eligibility-and-verification.md index 390b0531..457bcdc5 100644 --- a/docs/portal-guides/eligibility-and-verification.md +++ b/docs/portal-guides/eligibility-and-verification.md @@ -31,23 +31,29 @@ It also explains how these checks gate checkout and where the portal should disp - Show current eligibility status (e.g. “Checking”, “Eligible for …”, “Not available”, “Action needed”). - If not requested yet: show a single CTA (“Request eligibility review”). - In checkout (Internet orders): - - If eligibility is **PENDING/REQUIRED**, the submit CTA is disabled and we guide the user to the next action. - - If **ELIGIBLE**, proceed normally. + - If eligibility is **Pending/Not Requested**, the submit CTA is disabled and we guide the user to the next action. + - If **Eligible**, proceed normally. ### Target Salesforce Model **Account fields (canonical, cached by portal):** -- `InternetEligibilityStatus__c` (picklist) +- `Internet Eligibility Status` (picklist) - Suggested values: - - `REQUIRED` (no check requested / missing address) - - `PENDING` (case open, awaiting review) - - `ELIGIBLE` (approved) - - `INELIGIBLE` (rejected) -- `InternetEligibilityResult__c` (text/picklist; optional) + - `Not Requested` (no check requested yet; address may be missing or unconfirmed) + - `Pending` (case open, awaiting review) + - `Eligible` (approved) + - `Ineligible` (rejected) +- `Internet Eligibility` (text/picklist; optional result) - Example: `Home`, `Apartment`, or a more structured code that maps to portal offerings. -- `InternetEligibilityCheckedAt__c` (datetime; optional) -- `InternetEligibilityNotes__c` (long text; optional) +- Recommended supporting fields (optional but strongly recommended): + - `Internet Eligibility Request Date Time` (datetime) + - Set by the portal/BFF when the customer requests an eligibility check. + - Useful for UX (“Requested on …”) and for internal SLAs/reporting. + - `Internet Eligibility Checked Date Time` (datetime) + - Updated by Salesforce automation when the review completes (approved or rejected). + - `Internet Eligibility_Notes` (long text) + - `Internet Eligibility_Case_Id` (text / lookup, if you want fast linking to the case from the portal) **Case (workflow + audit trail):** @@ -62,46 +68,63 @@ It also explains how these checks gate checkout and where the portal should disp 2. BFF validates: - account has a service address (or includes the address in request) - throttling/rate limits -3. BFF creates Salesforce Case and sets `InternetEligibilityStatus__c = PENDING`. +3. BFF creates Salesforce Case and sets `Internet Eligibility Status = Pending`. + - Also sets `Internet Eligibility Request Date Time = now()` (first request timestamp). 4. Portal reads `GET /api/eligibility/internet` and shows: - - `PENDING` → “Review in progress” - - `ELIGIBLE` → “Eligible for …” - - `INELIGIBLE` → “Not available” + next steps (support/contact) + - `Pending` → “Review in progress” + - `Eligible` → “Eligible for: {Internet Eligibility}” + - `INEligible` → “Not available” + next steps (support/contact) 5. When Salesforce updates the Account fields: - Portal cache invalidates via CDC/eventing (preferred), or via polling fallback. ### Recommended status → UI mapping -| Status | Shop page | Checkout gating | -| ------------ | ------------------------------------------------ | --------------- | -| `REQUIRED` | Show “Add/confirm address” then “Request review” | Block submit | -| `PENDING` | Show “Review in progress” | Block submit | -| `ELIGIBLE` | Show “Eligible for: …” | Allow submit | -| `INELIGIBLE` | Show “Not available” + support CTA | Block submit | +| Status | Shop page | Checkout gating | +| --------------- | ------------------------------------------------ | --------------- | +| `Not Requested` | Show “Add/confirm address” then “Request review” | Block submit | +| `Pending` | Show “Review in progress” | Block submit | +| `Eligible` | Show “Eligible for: {Internet Eligibility}” | Allow submit | +| `INEligible` | Show “Not available” + support CTA | Block submit | + +### Notes on “Not Requested” vs “Pending” + +- Use `Not Requested` when the customer has never requested a check (or their address is missing). +- Use `Pending` immediately after creating the Salesforce Case. +- The portal should treat both as “blocked for ordering” but with different next actions: + - `Not Requested`: show CTA to request review (and/or prompt to add address). + - `Pending`: show status only (no repeated CTA spam), plus optional “View case” link if you expose it. + +Recommended UI details: + +- If `Internet Eligibility Request Date Time` is present, show “Requested on {date}”. +- If `Internet Eligibility Checked Date Time` is present, show “Last checked on {date}”. ## SIM ID Verification (Residence Card / Identity Document) ### Target UX - In SIM checkout (and any future SIM order flow): - - If status is `VERIFIED`: show “Verified” and **no upload/change UI**. - - If `SUBMITTED`: show what was submitted (filename + submitted time) and optionally allow “Replace file”. - - If `REQUIRED`: require upload before order submission. + - If status is `Verified`: show “Verified” and **no upload/change UI**. + - If `Submitted`: show what was submitted (filename + submitted time) and optionally allow “Replace file”. + - If `Not Submitted`: require upload before order submission. + - If `Rejected`: show rejection message and require resubmission. - In order detail pages: - - Show a simple “ID verification: Required / Submitted / Verified” row. + - Show a simple “ID verification: Not submitted / Submitted / Verified” row. ### Target Salesforce Model **Account fields (canonical):** -- `IdVerificationStatus__c` (picklist) - - Suggested values (3-state): - - `REQUIRED` - - `SUBMITTED` - - `VERIFIED` -- `IdVerificationSubmittedAt__c` (datetime; optional) -- `IdVerificationVerifiedAt__c` (datetime; optional) -- `IdVerificationNotes__c` (long text; optional) +- `Id Verification Status` (picklist) + - Values: + - `Not Submitted` + - `Submitted` + - `Verified` + - `Rejected` +- `Id Verification Submitted Date Time` (datetime; optional) +- `Id Verification Verified Date Time` (datetime; optional) +- `Id Verification Note` (long text; optional) +- `Id Verification Rejection Message` (long text; optional) **Files (document storage):** @@ -115,21 +138,70 @@ It also explains how these checks gate checkout and where the portal should disp 2. If user uploads a file: - Portal calls `POST /api/verification/id` with multipart file. - BFF uploads File to Salesforce Account and sets: - - `IdVerificationStatus__c = SUBMITTED` - - `IdVerificationSubmittedAt__c = now()` -3. Internal review updates status to `VERIFIED` (and sets verified timestamp/notes). + - `Id Verification Status = Submitted` + - `Id Verification Submitted Date Time = now()` +3. Internal review updates status to `Verified` (and sets verified timestamp/notes). 4. Portal displays: - - `VERIFIED` → no edit, no upload - - `SUBMITTED` → show file metadata + optional replace - - `REQUIRED` → upload required before SIM activation + - `Verified` → no edit, no upload + - `Submitted` → show file metadata + optional replace + - `Not Submitted` → upload required before SIM activation + - `Rejected` → show rejection message and require resubmission + +### Order-first review model (recommended) + +For operational convenience, it can be better to review ID **from the specific SIM order**, then roll that result up to the Account so future SIM orders are frictionless. + +**How it works:** + +1. Customer uploads the ID document during SIM checkout. +2. BFF attaches the file to the **Salesforce Order** (or an Order-related Case) as the review target. +3. BFF sets Account `Id Verification Status = Submitted` immediately (so the portal can show progress). +4. A Salesforce Flow (or automation) is triggered when the order’s “ID review” is approved: + - sets Account `Id Verification Status = Verified` + - sets `Id Verification Verified Date Time` + - links (or copies) the approved File to the Account (so it’s “on file” for future orders) + - optionally writes a note to `Id Verification Note` + +**Portal implications:** + +- Current order detail page can show: “ID verification: Submitted/Verified” sourced from the Account, plus (optionally) a link to the specific file attached to the Order. +- SIM checkout becomes very simple: + - `Not Submitted`: upload required + - `Submitted`: allow order submission, show “Submitted” + - `Verified`: no upload UI, no “change” UI + +### Rejections + resubmission (recommended UX) + +Even if you keep a 3-state Account field, the portal can still display “rejected” as a _derived_ UI state using notes/timestamps. + +Recommended approach: + +- Use `Id Verification Note` and `Id Verification Rejection Message` for reviewer feedback. +- When a document is not acceptable, set: + - `Id Verification Status = Rejected` (so the portal blocks future SIM submissions until a new file is provided) + - `Id Verification Rejection Message` = rejection reason (“image too blurry”, “expired”, etc.) + +Portal UI behavior: + +- If `Id Verification Status = Rejected` and `Id Verification Rejection Message` is non-empty: + - show “ID verification rejected” + “Rejection note” + - show an “Id Verification Rejection Message” block that tells the customer what to do next + - Example content: + - “Your ID verification was rejected. Please upload a new, clear photo/scan of your residence card.” + - “Make sure all corners are visible, the text is readable, and the document is not expired.” + - “After you resubmit, review will restart.” +- When the user uploads again: + - overwrite the prior file (or create a new version) and set status back to `Submitted` + - clear/replace the notes so the UI doesn’t keep showing stale rejection reasons ### Gating rules (SIM checkout) -| Status | Can submit order? | What portal shows | -| ----------- | ----------------: | -------------------------------------- | -| `REQUIRED` | No | Upload required | -| `SUBMITTED` | Yes | Submitted summary + (optional) replace | -| `VERIFIED` | Yes | Verified badge only | +| Status | Can submit order? | What portal shows | +| --------------- | ----------------: | ----------------------------------------- | +| `Not Submitted` | No | Upload required | +| `Submitted` | Yes | Submitted summary + (optional) replace | +| `Verified` | Yes | Verified badge only | +| `Rejected` | No | Rejection message + resubmission required | ## Where to show status (recommended) @@ -137,7 +209,7 @@ It also explains how these checks gate checkout and where the portal should disp - Internet: eligibility banner/status on `/account/shop/internet` - SIM: verification requirement banner/status on `/account/shop/sim` (optional) - **Checkout** - - Show the relevant status inline in the “Checkout requirements” section. + - Show the relevant status inline near the confirm/requirements cards. - **Orders** - Show “Eligibility / ID verification” status on the order detail page so users can track progress after submitting. - **Dashboard**