feat: Add Fulfillment Side Effects Service for order processing notifications and cache management feat: Create base validation interfaces and implement various order validators feat: Develop Internet Order Validator to check eligibility and prevent duplicate services feat: Implement SIM Order Validator to ensure residence card verification and activation fee presence feat: Create SKU Validator to validate product SKUs against the Salesforce pricebook feat: Implement User Mapping Validator to ensure necessary account mappings exist before ordering feat: Enhance Users Service with methods for user profile management and summary retrieval
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
import { Injectable, Inject } from "@nestjs/common";
|
|
import { Logger } from "nestjs-pino";
|
|
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
|
import { OrderOrchestrator } from "@bff/modules/orders/services/order-orchestrator.service.js";
|
|
import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js";
|
|
import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js";
|
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
|
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
|
|
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
|
import {
|
|
meStatusSchema,
|
|
type DashboardSummary,
|
|
type DashboardTask,
|
|
type MeStatus,
|
|
type PaymentMethodsStatus,
|
|
} from "@customer-portal/domain/dashboard";
|
|
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
|
import type { InternetEligibilityDetails } from "@customer-portal/domain/services";
|
|
import {
|
|
residenceCardVerificationSchema,
|
|
type ResidenceCardVerification,
|
|
} from "@customer-portal/domain/customer";
|
|
import type { OrderSummary } from "@customer-portal/domain/orders";
|
|
|
|
/**
|
|
* Me Status Aggregator
|
|
*
|
|
* Read-only aggregator that combines data from multiple sources
|
|
* (users, orders, eligibility, verification, payments, notifications)
|
|
* to build the dashboard status response.
|
|
*/
|
|
@Injectable()
|
|
export class MeStatusAggregator {
|
|
constructor(
|
|
private readonly users: UsersService,
|
|
private readonly orders: OrderOrchestrator,
|
|
private readonly internetEligibility: InternetEligibilityService,
|
|
private readonly residenceCards: ResidenceCardService,
|
|
private readonly mappings: MappingsService,
|
|
private readonly whmcsPayments: WhmcsPaymentService,
|
|
private readonly notifications: NotificationService,
|
|
@Inject(Logger) private readonly logger: Logger
|
|
) {}
|
|
|
|
async getStatusForUser(userId: string): Promise<MeStatus> {
|
|
try {
|
|
const [summary, internetEligibility, residenceCardVerification, orders] = await Promise.all([
|
|
this.users.getUserSummary(userId),
|
|
this.internetEligibility.getEligibilityDetailsForUser(userId),
|
|
this.safeGetResidenceCardVerification(userId),
|
|
this.safeGetOrders(userId),
|
|
]);
|
|
|
|
const paymentMethods = await this.safeGetPaymentMethodsStatus(userId);
|
|
|
|
const tasks = this.computeTasks({
|
|
summary,
|
|
paymentMethods,
|
|
internetEligibility,
|
|
residenceCardVerification,
|
|
orders,
|
|
});
|
|
|
|
await this.maybeCreateInvoiceDueNotification(userId, summary);
|
|
|
|
return meStatusSchema.parse({
|
|
summary,
|
|
paymentMethods,
|
|
internetEligibility,
|
|
residenceCardVerification,
|
|
tasks,
|
|
});
|
|
} catch (error) {
|
|
this.logger.error({ userId, err: error }, "Failed to get status for user");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async safeGetResidenceCardVerification(
|
|
userId: string
|
|
): Promise<ResidenceCardVerification> {
|
|
try {
|
|
return await this.residenceCards.getStatusForUser(userId);
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
{ userId, err: error instanceof Error ? error.message : String(error) },
|
|
"Failed to load residence card verification for status payload"
|
|
);
|
|
return residenceCardVerificationSchema.parse({
|
|
status: "not_submitted",
|
|
filename: null,
|
|
mimeType: null,
|
|
sizeBytes: null,
|
|
submittedAt: null,
|
|
reviewedAt: null,
|
|
reviewerNotes: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
private async safeGetOrders(userId: string): Promise<OrderSummary[] | null> {
|
|
try {
|
|
const result = await this.orders.getOrdersForUser(userId);
|
|
return Array.isArray(result) ? result : [];
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
{ userId, err: error instanceof Error ? error.message : String(error) },
|
|
"Failed to load orders for status payload"
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async safeGetPaymentMethodsStatus(userId: string): Promise<PaymentMethodsStatus> {
|
|
try {
|
|
const mapping = await this.mappings.findByUserId(userId);
|
|
if (!mapping?.whmcsClientId) {
|
|
return { totalCount: null };
|
|
}
|
|
|
|
const list = await this.whmcsPayments.getPaymentMethods(mapping.whmcsClientId, userId);
|
|
return { totalCount: typeof list?.totalCount === "number" ? list.totalCount : 0 };
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
{ userId, err: error instanceof Error ? error.message : String(error) },
|
|
"Failed to load payment methods for status payload"
|
|
);
|
|
return { totalCount: null };
|
|
}
|
|
}
|
|
|
|
private computeTasks(params: {
|
|
summary: DashboardSummary;
|
|
paymentMethods: PaymentMethodsStatus;
|
|
internetEligibility: InternetEligibilityDetails;
|
|
residenceCardVerification: ResidenceCardVerification;
|
|
orders: OrderSummary[] | null;
|
|
}): DashboardTask[] {
|
|
const tasks: DashboardTask[] = [];
|
|
|
|
const { summary, paymentMethods, internetEligibility, residenceCardVerification, orders } =
|
|
params;
|
|
|
|
// Priority 1: next unpaid invoice
|
|
if (summary.nextInvoice) {
|
|
const dueDate = new Date(summary.nextInvoice.dueDate);
|
|
const isValid = !Number.isNaN(dueDate.getTime());
|
|
const isOverdue = isValid ? dueDate.getTime() < Date.now() : false;
|
|
|
|
const formattedAmount = new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: summary.nextInvoice.currency,
|
|
maximumFractionDigits: 0,
|
|
}).format(summary.nextInvoice.amount);
|
|
|
|
const dueText = isValid
|
|
? dueDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
|
: "soon";
|
|
|
|
tasks.push({
|
|
id: `invoice-${summary.nextInvoice.id}`,
|
|
priority: 1,
|
|
type: "invoice",
|
|
title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice",
|
|
description: `Invoice #${summary.nextInvoice.id} · ${formattedAmount} · Due ${dueText}`,
|
|
actionLabel: "Pay now",
|
|
detailHref: `/account/billing/invoices/${summary.nextInvoice.id}`,
|
|
requiresSsoAction: true,
|
|
tone: "critical",
|
|
metadata: {
|
|
invoiceId: summary.nextInvoice.id,
|
|
amount: summary.nextInvoice.amount,
|
|
currency: summary.nextInvoice.currency,
|
|
...(isValid ? { dueDate: dueDate.toISOString() } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
// Priority 2: no payment method (only when we could verify)
|
|
if (paymentMethods.totalCount === 0) {
|
|
tasks.push({
|
|
id: "add-payment-method",
|
|
priority: 2,
|
|
type: "payment_method",
|
|
title: "Add a payment method",
|
|
description: "Required to place orders and process invoices",
|
|
actionLabel: "Add method",
|
|
detailHref: "/account/billing/payments",
|
|
requiresSsoAction: true,
|
|
tone: "warning",
|
|
});
|
|
}
|
|
|
|
// Priority 3: pending orders
|
|
if (orders && orders.length > 0) {
|
|
const pendingOrder = orders.find(
|
|
o =>
|
|
o.status === "Draft" ||
|
|
o.status === "Pending" ||
|
|
(o.status === "Activated" && o.activationStatus !== "Completed")
|
|
);
|
|
|
|
const firstPendingOrder = pendingOrder;
|
|
if (firstPendingOrder) {
|
|
const statusText =
|
|
firstPendingOrder.status === "Pending"
|
|
? "awaiting review"
|
|
: firstPendingOrder.status === "Draft"
|
|
? "in draft"
|
|
: "being activated";
|
|
|
|
tasks.push({
|
|
id: `order-${firstPendingOrder.id}`,
|
|
priority: 3,
|
|
type: "order",
|
|
title: "Order in progress",
|
|
description: `${firstPendingOrder.orderType || "Your"} order is ${statusText}`,
|
|
actionLabel: "View details",
|
|
detailHref: `/account/orders/${firstPendingOrder.id}`,
|
|
tone: "info",
|
|
metadata: { orderId: firstPendingOrder.id },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Priority 4: Internet eligibility review (only when explicitly pending)
|
|
if (internetEligibility.status === "pending") {
|
|
tasks.push({
|
|
id: "internet-eligibility-review",
|
|
priority: 4,
|
|
type: "internet_eligibility",
|
|
title: "Internet availability review",
|
|
description:
|
|
"We're verifying if our service is available at your residence. We'll notify you when review is complete.",
|
|
actionLabel: "View status",
|
|
detailHref: "/account/services/internet",
|
|
tone: "info",
|
|
});
|
|
}
|
|
|
|
// Priority 4: ID verification rejected
|
|
if (residenceCardVerification.status === "rejected") {
|
|
tasks.push({
|
|
id: "id-verification-rejected",
|
|
priority: 4,
|
|
type: "id_verification",
|
|
title: "ID verification requires attention",
|
|
description: "We couldn't verify your ID. Please review the feedback and resubmit.",
|
|
actionLabel: "Resubmit",
|
|
detailHref: "/account/settings/verification",
|
|
tone: "warning",
|
|
});
|
|
}
|
|
|
|
// Priority 4: onboarding (only when no other tasks)
|
|
if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) {
|
|
tasks.push({
|
|
id: "start-subscription",
|
|
priority: 4,
|
|
type: "onboarding",
|
|
title: "Start your first service",
|
|
description: "Browse our catalog and subscribe to internet, SIM, or VPN",
|
|
actionLabel: "Browse services",
|
|
detailHref: "/services",
|
|
tone: "neutral",
|
|
});
|
|
}
|
|
|
|
return tasks.sort((a, b) => a.priority - b.priority);
|
|
}
|
|
|
|
private async maybeCreateInvoiceDueNotification(
|
|
userId: string,
|
|
summary: DashboardSummary
|
|
): Promise<void> {
|
|
const invoice = summary.nextInvoice;
|
|
if (!invoice) return;
|
|
|
|
try {
|
|
const dueDate = new Date(invoice.dueDate);
|
|
if (Number.isNaN(dueDate.getTime())) return;
|
|
|
|
const daysUntilDue = (dueDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
|
|
// Notify when due within a week (or overdue).
|
|
if (daysUntilDue > 7) return;
|
|
|
|
await this.notifications.createNotification({
|
|
userId,
|
|
type: NOTIFICATION_TYPE.INVOICE_DUE,
|
|
source: NOTIFICATION_SOURCE.SYSTEM,
|
|
sourceId: `invoice:${invoice.id}`,
|
|
actionUrl: `/account/billing/invoices/${invoice.id}`,
|
|
});
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
{ userId, err: error instanceof Error ? error.message : String(error) },
|
|
"Failed to create invoice due notification"
|
|
);
|
|
}
|
|
}
|
|
}
|