Refactor Salesforce Integration and Update Billing Logic
- Replaced the OrderFieldConfigModule with SalesforceOrderFieldConfigModule in the Salesforce integration module to streamline configuration management. - Updated SalesforceOrderService to utilize SalesforceOrderFieldMapService, enhancing consistency in order field mapping. - Refactored multiple controllers to remove inline query parsing, directly using query parameters for improved clarity and maintainability. - Adjusted the ResidenceCardService to enhance error handling and response consistency when interacting with Salesforce. - Cleaned up unused imports and optimized code structure for better maintainability across the BFF modules.
This commit is contained in:
parent
ea188f098b
commit
ed5c2ead63
@ -15,8 +15,19 @@ const SECTION_PREFIX: Record<keyof SalesforceOrderFieldMap, string> = {
|
||||
product: "PRODUCT",
|
||||
};
|
||||
|
||||
/**
|
||||
* Salesforce Order Field Map Service
|
||||
*
|
||||
* Resolves Salesforce field names from environment configuration and provides
|
||||
* query building utilities for Order-related SOQL queries.
|
||||
*
|
||||
* This service lives in the integrations layer because:
|
||||
* - It is specific to Salesforce infrastructure
|
||||
* - It handles SOQL query construction
|
||||
* - It reads environment-specific field name overrides
|
||||
*/
|
||||
@Injectable()
|
||||
export class OrderFieldMapService {
|
||||
export class SalesforceOrderFieldMapService {
|
||||
readonly fields: SalesforceOrderFieldMap;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
@ -0,0 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { SalesforceOrderFieldMapService } from "./order-field-map.service.js";
|
||||
|
||||
/**
|
||||
* Salesforce Order Field Configuration Module
|
||||
*
|
||||
* Provides the SalesforceOrderFieldMapService for Salesforce integration layer.
|
||||
* This module is imported by SalesforceModule and can be imported by
|
||||
* application modules that need access to Salesforce field mappings.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [SalesforceOrderFieldMapService],
|
||||
exports: [SalesforceOrderFieldMapService],
|
||||
})
|
||||
export class SalesforceOrderFieldConfigModule {}
|
||||
@ -8,12 +8,12 @@ import { SalesforceOrderService } from "./services/salesforce-order.service.js";
|
||||
import { SalesforceCaseService } from "./services/salesforce-case.service.js";
|
||||
import { SalesforceOpportunityService } from "./services/salesforce-opportunity.service.js";
|
||||
import { OpportunityResolutionService } from "./services/opportunity-resolution.service.js";
|
||||
import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module.js";
|
||||
import { SalesforceOrderFieldConfigModule } from "./config/salesforce-order-field-config.module.js";
|
||||
import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js";
|
||||
import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard.js";
|
||||
|
||||
@Module({
|
||||
imports: [QueueModule, ConfigModule, OrderFieldConfigModule],
|
||||
imports: [QueueModule, ConfigModule, SalesforceOrderFieldConfigModule],
|
||||
providers: [
|
||||
SalesforceConnection,
|
||||
SalesforceAccountService,
|
||||
|
||||
@ -26,7 +26,7 @@ import {
|
||||
transformSalesforceOrderSummary,
|
||||
} from "@customer-portal/domain/orders/providers";
|
||||
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
|
||||
import { OrderFieldMapService } from "@bff/modules/orders/config/order-field-map.service.js";
|
||||
import { SalesforceOrderFieldMapService } from "../config/order-field-map.service.js";
|
||||
|
||||
/**
|
||||
* Salesforce Order Service
|
||||
@ -39,7 +39,7 @@ export class SalesforceOrderService {
|
||||
constructor(
|
||||
private readonly sf: SalesforceConnection,
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly orderFieldMap: OrderFieldMapService
|
||||
private readonly orderFieldMap: SalesforceOrderFieldMapService
|
||||
) {}
|
||||
|
||||
private readonly compositeOrderReference = "order_ref";
|
||||
|
||||
@ -103,12 +103,11 @@ export class BillingController {
|
||||
@Query() query: InvoiceSsoQueryDto
|
||||
): Promise<InvoiceSsoLink> {
|
||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
|
||||
const parsedQuery = invoiceSsoQuerySchema.parse(query as unknown);
|
||||
|
||||
const ssoUrl = await this.whmcsService.whmcsSsoForInvoice(
|
||||
whmcsClientId,
|
||||
params.id,
|
||||
parsedQuery.target
|
||||
query.target
|
||||
);
|
||||
|
||||
return {
|
||||
@ -126,19 +125,18 @@ export class BillingController {
|
||||
@Query() query: InvoicePaymentLinkQueryDto
|
||||
): Promise<InvoicePaymentLink> {
|
||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
|
||||
const parsedQuery = invoicePaymentLinkQuerySchema.parse(query as unknown);
|
||||
|
||||
const ssoResult = await this.whmcsService.createPaymentSsoToken(
|
||||
whmcsClientId,
|
||||
params.id,
|
||||
parsedQuery.paymentMethodId,
|
||||
parsedQuery.gatewayName
|
||||
query.paymentMethodId,
|
||||
query.gatewayName
|
||||
);
|
||||
|
||||
return {
|
||||
url: ssoResult.url,
|
||||
expiresAt: ssoResult.expiresAt,
|
||||
gatewayName: parsedQuery.gatewayName,
|
||||
gatewayName: query.gatewayName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
8
apps/bff/src/modules/id-mappings/domain/README.md
Normal file
8
apps/bff/src/modules/id-mappings/domain/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
## BFF-local domain contracts (id-mappings)
|
||||
|
||||
This folder contains **BFF-internal** contracts/schemas/validation for the `id-mappings` module.
|
||||
|
||||
- **Why it’s local**: these types are not part of the shared portal/BFF domain API; they describe internal mapping records and admin operations that are tightly coupled to the BFF’s persistence model.
|
||||
- **When to move it to `packages/domain/`**: if the portal (or another app) needs to consume these contracts directly, or we want shared runtime validation/error codes around mapping operations across apps.
|
||||
|
||||
If/when we migrate, follow the import hygiene rules in `docs/development/domain/import-hygiene.md` and expose only the required public surface from `@customer-portal/domain/<module>`.
|
||||
@ -140,7 +140,7 @@ export class MeStatusService {
|
||||
const isValid = !Number.isNaN(dueDate.getTime());
|
||||
const isOverdue = isValid ? dueDate.getTime() < Date.now() : false;
|
||||
|
||||
const formattedAmount = new Intl.NumberFormat("ja-JP", {
|
||||
const formattedAmount = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: summary.nextInvoice.currency,
|
||||
maximumFractionDigits: 0,
|
||||
|
||||
@ -41,12 +41,10 @@ export class NotificationsController {
|
||||
@Req() req: RequestWithUser,
|
||||
@Query() query: NotificationQueryDto
|
||||
): Promise<NotificationListResponse> {
|
||||
const parsedQuery = notificationQuerySchema.parse(query as unknown);
|
||||
|
||||
return this.notificationService.getNotifications(req.user.id, {
|
||||
limit: Math.min(parsedQuery.limit, 50), // Cap at 50
|
||||
offset: parsedQuery.offset,
|
||||
includeRead: parsedQuery.includeRead,
|
||||
limit: Math.min(query.limit, 50), // Cap at 50
|
||||
offset: query.offset,
|
||||
includeRead: query.includeRead,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { OrderFieldMapService } from "./order-field-map.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [OrderFieldMapService],
|
||||
exports: [OrderFieldMapService],
|
||||
})
|
||||
export class OrderFieldConfigModule {}
|
||||
@ -29,7 +29,7 @@ import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error
|
||||
import { SimFulfillmentService } from "./services/sim-fulfillment.service.js";
|
||||
import { ProvisioningQueueService } from "./queue/provisioning.queue.js";
|
||||
import { ProvisioningProcessor } from "./queue/provisioning.processor.js";
|
||||
import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
|
||||
import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/config/salesforce-order-field-config.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -41,7 +41,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
|
||||
CacheModule,
|
||||
VerificationModule,
|
||||
NotificationsModule,
|
||||
OrderFieldConfigModule,
|
||||
SalesforceOrderFieldConfigModule,
|
||||
],
|
||||
controllers: [OrdersController, CheckoutController],
|
||||
providers: [
|
||||
|
||||
@ -2,7 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import type { OrderBusinessValidation, UserMapping } from "@customer-portal/domain/orders";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||
import { OrderFieldMapService } from "@bff/modules/orders/config/order-field-map.service.js";
|
||||
import { SalesforceOrderFieldMapService } from "@bff/integrations/salesforce/config/order-field-map.service.js";
|
||||
|
||||
function assignIfString(target: Record<string, unknown>, key: string, value: unknown): void {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
@ -18,7 +18,7 @@ export class OrderBuilder {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly usersFacade: UsersFacade,
|
||||
private readonly orderFieldMap: OrderFieldMapService
|
||||
private readonly orderFieldMap: SalesforceOrderFieldMapService
|
||||
) {}
|
||||
|
||||
async buildOrderFields(
|
||||
@ -61,7 +61,7 @@ export class OrderBuilder {
|
||||
private addActivationFields(
|
||||
orderFields: Record<string, unknown>,
|
||||
body: OrderBusinessValidation,
|
||||
fieldNames: OrderFieldMapService["fields"]["order"]
|
||||
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
|
||||
): void {
|
||||
const config = body.configurations || {};
|
||||
|
||||
@ -73,7 +73,7 @@ export class OrderBuilder {
|
||||
private addInternetFields(
|
||||
orderFields: Record<string, unknown>,
|
||||
body: OrderBusinessValidation,
|
||||
fieldNames: OrderFieldMapService["fields"]["order"]
|
||||
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
|
||||
): void {
|
||||
const config = body.configurations || {};
|
||||
assignIfString(orderFields, fieldNames.accessMode, config.accessMode);
|
||||
@ -82,7 +82,7 @@ export class OrderBuilder {
|
||||
private addSimFields(
|
||||
orderFields: Record<string, unknown>,
|
||||
body: OrderBusinessValidation,
|
||||
fieldNames: OrderFieldMapService["fields"]["order"]
|
||||
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
|
||||
): void {
|
||||
const config = body.configurations || {};
|
||||
assignIfString(orderFields, fieldNames.simType, config.simType);
|
||||
@ -119,7 +119,7 @@ export class OrderBuilder {
|
||||
orderFields: Record<string, unknown>,
|
||||
userId: string,
|
||||
body: OrderBusinessValidation,
|
||||
fieldNames: OrderFieldMapService["fields"]["order"]
|
||||
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
|
||||
): Promise<void> {
|
||||
try {
|
||||
const profile = await this.usersFacade.getProfile(userId);
|
||||
|
||||
@ -69,9 +69,8 @@ export class CallHistoryController {
|
||||
@Get("sim/call-history/sftp-files")
|
||||
@ZodResponse({ description: "List available SFTP files", type: SimSftpListResultResponseDto })
|
||||
async listSftpFiles(@Query() query: SimSftpListQueryDto) {
|
||||
const parsedQuery = simSftpListQuerySchema.parse(query as unknown);
|
||||
const files = await this.simCallHistoryService.listSftpFiles(parsedQuery.path);
|
||||
return { files, path: parsedQuery.path };
|
||||
const files = await this.simCallHistoryService.listSftpFiles(query.path);
|
||||
return { files, path: query.path };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -84,8 +83,7 @@ export class CallHistoryController {
|
||||
type: SimCallHistoryImportResultResponseDto,
|
||||
})
|
||||
async importCallHistory(@Query() query: SimCallHistoryImportQueryDto) {
|
||||
const parsedQuery = simCallHistoryImportQuerySchema.parse(query as unknown);
|
||||
const result = await this.simCallHistoryService.importCallHistory(parsedQuery.month);
|
||||
const result = await this.simCallHistoryService.importCallHistory(query.month);
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -105,13 +103,12 @@ export class CallHistoryController {
|
||||
@Param() params: SubscriptionIdParamDto,
|
||||
@Query() query: SimHistoryQueryDto
|
||||
): Promise<SimDomesticCallHistoryResponse> {
|
||||
const parsedQuery = simHistoryQuerySchema.parse(query as unknown);
|
||||
const result = await this.simCallHistoryService.getDomesticCallHistory(
|
||||
req.user.id,
|
||||
params.id,
|
||||
parsedQuery.month,
|
||||
parsedQuery.page,
|
||||
parsedQuery.limit
|
||||
query.month,
|
||||
query.page,
|
||||
query.limit
|
||||
);
|
||||
return result;
|
||||
}
|
||||
@ -130,13 +127,12 @@ export class CallHistoryController {
|
||||
@Param() params: SubscriptionIdParamDto,
|
||||
@Query() query: SimHistoryQueryDto
|
||||
): Promise<SimInternationalCallHistoryResponse> {
|
||||
const parsedQuery = simHistoryQuerySchema.parse(query as unknown);
|
||||
const result = await this.simCallHistoryService.getInternationalCallHistory(
|
||||
req.user.id,
|
||||
params.id,
|
||||
parsedQuery.month,
|
||||
parsedQuery.page,
|
||||
parsedQuery.limit
|
||||
query.month,
|
||||
query.page,
|
||||
query.limit
|
||||
);
|
||||
return result;
|
||||
}
|
||||
@ -152,13 +148,12 @@ export class CallHistoryController {
|
||||
@Param() params: SubscriptionIdParamDto,
|
||||
@Query() query: SimHistoryQueryDto
|
||||
): Promise<SimSmsHistoryResponse> {
|
||||
const parsedQuery = simHistoryQuerySchema.parse(query as unknown);
|
||||
const result = await this.simCallHistoryService.getSmsHistory(
|
||||
req.user.id,
|
||||
params.id,
|
||||
parsedQuery.month,
|
||||
parsedQuery.page,
|
||||
parsedQuery.limit
|
||||
query.month,
|
||||
query.page,
|
||||
query.limit
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -193,8 +193,7 @@ export class SimController {
|
||||
@Param() params: SubscriptionIdParamDto,
|
||||
@Body() body: SimReissueEsimRequestDto
|
||||
): Promise<SimActionResponse> {
|
||||
const parsedBody = simReissueEsimRequestSchema.parse(body as unknown);
|
||||
await this.simManagementService.reissueEsimProfile(req.user.id, params.id, parsedBody.newEid);
|
||||
await this.simManagementService.reissueEsimProfile(req.user.id, params.id, body.newEid);
|
||||
return { message: "eSIM profile reissue completed successfully" };
|
||||
}
|
||||
|
||||
|
||||
@ -65,7 +65,7 @@ export class ResidenceCardController {
|
||||
filename: file.originalname || "residence-card",
|
||||
mimeType: file.mimetype,
|
||||
sizeBytes: file.size,
|
||||
content: file.buffer as unknown as Uint8Array<ArrayBuffer>,
|
||||
content: file.buffer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Injectable, Inject, HttpStatus } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||
@ -9,12 +9,14 @@ import {
|
||||
} from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||
import { normalizeSalesforceDateTimeToIsoUtc } from "@bff/integrations/salesforce/utils/datetime.util.js";
|
||||
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
|
||||
import { ErrorCode } from "@customer-portal/domain/common";
|
||||
import {
|
||||
residenceCardVerificationSchema,
|
||||
type ResidenceCardVerification,
|
||||
type ResidenceCardVerificationStatus,
|
||||
} from "@customer-portal/domain/customer";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
|
||||
import { basename, extname } from "node:path";
|
||||
|
||||
function mapFileTypeToMime(fileType?: string | null): string | null {
|
||||
@ -139,22 +141,21 @@ export class ResidenceCardService {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
content: Uint8Array<ArrayBuffer>;
|
||||
content: Buffer;
|
||||
}): Promise<ResidenceCardVerification> {
|
||||
const mapping = await this.mappings.findByUserId(params.userId);
|
||||
if (!mapping?.sfAccountId) {
|
||||
throw new Error("No Salesforce mapping found for current user");
|
||||
throw new DomainHttpException(ErrorCode.ACCOUNT_MAPPING_MISSING, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
||||
|
||||
const fileBuffer = Buffer.from(params.content as unknown as Uint8Array);
|
||||
const versionData = fileBuffer.toString("base64");
|
||||
const versionData = params.content.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");
|
||||
throw new DomainHttpException(ErrorCode.CONFIGURATION_ERROR, HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
try {
|
||||
@ -166,21 +167,30 @@ export class ResidenceCardService {
|
||||
});
|
||||
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");
|
||||
throw new DomainHttpException(
|
||||
ErrorCode.EXTERNAL_SERVICE_ERROR,
|
||||
HttpStatus.SERVICE_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DomainHttpException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error("Failed to upload residence card to Salesforce Files", {
|
||||
userId: params.userId,
|
||||
sfAccountIdTail: sfAccountId.slice(-4),
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to submit residence card. Please try again later.");
|
||||
throw new DomainHttpException(
|
||||
ErrorCode.EXTERNAL_SERVICE_ERROR,
|
||||
HttpStatus.SERVICE_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const fields = this.getAccountFieldNames();
|
||||
const update = this.sf.sobject("Account")?.update;
|
||||
if (!update) {
|
||||
throw new Error("Salesforce Account update method not available");
|
||||
throw new DomainHttpException(ErrorCode.CONFIGURATION_ERROR, HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
await update({
|
||||
|
||||
@ -9,44 +9,18 @@ import {
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
// ✅ Generic utilities from lib
|
||||
import {
|
||||
apiClient,
|
||||
queryKeys,
|
||||
getDataOrDefault,
|
||||
getDataOrThrow,
|
||||
type QueryParams,
|
||||
} from "@/lib/api";
|
||||
import { queryKeys } from "@/lib/api";
|
||||
|
||||
// ✅ Single consolidated import from domain
|
||||
import {
|
||||
// Types
|
||||
type Invoice,
|
||||
type InvoiceList,
|
||||
type InvoiceSsoLink,
|
||||
type InvoiceQueryParams,
|
||||
// Schemas
|
||||
invoiceSchema,
|
||||
invoiceListSchema,
|
||||
} from "@customer-portal/domain/billing";
|
||||
|
||||
import { type PaymentMethodList } from "@customer-portal/domain/payments";
|
||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||
|
||||
// Constants
|
||||
const EMPTY_INVOICE_LIST: InvoiceList = {
|
||||
invoices: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
totalItems: 0,
|
||||
totalPages: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const EMPTY_PAYMENT_METHODS: PaymentMethodList = {
|
||||
paymentMethods: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
import { billingService } from "../services/billing.service";
|
||||
|
||||
// Type helpers for React Query
|
||||
type InvoicesQueryKey = ReturnType<typeof queryKeys.billing.invoices>;
|
||||
@ -74,44 +48,6 @@ type SsoLinkMutationOptions = UseMutationOptions<
|
||||
{ invoiceId: number; target?: "view" | "download" | "pay" }
|
||||
>;
|
||||
|
||||
// Helper functions
|
||||
function toQueryParams(params: InvoiceQueryParams): QueryParams {
|
||||
const query: QueryParams = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
query[key] = value;
|
||||
}
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
// API functions
|
||||
async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> {
|
||||
const query = params ? toQueryParams(params) : undefined;
|
||||
const response = await apiClient.GET<InvoiceList>(
|
||||
"/api/invoices",
|
||||
query ? { params: { query } } : undefined
|
||||
);
|
||||
const data = getDataOrDefault(response, EMPTY_INVOICE_LIST);
|
||||
return invoiceListSchema.parse(data);
|
||||
}
|
||||
|
||||
async function fetchInvoice(id: string): Promise<Invoice> {
|
||||
const response = await apiClient.GET<Invoice>("/api/invoices/{id}", {
|
||||
params: { path: { id } },
|
||||
});
|
||||
const invoice = getDataOrThrow(response, "Invoice not found");
|
||||
return invoiceSchema.parse(invoice);
|
||||
}
|
||||
|
||||
async function fetchPaymentMethods(): Promise<PaymentMethodList> {
|
||||
const response = await apiClient.GET<PaymentMethodList>("/api/invoices/payment-methods");
|
||||
return getDataOrDefault(response, EMPTY_PAYMENT_METHODS);
|
||||
}
|
||||
|
||||
// Exported hooks
|
||||
export function useInvoices(
|
||||
params?: InvoiceQueryParams,
|
||||
@ -121,7 +57,7 @@ export function useInvoices(
|
||||
const queryKeyParams = params ? { ...params } : undefined;
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.invoices(queryKeyParams),
|
||||
queryFn: () => fetchInvoices(params),
|
||||
queryFn: () => billingService.getInvoices(params),
|
||||
enabled: isAuthenticated,
|
||||
...options,
|
||||
});
|
||||
@ -134,7 +70,7 @@ export function useInvoice(
|
||||
const { isAuthenticated } = useAuthSession();
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.invoice(id),
|
||||
queryFn: () => fetchInvoice(id),
|
||||
queryFn: () => billingService.getInvoice(id),
|
||||
enabled: isAuthenticated && Boolean(id),
|
||||
...options,
|
||||
});
|
||||
@ -146,7 +82,7 @@ export function usePaymentMethods(
|
||||
const { isAuthenticated } = useAuthSession();
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.paymentMethods(),
|
||||
queryFn: fetchPaymentMethods,
|
||||
queryFn: billingService.getPaymentMethods,
|
||||
enabled: isAuthenticated,
|
||||
...options,
|
||||
});
|
||||
@ -161,13 +97,7 @@ export function useCreateInvoiceSsoLink(
|
||||
> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ invoiceId, target }) => {
|
||||
const response = await apiClient.POST<InvoiceSsoLink>("/api/invoices/{id}/sso-link", {
|
||||
params: {
|
||||
path: { id: invoiceId },
|
||||
query: target ? { target } : undefined,
|
||||
},
|
||||
});
|
||||
return getDataOrThrow(response, "Failed to create SSO link");
|
||||
return billingService.createInvoiceSsoLink(invoiceId, target);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@ -177,12 +107,7 @@ export function useCreatePaymentMethodsSsoLink(
|
||||
options?: UseMutationOptions<InvoiceSsoLink, Error, void>
|
||||
): UseMutationResult<InvoiceSsoLink, Error, void> {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await apiClient.POST<InvoiceSsoLink>("/api/auth/sso-link", {
|
||||
body: { destination: "index.php?rp=/account/paymentmethods" },
|
||||
});
|
||||
return getDataOrThrow(response, "Failed to create payment methods SSO link");
|
||||
},
|
||||
mutationFn: billingService.createPaymentMethodsSsoLink,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./hooks";
|
||||
export * from "./components";
|
||||
export * from "./services";
|
||||
|
||||
114
apps/portal/src/features/billing/services/billing.service.ts
Normal file
114
apps/portal/src/features/billing/services/billing.service.ts
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Billing Service
|
||||
*
|
||||
* Handles all billing-related API calls.
|
||||
* Hooks should use this service instead of calling the API directly.
|
||||
*/
|
||||
|
||||
import { apiClient, getDataOrDefault, getDataOrThrow, type QueryParams } from "@/lib/api";
|
||||
import {
|
||||
type Invoice,
|
||||
type InvoiceList,
|
||||
type InvoiceSsoLink,
|
||||
type InvoiceQueryParams,
|
||||
invoiceSchema,
|
||||
invoiceListSchema,
|
||||
} from "@customer-portal/domain/billing";
|
||||
import { type PaymentMethodList } from "@customer-portal/domain/payments";
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const EMPTY_INVOICE_LIST: InvoiceList = {
|
||||
invoices: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
totalItems: 0,
|
||||
totalPages: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const EMPTY_PAYMENT_METHODS: PaymentMethodList = {
|
||||
paymentMethods: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function toQueryParams(params: InvoiceQueryParams): QueryParams {
|
||||
const query: QueryParams = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
query[key] = value;
|
||||
}
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Functions
|
||||
// ============================================================================
|
||||
|
||||
async function getInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> {
|
||||
const query = params ? toQueryParams(params) : undefined;
|
||||
const response = await apiClient.GET<InvoiceList>(
|
||||
"/api/invoices",
|
||||
query ? { params: { query } } : undefined
|
||||
);
|
||||
const data = getDataOrDefault(response, EMPTY_INVOICE_LIST);
|
||||
return invoiceListSchema.parse(data);
|
||||
}
|
||||
|
||||
async function getInvoice(id: string): Promise<Invoice> {
|
||||
const response = await apiClient.GET<Invoice>("/api/invoices/{id}", {
|
||||
params: { path: { id } },
|
||||
});
|
||||
const invoice = getDataOrThrow(response, "Invoice not found");
|
||||
return invoiceSchema.parse(invoice);
|
||||
}
|
||||
|
||||
async function getPaymentMethods(): Promise<PaymentMethodList> {
|
||||
const response = await apiClient.GET<PaymentMethodList>("/api/invoices/payment-methods");
|
||||
return getDataOrDefault(response, EMPTY_PAYMENT_METHODS);
|
||||
}
|
||||
|
||||
async function createInvoiceSsoLink(
|
||||
invoiceId: number,
|
||||
target?: "view" | "download" | "pay"
|
||||
): Promise<InvoiceSsoLink> {
|
||||
const response = await apiClient.POST<InvoiceSsoLink>("/api/invoices/{id}/sso-link", {
|
||||
params: {
|
||||
path: { id: invoiceId },
|
||||
query: target ? { target } : undefined,
|
||||
},
|
||||
});
|
||||
return getDataOrThrow(response, "Failed to create SSO link");
|
||||
}
|
||||
|
||||
async function createPaymentMethodsSsoLink(): Promise<InvoiceSsoLink> {
|
||||
const response = await apiClient.POST<InvoiceSsoLink>("/api/auth/sso-link", {
|
||||
body: { destination: "index.php?rp=/account/paymentmethods" },
|
||||
});
|
||||
return getDataOrThrow(response, "Failed to create payment methods SSO link");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service Export
|
||||
// ============================================================================
|
||||
|
||||
export const billingService = {
|
||||
getInvoices,
|
||||
getInvoice,
|
||||
getPaymentMethods,
|
||||
createInvoiceSsoLink,
|
||||
createPaymentMethodsSsoLink,
|
||||
} as const;
|
||||
|
||||
// Re-export constants for use in hooks
|
||||
export { EMPTY_INVOICE_LIST, EMPTY_PAYMENT_METHODS };
|
||||
1
apps/portal/src/features/billing/services/index.ts
Normal file
1
apps/portal/src/features/billing/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { billingService, EMPTY_INVOICE_LIST, EMPTY_PAYMENT_METHODS } from "./billing.service";
|
||||
@ -275,6 +275,16 @@ This allows gradual migration while new code uses unified types.
|
||||
|
||||
## Implementation Files
|
||||
|
||||
- `packages/domain/src/entities/product.ts` - Core unified types
|
||||
- `packages/domain/src/entities/catalog.ts` - Re-exports with legacy aliases
|
||||
- `packages/domain/src/entities/order.ts` - Updated order types
|
||||
- **Note**: The domain package uses a provider-aware structure. The older `packages/domain/src/entities/*` paths referenced by earlier drafts are no longer accurate.
|
||||
|
||||
Current home for these concerns (examples):
|
||||
|
||||
- `packages/domain/services/contract.ts`: catalog/service product contracts
|
||||
- `packages/domain/services/schema.ts`: Zod schemas for service catalog types
|
||||
- `packages/domain/services/providers/salesforce/raw.types.ts`: Salesforce raw response shapes
|
||||
- `packages/domain/services/providers/salesforce/mapper.ts`: Salesforce → domain transformations
|
||||
|
||||
For the overall structure and import rules, see:
|
||||
|
||||
- `docs/architecture/domain-layer.md`
|
||||
- `docs/development/domain/structure.md`
|
||||
|
||||
@ -57,6 +57,7 @@ export const ErrorCode = {
|
||||
INSUFFICIENT_BALANCE: "BIZ_005",
|
||||
SERVICE_UNAVAILABLE: "BIZ_006",
|
||||
LEGACY_ACCOUNT_EXISTS: "BIZ_007",
|
||||
ACCOUNT_MAPPING_MISSING: "BIZ_008",
|
||||
|
||||
// System Errors (SYS_*)
|
||||
INTERNAL_ERROR: "SYS_001",
|
||||
@ -116,6 +117,8 @@ export const ErrorMessages: Record<ErrorCodeType, string> = {
|
||||
[ErrorCode.INSUFFICIENT_BALANCE]: "Insufficient account balance.",
|
||||
[ErrorCode.SERVICE_UNAVAILABLE]:
|
||||
"This service is temporarily unavailable. Please try again later.",
|
||||
[ErrorCode.ACCOUNT_MAPPING_MISSING]:
|
||||
"Your account isn’t fully set up yet. Please contact support or try again later.",
|
||||
|
||||
// System
|
||||
[ErrorCode.INTERNAL_ERROR]: "An unexpected error occurred. Please try again later.",
|
||||
@ -299,6 +302,13 @@ export const ErrorMetadata: Record<ErrorCodeType, ErrorMetadata> = {
|
||||
shouldRetry: true,
|
||||
logLevel: "warn",
|
||||
},
|
||||
[ErrorCode.ACCOUNT_MAPPING_MISSING]: {
|
||||
category: "business",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "warn",
|
||||
},
|
||||
|
||||
// System - high severity
|
||||
[ErrorCode.INTERNAL_ERROR]: {
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./raw.types.js";
|
||||
export * from "./mapper.js";
|
||||
export * from "./field-map.js";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user