diff --git a/apps/bff/src/modules/orders/config/order-field-map.service.ts b/apps/bff/src/integrations/salesforce/config/order-field-map.service.ts similarity index 89% rename from apps/bff/src/modules/orders/config/order-field-map.service.ts rename to apps/bff/src/integrations/salesforce/config/order-field-map.service.ts index af47671d..0e88a179 100644 --- a/apps/bff/src/modules/orders/config/order-field-map.service.ts +++ b/apps/bff/src/integrations/salesforce/config/order-field-map.service.ts @@ -15,8 +15,19 @@ const SECTION_PREFIX: Record = { 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) { diff --git a/apps/bff/src/integrations/salesforce/config/salesforce-order-field-config.module.ts b/apps/bff/src/integrations/salesforce/config/salesforce-order-field-config.module.ts new file mode 100644 index 00000000..50a05703 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/config/salesforce-order-field-config.module.ts @@ -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 {} diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index c98881f2..e85dd72b 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -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, diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts index 165f084d..2908623b 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts @@ -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"; diff --git a/apps/bff/src/modules/billing/billing.controller.ts b/apps/bff/src/modules/billing/billing.controller.ts index e68b07a4..9d0a6d94 100644 --- a/apps/bff/src/modules/billing/billing.controller.ts +++ b/apps/bff/src/modules/billing/billing.controller.ts @@ -103,12 +103,11 @@ export class BillingController { @Query() query: InvoiceSsoQueryDto ): Promise { 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 { 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, }; } } diff --git a/apps/bff/src/modules/id-mappings/domain/README.md b/apps/bff/src/modules/id-mappings/domain/README.md new file mode 100644 index 00000000..181c4716 --- /dev/null +++ b/apps/bff/src/modules/id-mappings/domain/README.md @@ -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/`. diff --git a/apps/bff/src/modules/me-status/me-status.service.ts b/apps/bff/src/modules/me-status/me-status.service.ts index 96e83c95..7d9bee15 100644 --- a/apps/bff/src/modules/me-status/me-status.service.ts +++ b/apps/bff/src/modules/me-status/me-status.service.ts @@ -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, diff --git a/apps/bff/src/modules/notifications/notifications.controller.ts b/apps/bff/src/modules/notifications/notifications.controller.ts index 09f04dde..b45c858b 100644 --- a/apps/bff/src/modules/notifications/notifications.controller.ts +++ b/apps/bff/src/modules/notifications/notifications.controller.ts @@ -41,12 +41,10 @@ export class NotificationsController { @Req() req: RequestWithUser, @Query() query: NotificationQueryDto ): Promise { - 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, }); } diff --git a/apps/bff/src/modules/orders/config/order-field-config.module.ts b/apps/bff/src/modules/orders/config/order-field-config.module.ts deleted file mode 100644 index 48bfbeac..00000000 --- a/apps/bff/src/modules/orders/config/order-field-config.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index 8af709d5..c02a7a9d 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -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: [ diff --git a/apps/bff/src/modules/orders/services/order-builder.service.ts b/apps/bff/src/modules/orders/services/order-builder.service.ts index beba568b..5e1fabc3 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -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, 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, 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, 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, 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, userId: string, body: OrderBusinessValidation, - fieldNames: OrderFieldMapService["fields"]["order"] + fieldNames: SalesforceOrderFieldMapService["fields"]["order"] ): Promise { try { const profile = await this.usersFacade.getProfile(userId); diff --git a/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts b/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts index 09231a7a..0eee38a4 100644 --- a/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts +++ b/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts @@ -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 { - 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 { - 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 { - 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; } diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts index 77eb69cc..89d00ad1 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts @@ -193,8 +193,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimReissueEsimRequestDto ): Promise { - 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" }; } diff --git a/apps/bff/src/modules/verification/residence-card.controller.ts b/apps/bff/src/modules/verification/residence-card.controller.ts index df0db842..252cf7d3 100644 --- a/apps/bff/src/modules/verification/residence-card.controller.ts +++ b/apps/bff/src/modules/verification/residence-card.controller.ts @@ -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, + content: file.buffer, }); } } diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts index 8f31f077..781d32d0 100644 --- a/apps/bff/src/modules/verification/residence-card.service.ts +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -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; + content: Buffer; }): Promise { 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({ diff --git a/apps/portal/src/features/billing/hooks/useBilling.ts b/apps/portal/src/features/billing/hooks/useBilling.ts index d9941632..cde557f8 100644 --- a/apps/portal/src/features/billing/hooks/useBilling.ts +++ b/apps/portal/src/features/billing/hooks/useBilling.ts @@ -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; @@ -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 { - const query = params ? toQueryParams(params) : undefined; - const response = await apiClient.GET( - "/api/invoices", - query ? { params: { query } } : undefined - ); - const data = getDataOrDefault(response, EMPTY_INVOICE_LIST); - return invoiceListSchema.parse(data); -} - -async function fetchInvoice(id: string): Promise { - const response = await apiClient.GET("/api/invoices/{id}", { - params: { path: { id } }, - }); - const invoice = getDataOrThrow(response, "Invoice not found"); - return invoiceSchema.parse(invoice); -} - -async function fetchPaymentMethods(): Promise { - const response = await apiClient.GET("/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("/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 ): UseMutationResult { return useMutation({ - mutationFn: async () => { - const response = await apiClient.POST("/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, }); } diff --git a/apps/portal/src/features/billing/index.ts b/apps/portal/src/features/billing/index.ts index fc858b7b..84c1b90a 100644 --- a/apps/portal/src/features/billing/index.ts +++ b/apps/portal/src/features/billing/index.ts @@ -1,2 +1,3 @@ export * from "./hooks"; export * from "./components"; +export * from "./services"; diff --git a/apps/portal/src/features/billing/services/billing.service.ts b/apps/portal/src/features/billing/services/billing.service.ts new file mode 100644 index 00000000..3ad0cecb --- /dev/null +++ b/apps/portal/src/features/billing/services/billing.service.ts @@ -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 { + const query = params ? toQueryParams(params) : undefined; + const response = await apiClient.GET( + "/api/invoices", + query ? { params: { query } } : undefined + ); + const data = getDataOrDefault(response, EMPTY_INVOICE_LIST); + return invoiceListSchema.parse(data); +} + +async function getInvoice(id: string): Promise { + const response = await apiClient.GET("/api/invoices/{id}", { + params: { path: { id } }, + }); + const invoice = getDataOrThrow(response, "Invoice not found"); + return invoiceSchema.parse(invoice); +} + +async function getPaymentMethods(): Promise { + const response = await apiClient.GET("/api/invoices/payment-methods"); + return getDataOrDefault(response, EMPTY_PAYMENT_METHODS); +} + +async function createInvoiceSsoLink( + invoiceId: number, + target?: "view" | "download" | "pay" +): Promise { + const response = await apiClient.POST("/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 { + const response = await apiClient.POST("/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 }; diff --git a/apps/portal/src/features/billing/services/index.ts b/apps/portal/src/features/billing/services/index.ts new file mode 100644 index 00000000..91a4d251 --- /dev/null +++ b/apps/portal/src/features/billing/services/index.ts @@ -0,0 +1 @@ +export { billingService, EMPTY_INVOICE_LIST, EMPTY_PAYMENT_METHODS } from "./billing.service"; diff --git a/docs/development/domain/types.md b/docs/development/domain/types.md index cfffdbc0..43e700b4 100644 --- a/docs/development/domain/types.md +++ b/docs/development/domain/types.md @@ -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` diff --git a/packages/domain/common/errors.ts b/packages/domain/common/errors.ts index 55725ea1..a7e4d3e4 100644 --- a/packages/domain/common/errors.ts +++ b/packages/domain/common/errors.ts @@ -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 = { [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 = { shouldRetry: true, logLevel: "warn", }, + [ErrorCode.ACCOUNT_MAPPING_MISSING]: { + category: "business", + severity: "medium", + shouldLogout: false, + shouldRetry: false, + logLevel: "warn", + }, // System - high severity [ErrorCode.INTERNAL_ERROR]: { diff --git a/packages/domain/orders/providers/salesforce/index.ts b/packages/domain/orders/providers/salesforce/index.ts index fa8002ef..41f03362 100644 --- a/packages/domain/orders/providers/salesforce/index.ts +++ b/packages/domain/orders/providers/salesforce/index.ts @@ -1,2 +1,3 @@ export * from "./raw.types.js"; export * from "./mapper.js"; +export * from "./field-map.js";