refactor: enhance address handling in BFF workflows

- Integrated AddressWriterService into GuestEligibilityWorkflowService and NewCustomerSignupWorkflowService for improved address writing to Salesforce.
- Updated AddressModule to include SalesforceModule and export AddressWriterService.
- Refactored address handling in various workflows to utilize the new address structure, ensuring consistency and reliability in address processing.
- Removed deprecated address building logic from eligibility check store, streamlining address management across components.
This commit is contained in:
barsa 2026-03-03 16:33:40 +09:00
parent 26a1419189
commit 6299fbabdc
17 changed files with 1356 additions and 196 deletions

View File

@ -0,0 +1,129 @@
/**
* Address Writer Service
*
* Centralizes bilingual address handling for registration workflows.
* Writes Japanese address to Salesforce and English address to WHMCS.
*
* - SF: Uses Japanese fields (prefectureJa, cityJa, townJa) directly from bilingual address
* - WHMCS: Resolves English fields by looking up postal code via Japan Post API
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import {
type BilingualAddress,
type WhmcsAddressFields,
prepareSalesforceContactAddressFields,
prepareWhmcsAddressFields,
} from "@customer-portal/domain/address";
import { JapanPostFacade } from "@bff/integrations/japanpost/index.js";
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
export interface ResolveWhmcsAddressParams {
postcode: string;
/** Japanese town name for disambiguation when postal code returns multiple results */
townJa?: string | undefined;
streetAddress: string;
buildingName?: string | null | undefined;
roomNumber?: string | null | undefined;
residenceType: "house" | "apartment";
}
@Injectable()
export class AddressWriterService {
constructor(
private readonly japanPost: JapanPostFacade,
private readonly salesforceFacade: SalesforceFacade,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Write Japanese address to Salesforce Contact.
* Uses prepareSalesforceContactAddressFields() for field mapping.
*/
async writeToSalesforce(sfAccountId: string, address: BilingualAddress): Promise<void> {
const sfFields = prepareSalesforceContactAddressFields(address);
await this.salesforceFacade.updateContactAddress(sfAccountId, {
mailingStreet: sfFields.MailingStreet,
mailingCity: sfFields.MailingCity,
mailingState: sfFields.MailingState,
mailingPostalCode: sfFields.MailingPostalCode,
mailingCountry: sfFields.MailingCountry,
buildingName: sfFields.BuildingName__c,
roomNumber: sfFields.RoomNumber__c,
});
this.logger.debug({ sfAccountId }, "Wrote Japanese address to Salesforce");
}
/**
* Resolve English address from postal code via Japan Post API
* and prepare WHMCS address fields.
*
* Flow:
* 1. Call Japan Post API with postal code
* 2. Match result using townJa (if multiple matches)
* 3. Build BilingualAddress with resolved English fields
* 4. Return WhmcsAddressFields via prepareWhmcsAddressFields()
*/
async resolveWhmcsAddress(params: ResolveWhmcsAddressParams): Promise<WhmcsAddressFields> {
const { postcode, townJa, streetAddress, buildingName, roomNumber, residenceType } = params;
const lookupResult = await this.japanPost.lookupByZipCode(postcode);
if (lookupResult.addresses.length === 0) {
this.logger.warn({ postcode }, "Japan Post API returned no results for postal code");
throw new Error(`No address found for postal code ${postcode}`);
}
// Find the matching address entry
let matched = lookupResult.addresses[0]!;
if (townJa && lookupResult.addresses.length > 1) {
const townMatch = lookupResult.addresses.find(a => a.town === townJa);
if (townMatch) {
matched = townMatch;
} else {
this.logger.warn(
{ postcode, townJa, availableTowns: lookupResult.addresses.map(a => a.town) },
"Could not match townJa in Japan Post results, using first result"
);
}
}
// Build a BilingualAddress from resolved data
const bilingualAddress: BilingualAddress = {
postcode,
prefecture: matched.prefectureRoma,
city: matched.cityRoma,
town: matched.townRoma,
prefectureJa: matched.prefecture,
cityJa: matched.city,
townJa: matched.town,
streetAddress,
buildingName: buildingName ?? null,
roomNumber: roomNumber ?? null,
residenceType,
};
return prepareWhmcsAddressFields(bilingualAddress);
}
/**
* Convenience: resolve WHMCS address from a full BilingualAddress.
* Re-derives English fields from postal code via Japan Post API.
*/
async resolveWhmcsAddressFromBilingual(address: BilingualAddress): Promise<WhmcsAddressFields> {
return this.resolveWhmcsAddress({
postcode: address.postcode,
townJa: address.townJa,
streetAddress: address.streetAddress,
buildingName: address.buildingName,
roomNumber: address.roomNumber,
residenceType: address.residenceType,
});
}
}

View File

@ -1,15 +1,19 @@
/**
* Address Module
*
* NestJS module for address lookup functionality.
* NestJS module for address lookup and writing functionality.
*/
import { Module } from "@nestjs/common";
import { AddressController } from "./address.controller.js";
import { AddressWriterService } from "./address-writer.service.js";
import { JapanPostModule } from "@bff/integrations/japanpost/japanpost.module.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
@Module({
imports: [JapanPostModule],
imports: [JapanPostModule, SalesforceModule],
controllers: [AddressController],
providers: [AddressWriterService],
exports: [AddressWriterService],
})
export class AddressModule {}

View File

@ -3,6 +3,7 @@ import { UsersModule } from "@bff/modules/users/users.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
import { AddressModule } from "@bff/modules/address/address.module.js";
import { TokensModule } from "../tokens/tokens.module.js";
import { OtpModule } from "../otp/otp.module.js";
// Coordinator
@ -38,6 +39,7 @@ import { GetStartedController } from "../presentation/http/get-started.controlle
MappingsModule,
IntegrationsModule,
WorkflowModule,
AddressModule,
],
controllers: [GetStartedController],
providers: [

View File

@ -14,6 +14,7 @@ import {
PORTAL_SOURCE_INTERNET_ELIGIBILITY,
PORTAL_STATUS_NOT_YET,
} from "@bff/modules/auth/constants/portal.constants.js";
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
import { CreateEligibilityCaseStep } from "./steps/index.js";
@ -41,6 +42,7 @@ export class GuestEligibilityWorkflowService {
private readonly salesforceFacade: SalesforceFacade,
private readonly lockService: DistributedLockService,
private readonly eligibilityCaseStep: CreateEligibilityCaseStep,
private readonly addressWriter: AddressWriterService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -99,31 +101,25 @@ export class GuestEligibilityWorkflowService {
);
}
// Save Japanese address to SF Contact (if Japanese address fields provided)
if (address.prefectureJa || address.cityJa || address.townJa) {
await this.salesforceAccountService.updateContactAddress(sfAccountId, {
mailingStreet: `${address.townJa || ""}${address.streetAddress || ""}`.trim(),
mailingCity: address.cityJa || address.city,
mailingState: address.prefectureJa || address.state,
mailingPostalCode: address.postcode,
mailingCountry: "Japan",
buildingName: address.buildingName ?? null,
roomNumber: address.roomNumber ?? null,
});
this.logger.debug({ sfAccountId }, "Updated SF Contact with Japanese address");
// Write Japanese address to Salesforce
try {
await this.addressWriter.writeToSalesforce(sfAccountId, address);
} catch (addressError) {
this.logger.warn(
{ error: extractErrorMessage(addressError), email: normalizedEmail },
"SF address write failed (non-critical, continuing)"
);
}
// Create eligibility case via shared step
const { caseId } = await this.eligibilityCaseStep.execute({
sfAccountId,
address: {
address1: address.address1,
...(address.address2 ? { address2: address.address2 } : {}),
city: address.city,
state: address.state,
address1: `${address.townJa}${address.streetAddress}`,
city: address.cityJa,
state: address.prefectureJa,
postcode: address.postcode,
...(address.country ? { country: address.country } : {}),
...(address.streetAddress ? { streetAddress: address.streetAddress } : {}),
country: "Japan",
},
});

View File

@ -12,6 +12,7 @@ import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/w
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { PORTAL_SOURCE_INTERNET_ELIGIBILITY } from "@bff/modules/auth/constants/portal.constants.js";
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
import {
@ -46,6 +47,7 @@ export class NewCustomerSignupWorkflowService {
private readonly portalUserStep: CreatePortalUserStep,
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
private readonly authResultStep: GenerateAuthResultStep,
private readonly addressWriter: AddressWriterService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -146,18 +148,27 @@ export class NewCustomerSignupWorkflowService {
updateSourceIfExists: true,
});
// Step 1.5: Write Japanese address to Salesforce (DEGRADABLE)
try {
await this.addressWriter.writeToSalesforce(sfResult.sfAccountId, address);
} catch (addressError) {
this.logger.warn(
{ error: extractErrorMessage(addressError), email },
"SF address write failed (non-critical, continuing)"
);
}
// Step 2: Create eligibility case (DEGRADABLE)
let eligibilityRequestId: string | undefined;
try {
const caseResult = await this.caseStep.execute({
sfAccountId: sfResult.sfAccountId,
address: {
address1: address.address1,
...(address.address2 && { address2: address.address2 }),
city: address.city,
state: address.state,
address1: `${address.townJa}${address.streetAddress}`,
city: address.cityJa,
state: address.prefectureJa,
postcode: address.postcode,
...(address.country ? { country: address.country } : {}),
country: "Japan",
},
});
eligibilityRequestId = caseResult.caseId;
@ -169,6 +180,8 @@ export class NewCustomerSignupWorkflowService {
}
// Step 3: Create WHMCS client (CRITICAL, has rollback)
const whmcsAddress = await this.addressWriter.resolveWhmcsAddressFromBilingual(address);
const whmcsResult = await this.whmcsStep.execute({
email,
password,
@ -176,12 +189,12 @@ export class NewCustomerSignupWorkflowService {
lastName,
phone: phone ?? "",
address: {
address1: address.address1,
...(address.address2 && { address2: address.address2 }),
city: address.city,
state: address.state ?? "",
postcode: address.postcode,
country: address.country ?? "Japan",
address1: whmcsAddress.address1 || "",
...(whmcsAddress.address2 && { address2: whmcsAddress.address2 }),
city: whmcsAddress.city,
state: whmcsAddress.state,
postcode: whmcsAddress.postcode,
country: whmcsAddress.country,
},
customerNumber: sfResult.customerNumber ?? null,
dateOfBirth,

View File

@ -10,6 +10,8 @@ import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/w
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { PORTAL_SOURCE_NEW_SIGNUP } from "@bff/modules/auth/constants/portal.constants.js";
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
import type { BilingualAddress } from "@customer-portal/domain/address";
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
import {
@ -39,6 +41,7 @@ export class SfCompletionWorkflowService {
private readonly portalUserStep: CreatePortalUserStep,
private readonly sfFlagsStep: UpdateSalesforceFlagsStep,
private readonly authResultStep: GenerateAuthResultStep,
private readonly addressWriter: AddressWriterService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -101,7 +104,7 @@ export class SfCompletionWorkflowService {
sfAccountId?: string | undefined;
firstName?: string | undefined;
lastName?: string | undefined;
address?: Record<string, string | undefined> | undefined;
address?: Record<string, unknown> | undefined;
}
): Promise<AuthResultInternal> {
const {
@ -132,7 +135,31 @@ export class SfCompletionWorkflowService {
source: PORTAL_SOURCE_NEW_SIGNUP,
});
// Step 1.5: Write Japanese address to Salesforce for new customers (DEGRADABLE)
if (isNewCustomer && address.prefectureJa) {
try {
await this.addressWriter.writeToSalesforce(
sfResult.sfAccountId,
address as BilingualAddress
);
} catch (addressError) {
this.logger.warn(
{ error: extractErrorMessage(addressError), email: session.email },
"SF address write failed (non-critical, continuing)"
);
}
}
// Step 2: Create WHMCS client (CRITICAL, has rollback)
const whmcsAddress = await this.addressWriter.resolveWhmcsAddress({
postcode: address.postcode,
townJa: address.townJa,
streetAddress: address.streetAddress || "",
buildingName: address.buildingName,
roomNumber: address.roomNumber,
residenceType: address.residenceType || "house",
});
const whmcsResult = await this.whmcsStep.execute({
firstName: finalFirstName,
lastName: finalLastName,
@ -140,12 +167,12 @@ export class SfCompletionWorkflowService {
password,
phone: phone ?? "",
address: {
address1: address.address1,
...(address.address2 && { address2: address.address2 }),
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country ?? "Japan",
address1: whmcsAddress.address1 || "",
...(whmcsAddress.address2 && { address2: whmcsAddress.address2 }),
city: whmcsAddress.city,
state: whmcsAddress.state,
postcode: whmcsAddress.postcode,
country: whmcsAddress.country,
},
customerNumber: sfResult.customerNumber ?? null,
dateOfBirth,
@ -216,13 +243,13 @@ export class SfCompletionWorkflowService {
private resolveAddress(
requestAddress: CompleteAccountRequest["address"] | undefined,
sessionAddress: Record<string, string | undefined> | undefined
sessionAddress: Record<string, unknown> | undefined
): NonNullable<CompleteAccountRequest["address"]> {
const address = requestAddress ?? sessionAddress;
if (!address || !address.address1 || !address.city || !address.state || !address.postcode) {
if (!address || !address.postcode) {
throw new BadRequestException(
"Address information is incomplete. Please ensure all required fields (address, city, prefecture, postcode) are provided."
"Address information is incomplete. Please ensure postcode is provided."
);
}

View File

@ -33,11 +33,11 @@ interface AccountStatusResult {
sfFirstName?: string;
sfLastName?: string;
sfAddress?: {
address1: string;
city: string;
state: string;
postcode: string;
country: string;
prefectureJa?: string;
cityJa?: string;
townJa?: string;
streetAddress?: string;
postcode?: string;
};
whmcsClientId?: number;
whmcsFirstName?: string;
@ -247,7 +247,15 @@ export class VerificationWorkflowService {
sfAccountId: sfAccount.id,
...(sfAccount.firstName && { sfFirstName: sfAccount.firstName }),
...(sfAccount.lastName && { sfLastName: sfAccount.lastName }),
...(sfAccount.address && { sfAddress: sfAccount.address }),
...(sfAccount.address && {
sfAddress: {
prefectureJa: sfAccount.address.state,
cityJa: sfAccount.address.city,
// MailingStreet contains townJa + streetAddress concatenated — store as-is
streetAddress: sfAccount.address.address1,
postcode: sfAccount.address.postcode,
},
}),
};
}

View File

@ -1,6 +1,5 @@
import { useState, useCallback } from "react";
import { type JapanAddressFormData } from "@/features/address/components/JapanAddressForm";
import { prepareWhmcsAddressFields } from "@customer-portal/domain/address";
import { usePasswordValidation } from "@/features/auth/hooks/usePasswordValidation";
import { phoneSchema } from "@customer-portal/domain/common";
import type { AccountFormErrors } from "./types";
@ -63,17 +62,7 @@ export function useCompleteAccountForm({
const handleAddressChange = useCallback(
(data: JapanAddressFormData, isComplete: boolean) => {
setIsAddressComplete(isComplete);
const whmcsFields = prepareWhmcsAddressFields(data);
updateFormData({
address: {
address1: whmcsFields.address1 || "",
address2: whmcsFields.address2 || "",
city: whmcsFields.city || "",
state: whmcsFields.state || "",
postcode: whmcsFields.postcode || "",
country: "JP",
},
});
updateFormData({ address: data });
},
[updateFormData]
);

View File

@ -6,6 +6,7 @@
*/
import { fromPromise } from "xstate";
import type { BilingualAddress } from "@customer-portal/domain/address";
import * as getStartedApi from "../api/get-started.api";
import type {
SendCodeInput,
@ -54,18 +55,7 @@ export const completeAccountActor = fromPromise<CompleteAccountOutput, CompleteA
marketingConsent: formData.marketingConsent,
...(isNewCustomer || formData.firstName ? { firstName: formData.firstName } : {}),
...(isNewCustomer || formData.lastName ? { lastName: formData.lastName } : {}),
...(isNewCustomer || formData.address?.address1
? {
address: {
address1: formData.address.address1 || "",
address2: formData.address.address2,
city: formData.address.city || "",
state: formData.address.state || "",
postcode: formData.address.postcode || "",
country: formData.address.country || "JP",
},
}
: {}),
...(formData.address?.postcode ? { address: formData.address as BilingualAddress } : {}),
});
}
);

View File

@ -10,18 +10,19 @@
import { setup, assign } from "xstate";
import { getErrorMessage } from "@/shared/utils";
import type {
GetStartedAddress,
GetStartedContext,
GetStartedEvent,
GetStartedFormData,
GetStartedMachineInput,
} from "./get-started.types";
import {
sendCodeActor,
verifyCodeActor,
completeAccountActor,
migrateAccountActor,
} from "./get-started.actors";
import type {
GetStartedContext,
GetStartedEvent,
GetStartedFormData,
GetStartedMachineInput,
} from "./get-started.types";
// ============================================================================
// Initial form data (matches store defaults)
@ -153,17 +154,7 @@ export const getStartedMachine = setup({
firstName: prefill.firstName ?? context.formData.firstName,
lastName: prefill.lastName ?? context.formData.lastName,
phone: prefill.phone ?? context.formData.phone,
address: prefill.address
? {
address1: prefill.address.address1,
address2: prefill.address.address2,
city: prefill.address.city,
state: prefill.address.state,
postcode: prefill.address.postcode,
country: prefill.address.country,
countryCode: prefill.address.countryCode,
}
: context.formData.address,
address: (prefill.address as GetStartedAddress) ?? context.formData.address,
};
},
}),
@ -271,17 +262,7 @@ export const getStartedMachine = setup({
firstName: prefill.firstName ?? context.formData.firstName,
lastName: prefill.lastName ?? context.formData.lastName,
phone: prefill.phone ?? context.formData.phone,
address: prefill.address
? {
address1: prefill.address.address1,
address2: prefill.address.address2,
city: prefill.address.city,
state: prefill.address.state,
postcode: prefill.address.postcode,
country: prefill.address.country,
countryCode: prefill.address.countryCode,
}
: context.formData.address,
address: (prefill.address as GetStartedAddress) ?? context.formData.address,
};
},
}),

View File

@ -10,6 +10,7 @@ import type {
SendVerificationCodeResponse,
} from "@customer-portal/domain/get-started";
import type { AuthResponse } from "@customer-portal/domain/auth";
import type { BilingualAddress } from "@customer-portal/domain/address";
// ============================================================================
// Form & Service Types (mirrored from store for compatibility)
@ -21,15 +22,9 @@ export interface ServiceContext {
redirectTo?: string | undefined;
}
export interface GetStartedAddress {
address1?: string | undefined;
address2?: string | undefined;
city?: string | undefined;
state?: string | undefined;
postcode?: string | undefined;
country?: string | undefined;
countryCode?: string | undefined;
}
export type GetStartedAddress = {
[K in keyof BilingualAddress]?: BilingualAddress[K] | undefined;
};
export interface GetStartedFormData {
email: string;

View File

@ -17,7 +17,6 @@ import {
verifyCode,
signupWithEligibility,
} from "@/features/get-started/api/get-started.api";
import { prepareWhmcsAddressFields } from "@customer-portal/domain/address";
// ============================================================================
// Types
@ -150,47 +149,6 @@ const initialState = {
_resendTimerId: null,
};
// ============================================================================
// Address Building Helper
// ============================================================================
interface EligibilityCheckAddress {
address1: string;
address2?: string;
city: string;
state: string;
postcode: string;
country: "JP";
prefectureJa?: string;
cityJa?: string;
townJa?: string;
streetAddress?: string;
buildingName?: string;
roomNumber?: string;
}
function buildEligibilityAddress(addressData: JapanAddressFormData): EligibilityCheckAddress {
const whmcsAddress = prepareWhmcsAddressFields(addressData);
const address: EligibilityCheckAddress = {
address1: whmcsAddress.address1 || "",
city: whmcsAddress.city || "",
state: whmcsAddress.state || "",
postcode: whmcsAddress.postcode || "",
country: "JP",
};
if (whmcsAddress.address2) address.address2 = whmcsAddress.address2;
if (addressData.prefectureJa) address.prefectureJa = addressData.prefectureJa;
if (addressData.cityJa) address.cityJa = addressData.cityJa;
if (addressData.townJa) address.townJa = addressData.townJa;
if (addressData.streetAddress) address.streetAddress = addressData.streetAddress;
if (addressData.buildingName) address.buildingName = addressData.buildingName;
if (addressData.roomNumber) address.roomNumber = addressData.roomNumber;
return address;
}
// ============================================================================
// Action Implementations (extracted to reduce store function length)
// ============================================================================
@ -213,7 +171,7 @@ async function submitOnlyAction(get: StoreGet, set: StoreSet): Promise<boolean>
email: formData.email.trim(),
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(),
address: buildEligibilityAddress(formData.address),
address: formData.address,
continueToAccount: false,
});
@ -363,20 +321,11 @@ async function completeAccountAction(get: StoreGet, set: StoreSet): Promise<bool
set({ loading: true, error: null });
try {
const whmcsAddress = prepareWhmcsAddressFields(formData.address);
const result = await signupWithEligibility({
sessionToken,
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(),
address: {
address1: whmcsAddress.address1 || "",
...(whmcsAddress.address2 && { address2: whmcsAddress.address2 }),
city: whmcsAddress.city || "",
state: whmcsAddress.state || "",
postcode: whmcsAddress.postcode || "",
country: "JP",
},
address: formData.address,
phone: accountData.phone.trim(),
password: accountData.password,
dateOfBirth: accountData.dateOfBirth,

View File

@ -0,0 +1,152 @@
# Bilingual Address Handler for Registration
**Date**: 2026-03-03
**Status**: Design
## Problem
Registration flows use `addressFormSchema` (simple English-only: address1, address2, city, state, postcode). The bilingual address infrastructure exists (`bilingualAddressSchema`, `prepareWhmcsAddressFields`, `prepareSalesforceContactAddressFields`, `JapanAddressForm`, Japan Post API integration) but isn't wired into signup workflows.
Salesforce receives English address data when it should receive Japanese. Address transformation logic is scattered inline across workflows.
## Requirements
| Outcome | SF Write | WHMCS Write | Address Input |
| ------------------- | ------------------------------- | ------------------------------- | ------------------------------- |
| A (New Customer) | Japanese (at signup) | English (at signup) | JapanAddressForm |
| B (SF-Only) | Japanese (at eligibility check) | English (at account completion) | JapanAddressForm at eligibility |
| C (WHMCS Migration) | None | None | None |
| D (Portal Exists) | None | None | None |
## Key Decisions
1. **Japan Post API postal code lookup** auto-populates both JA and EN fields on the frontend.
2. **Always re-derive EN fields from postal code** via Japan Post API on the backend when writing to WHMCS. One code path regardless of whether data is in Redis (fresh) or Salesforce (days later). Avoids Redis TTL dependency.
3. **Centralized `AddressWriterService`** in BFF orchestrates all address writes.
4. **`bilingualAddressSchema` replaces `addressFormSchema`** in all signup request/session/handoff schemas.
5. **`JapanAddressForm` replaces simple address fields** in all signup frontend forms.
## Architecture
### AddressWriterService (BFF)
Location: `apps/bff/src/modules/address/address-writer.service.ts`
```
AddressWriterService
Dependencies: JapanPostFacade, SalesforceAccountService
writeToSalesforce(sfAccountId, bilingualAddress)
→ prepareSalesforceContactAddressFields(address) → SF Contact update
→ Writes: MailingStreet (JA town+street), MailingCity (JA), MailingState (JA), BuildingName__c, RoomNumber__c
resolveAndPrepareWhmcsAddress(postalCode, townJa?, streetAddress, buildingInfo)
→ Japan Post API lookup by postal code
→ Match correct result using townJa (for multi-match postal codes)
→ prepareWhmcsAddressFields() with resolved EN fields
→ Returns: WhmcsAddressFields (address1, address2, city, state, postcode, country)
```
### Schema Changes
Replace `addressFormSchema` with `bilingualAddressSchema` in:
| Schema | File | Change |
| ------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| `signupWithEligibilityRequestSchema` | `domain/get-started/schema.ts` | `address: addressFormSchema``address: bilingualAddressSchema` |
| `completeAccountRequestSchema` | `domain/get-started/schema.ts` | `address: addressFormSchema.optional()``address: bilingualAddressSchema.optional()` |
| `getStartedSessionSchema` | `domain/get-started/schema.ts` | `address: addressFormSchema.partial().optional()``address: bilingualAddressSchema.partial().optional()` |
| `guestHandoffTokenSchema` | `domain/get-started/schema.ts` | `address: addressFormSchema.partial().optional()``address: bilingualAddressSchema.partial().optional()` |
| `verifyCodeResponseSchema` prefill | `domain/get-started/schema.ts` | `address: addressFormSchema.partial().optional()``address: bilingualAddressSchema.partial().optional()` |
| `guestEligibilityRequestSchema` | `domain/get-started/schema.ts` | Remove `bilingualEligibilityAddressSchema`, use `bilingualAddressSchema` |
### Workflow Changes
**NewCustomerSignupWorkflowService (Outcome A)**:
- Accept `bilingualAddressSchema` from request
- After SF account creation: `addressWriter.writeToSalesforce(sfAccountId, address)` → JA to SF
- For WHMCS client: `addressWriter.resolveAndPrepareWhmcsAddress(postcode, townJa, streetAddress, building)` → EN to WHMCS
**GuestEligibilityWorkflowService (Outcome B - eligibility check)**:
- Refactor inline SF address write (lines 103-113) to use `addressWriter.writeToSalesforce()`
- Store bilingual address in handoff token (for B1 immediate flow)
**SfCompletionWorkflowService (Outcome B - account completion)**:
- Resolve address from session (may have bilingual data if B1, or JA-only + postcode if B2)
- `addressWriter.resolveAndPrepareWhmcsAddress(postcode, townJa, streetAddress, building)` → EN to WHMCS
### Frontend Changes
Replace simple address fields with `JapanAddressForm` component in:
- Signup with eligibility form (Outcome A)
- Guest eligibility form (Outcome B)
- Forms submit `bilingualAddressSchema` shape
No address form needed for:
- Complete account (Outcome B) — address from session
- WHMCS migration (Outcome C) — no address needed
- Portal exists (Outcome D) — redirect to login
### Data Flow Diagrams
**Outcome A (New Customer)**:
```
User → JapanAddressForm → postal code lookup → auto-fill JA+EN
→ Submit (bilingualAddress) → BFF
→ addressWriter.writeToSalesforce(sfAccountId, address) → SF gets JA
→ addressWriter.resolveAndPrepareWhmcsAddress(postcode, townJa, ...) → Japan Post API → WHMCS gets EN
```
**Outcome B1 (SF-Only, immediate)**:
```
User → JapanAddressForm (guest eligibility) → Submit (bilingualAddress) → BFF
→ addressWriter.writeToSalesforce(sfAccountId, address) → SF gets JA
→ Store bilingual in handoff token → Redis (30 min TTL)
→ User verifies email → complete account
→ addressWriter.resolveAndPrepareWhmcsAddress(postcode, townJa, ...) → Japan Post API → WHMCS gets EN
```
**Outcome B2 (SF-Only, returns days later)**:
```
[Days ago] Guest eligibility → SF got JA address + postal code
[Now] User verifies email → system detects SF_UNMAPPED
→ Prefill from SF: postal code, JA address fields
→ addressWriter.resolveAndPrepareWhmcsAddress(postcode, townJa, ...) → Japan Post API → WHMCS gets EN
```
## Files to Modify
### Domain (packages/domain/)
- `get-started/schema.ts` — Replace addressFormSchema references with bilingualAddressSchema
- `address/schema.ts` — May need minor additions for the resolve flow
### BFF (apps/bff/)
- **New**: `modules/address/address-writer.service.ts` — Centralized address write service
- `modules/address/address.module.ts` — Export AddressWriterService
- `modules/auth/infra/workflows/new-customer-signup-workflow.service.ts` — Use AddressWriterService
- `modules/auth/infra/workflows/guest-eligibility-workflow.service.ts` — Use AddressWriterService
- `modules/auth/infra/workflows/sf-completion-workflow.service.ts` — Use AddressWriterService
- `modules/auth/infra/workflows/steps/create-whmcs-client.step.ts` — Accept WhmcsAddressFields
- `modules/auth/infra/workflows/verification-workflow.service.ts` — Prefill bilingual address from SF
### Portal (apps/portal/)
- Signup with eligibility form — Replace address fields with JapanAddressForm
- Guest eligibility form — Ensure JapanAddressForm is used, submits bilingualAddressSchema
- API layer — Update request types to match new schemas
## Risks
- **Japan Post API availability**: If API is down during WHMCS write, registration fails. Mitigation: retry with backoff (existing retry.util), graceful error message.
- **Multi-match postal codes**: Some postal codes return multiple town entries. Mitigation: match using `townJa` from the stored/submitted address.
- **Schema migration**: Changing session/handoff schemas could affect in-flight registrations. Mitigation: make new fields optional, handle old format gracefully during rollout.

View File

@ -0,0 +1,951 @@
# Bilingual Address Handler Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Centralize address handling so registration writes Japanese address to Salesforce and English address to WHMCS, using the existing bilingual address infrastructure.
**Architecture:** Replace `addressFormSchema` (simple English) with `bilingualAddressSchema` in all signup request/session/handoff schemas. Create `AddressWriterService` in BFF that centralizes SF (Japanese) and WHMCS (English via Japan Post API postal code lookup) address writes. Update all signup workflows to use it. Frontend already uses `JapanAddressForm` — just stop converting to WHMCS format before sending.
**Tech Stack:** Zod schemas (domain), NestJS services (BFF), React/XState/Zustand (Portal), Japan Post API
---
### Task 1: Update Domain Schemas — Replace addressFormSchema with bilingualAddressSchema in get-started
**Files:**
- Modify: `packages/domain/get-started/schema.ts`
- Modify: `packages/domain/get-started/contract.ts`
- Modify: `packages/domain/get-started/index.ts`
**Context:** The get-started schemas currently reference `addressFormSchema` (simple: address1, address2, city, state, postcode, country) from `customer/schema.ts`. We need to replace all these references with `bilingualAddressSchema` from `address/schema.ts`, which has both Japanese and English fields. We also remove the duplicate `bilingualEligibilityAddressSchema` that exists in this file.
**Step 1: Update imports in `packages/domain/get-started/schema.ts`**
Replace the import on line 16-17:
```typescript
// OLD (line 16-17):
import { addressFormSchema } from "../customer/schema.js";
// NEW:
import { bilingualAddressSchema } from "../address/schema.js";
```
**Step 2: Remove `bilingualEligibilityAddressSchema` (lines 124-140)**
Delete the entire `bilingualEligibilityAddressSchema` definition. It's a duplicate of `bilingualAddressSchema` with slightly different field definitions. We'll use `bilingualAddressSchema` directly.
**Step 3: Update `guestEligibilityRequestSchema` (line 146-159)**
Change line 154 from:
```typescript
address: bilingualEligibilityAddressSchema,
```
to:
```typescript
address: bilingualAddressSchema,
```
**Step 4: Update `guestHandoffTokenSchema` (line 181-202)**
Change line 195 from:
```typescript
address: addressFormSchema.partial().optional(),
```
to:
```typescript
address: bilingualAddressSchema.partial().optional(),
```
**Step 5: Update `completeAccountRequestSchema` (line 215-238)**
Change line 223 from:
```typescript
address: addressFormSchema.optional(),
```
to:
```typescript
address: bilingualAddressSchema.optional(),
```
**Step 6: Update `signupWithEligibilityRequestSchema` (line 249-272)**
Change line 257 from:
```typescript
address: addressFormSchema,
```
to:
```typescript
address: bilingualAddressSchema,
```
**Step 7: Update `verifyCodeResponseSchema` prefill (line 80-102)**
Change line 98 from:
```typescript
address: addressFormSchema.partial().optional(),
```
to:
```typescript
address: bilingualAddressSchema.partial().optional(),
```
**Step 8: Update `getStartedSessionSchema` (line 320-345)**
Change line 330 from:
```typescript
address: addressFormSchema.partial().optional(),
```
to:
```typescript
address: bilingualAddressSchema.partial().optional(),
```
**Step 9: Update `packages/domain/get-started/contract.ts`**
Remove the import of `bilingualEligibilityAddressSchema` on line 19 and the type export on line 90:
```typescript
// Remove from imports (line 19):
bilingualEligibilityAddressSchema,
// Remove from types (line 90):
export type BilingualEligibilityAddress = z.infer<typeof bilingualEligibilityAddressSchema>;
```
**Step 10: Update `packages/domain/get-started/index.ts`**
Remove the export of `bilingualEligibilityAddressSchema` on line 52 and `BilingualEligibilityAddress` on line 28:
```typescript
// Remove from schema exports (line 52):
bilingualEligibilityAddressSchema,
// Remove from type exports (line 28):
type BilingualEligibilityAddress,
```
**Step 11: Build domain package and verify**
Run: `pnpm domain:build`
Expected: Build succeeds
Run: `pnpm type-check`
Expected: May show errors in BFF/Portal files that reference the old schemas — those will be fixed in subsequent tasks.
**Step 12: Commit**
```bash
git add packages/domain/get-started/
git commit -m "refactor: replace addressFormSchema with bilingualAddressSchema in get-started schemas"
```
---
### Task 2: Create AddressWriterService in BFF
**Files:**
- Create: `apps/bff/src/modules/address/address-writer.service.ts`
- Modify: `apps/bff/src/modules/address/address.module.ts`
**Context:** This service centralizes all address transformation and writing logic. It uses `prepareSalesforceContactAddressFields()` from the domain for SF writes, and calls the Japan Post API to resolve English fields for WHMCS writes. It depends on `JapanPostFacade` (for postal code lookup) and `SalesforceFacade` (for SF contact updates).
**Step 1: Create `apps/bff/src/modules/address/address-writer.service.ts`**
```typescript
/**
* Address Writer Service
*
* Centralizes bilingual address handling for registration workflows.
* Writes Japanese address to Salesforce and English address to WHMCS.
*
* - SF: Uses Japanese fields (prefectureJa, cityJa, townJa) directly from bilingual address
* - WHMCS: Resolves English fields by looking up postal code via Japan Post API
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import {
type BilingualAddress,
type WhmcsAddressFields,
prepareSalesforceContactAddressFields,
prepareWhmcsAddressFields,
} from "@customer-portal/domain/address";
import { JapanPostFacade } from "@bff/integrations/japanpost/index.js";
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
export interface ResolveWhmcsAddressParams {
postcode: string;
/** Japanese town name for disambiguation when postal code returns multiple results */
townJa?: string | undefined;
streetAddress: string;
buildingName?: string | null | undefined;
roomNumber?: string | null | undefined;
residenceType: "house" | "apartment";
}
@Injectable()
export class AddressWriterService {
constructor(
private readonly japanPost: JapanPostFacade,
private readonly salesforceFacade: SalesforceFacade,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Write Japanese address to Salesforce Contact.
* Uses prepareSalesforceContactAddressFields() for field mapping.
*/
async writeToSalesforce(sfAccountId: string, address: BilingualAddress): Promise<void> {
const sfFields = prepareSalesforceContactAddressFields(address);
await this.salesforceFacade.updateContactAddress(sfAccountId, {
mailingStreet: sfFields.MailingStreet,
mailingCity: sfFields.MailingCity,
mailingState: sfFields.MailingState,
mailingPostalCode: sfFields.MailingPostalCode,
mailingCountry: sfFields.MailingCountry,
buildingName: sfFields.BuildingName__c,
roomNumber: sfFields.RoomNumber__c,
});
this.logger.debug({ sfAccountId }, "Wrote Japanese address to Salesforce");
}
/**
* Resolve English address from postal code via Japan Post API
* and prepare WHMCS address fields.
*
* Flow:
* 1. Call Japan Post API with postal code
* 2. Match result using townJa (if multiple matches)
* 3. Build BilingualAddress with resolved English fields
* 4. Return WhmcsAddressFields via prepareWhmcsAddressFields()
*/
async resolveWhmcsAddress(params: ResolveWhmcsAddressParams): Promise<WhmcsAddressFields> {
const { postcode, townJa, streetAddress, buildingName, roomNumber, residenceType } = params;
const lookupResult = await this.japanPost.lookupByZipCode(postcode);
if (lookupResult.addresses.length === 0) {
this.logger.warn({ postcode }, "Japan Post API returned no results for postal code");
throw new Error(`No address found for postal code ${postcode}`);
}
// Find the matching address entry
let matched = lookupResult.addresses[0]!;
if (townJa && lookupResult.addresses.length > 1) {
const townMatch = lookupResult.addresses.find(a => a.town === townJa);
if (townMatch) {
matched = townMatch;
} else {
this.logger.warn(
{ postcode, townJa, availableTowns: lookupResult.addresses.map(a => a.town) },
"Could not match townJa in Japan Post results, using first result"
);
}
}
// Build a BilingualAddress from resolved data
const bilingualAddress: BilingualAddress = {
postcode,
prefecture: matched.prefectureRoma,
city: matched.cityRoma,
town: matched.townRoma,
prefectureJa: matched.prefecture,
cityJa: matched.city,
townJa: matched.town,
streetAddress,
buildingName: buildingName ?? null,
roomNumber: roomNumber ?? null,
residenceType,
};
return prepareWhmcsAddressFields(bilingualAddress);
}
/**
* Convenience: resolve WHMCS address from a full BilingualAddress.
* Re-derives English fields from postal code via Japan Post API.
*/
async resolveWhmcsAddressFromBilingual(address: BilingualAddress): Promise<WhmcsAddressFields> {
return this.resolveWhmcsAddress({
postcode: address.postcode,
townJa: address.townJa,
streetAddress: address.streetAddress,
buildingName: address.buildingName,
roomNumber: address.roomNumber,
residenceType: address.residenceType,
});
}
}
```
**Step 2: Update `apps/bff/src/modules/address/address.module.ts`**
```typescript
/**
* Address Module
*
* NestJS module for address lookup and writing functionality.
*/
import { Module } from "@nestjs/common";
import { AddressController } from "./address.controller.js";
import { AddressWriterService } from "./address-writer.service.js";
import { JapanPostModule } from "@bff/integrations/japanpost/japanpost.module.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
@Module({
imports: [JapanPostModule, SalesforceModule],
controllers: [AddressController],
providers: [AddressWriterService],
exports: [AddressWriterService],
})
export class AddressModule {}
```
**Step 3: Verify the SalesforceModule exists and exports SalesforceFacade**
Run: `pnpm type-check` from the BFF
Expected: May still have downstream errors, but AddressWriterService itself should compile. Check that `SalesforceModule` is importable. If not, check the path — it may be `@bff/integrations/salesforce/salesforce.module.js`. Verify with:
```bash
# Check the actual module file path
ls apps/bff/src/integrations/salesforce/salesforce.module.ts
```
**Step 4: Commit**
```bash
git add apps/bff/src/modules/address/
git commit -m "feat: create AddressWriterService for centralized bilingual address handling"
```
---
### Task 3: Update NewCustomerSignupWorkflow (Outcome A) to use AddressWriterService
**Files:**
- Modify: `apps/bff/src/modules/auth/infra/workflows/new-customer-signup-workflow.service.ts`
- Modify: `apps/bff/src/modules/auth/auth.module.ts` (or wherever the auth module imports are — ensure AddressModule is imported)
**Context:** Currently this workflow (lines 137-189) destructures `address` from the request as `addressFormSchema` shape and manually composes address fields for WHMCS and eligibility case steps. Now `address` will be a `BilingualAddress`. We need to: (1) write Japanese to SF after account creation, (2) resolve English from postal code for WHMCS.
**Step 1: Add AddressWriterService dependency**
Add to constructor:
```typescript
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
// In constructor:
private readonly addressWriter: AddressWriterService,
```
**Step 2: Update `executeWithSteps()` method (lines 126-240)**
The `address` variable (line 137) is now `BilingualAddress`. Update the method:
After Step 1 (SF account creation, ~line 148), add SF address write:
```typescript
// Step 1.5: Write Japanese address to Salesforce (DEGRADABLE)
try {
await this.addressWriter.writeToSalesforce(sfResult.sfAccountId, address);
} catch (addressError) {
this.logger.warn(
{ error: extractErrorMessage(addressError), email },
"SF address write failed (non-critical, continuing)"
);
}
```
Update Step 2 (eligibility case, ~lines 151-169) — the case step likely accepts simple address. Keep passing the basic fields:
```typescript
const caseResult = await this.caseStep.execute({
sfAccountId: sfResult.sfAccountId,
address: {
address1: `${address.townJa}${address.streetAddress}`,
city: address.cityJa,
state: address.prefectureJa,
postcode: address.postcode,
country: "Japan",
},
});
```
Update Step 3 (WHMCS client, ~lines 172-189) — resolve English via postal code:
```typescript
const whmcsAddress = await this.addressWriter.resolveWhmcsAddressFromBilingual(address);
const whmcsResult = await this.whmcsStep.execute({
email,
password,
firstName,
lastName,
phone: phone ?? "",
address: {
address1: whmcsAddress.address1 || "",
...(whmcsAddress.address2 && { address2: whmcsAddress.address2 }),
city: whmcsAddress.city,
state: whmcsAddress.state,
postcode: whmcsAddress.postcode,
country: whmcsAddress.country,
},
customerNumber: sfResult.customerNumber ?? null,
dateOfBirth,
gender,
});
```
**Step 3: Ensure AddressModule is imported in the auth module**
Find the auth module file and add `AddressModule` to imports. Check:
```bash
# Find the auth module
grep -l "AuthModule" apps/bff/src/modules/auth/*.ts
```
Add `AddressModule` to the `imports` array.
**Step 4: Run type-check**
Run: `pnpm type-check`
Expected: Errors may remain in other files but this workflow should compile.
**Step 5: Commit**
```bash
git add apps/bff/src/modules/auth/
git commit -m "refactor: update new-customer-signup workflow to use AddressWriterService"
```
---
### Task 4: Update GuestEligibilityWorkflow (Outcome B - eligibility check) to use AddressWriterService
**Files:**
- Modify: `apps/bff/src/modules/auth/infra/workflows/guest-eligibility-workflow.service.ts`
**Context:** This workflow already writes Japanese to SF (lines 102-114) via inline code. The `address` is now `BilingualAddress` (from `guestEligibilityRequestSchema` which was updated in Task 1). We replace the inline SF write with `addressWriter.writeToSalesforce()` and store the full bilingual address in the handoff token.
**Step 1: Add AddressWriterService dependency**
```typescript
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
// In constructor:
private readonly addressWriter: AddressWriterService,
```
**Step 2: Replace inline SF address write (lines 102-114)**
Replace:
```typescript
// Save Japanese address to SF Contact (if Japanese address fields provided)
if (address.prefectureJa || address.cityJa || address.townJa) {
await this.salesforceAccountService.updateContactAddress(sfAccountId, {
mailingStreet: `${address.townJa || ""}${address.streetAddress || ""}`.trim(),
mailingCity: address.cityJa || address.city,
mailingState: address.prefectureJa || address.state,
mailingPostalCode: address.postcode,
mailingCountry: "Japan",
buildingName: address.buildingName ?? null,
roomNumber: address.roomNumber ?? null,
});
this.logger.debug({ sfAccountId }, "Updated SF Contact with Japanese address");
}
```
With:
```typescript
// Write Japanese address to Salesforce
try {
await this.addressWriter.writeToSalesforce(sfAccountId, address);
} catch (addressError) {
this.logger.warn(
{ error: extractErrorMessage(addressError), email: normalizedEmail },
"SF address write failed (non-critical, continuing)"
);
}
```
**Step 3: Update eligibility case address (lines 117-128)**
Update the case step address to use Japanese fields (since the case is about the Japanese address):
```typescript
const { caseId } = await this.eligibilityCaseStep.execute({
sfAccountId,
address: {
address1: `${address.townJa}${address.streetAddress}`,
city: address.cityJa,
state: address.prefectureJa,
postcode: address.postcode,
country: "Japan",
},
});
```
**Step 4: Update handoff token storage (lines 131-143)**
The `removeUndefined(address)` call stores the address. Since `address` is now `BilingualAddress`, this will store all bilingual fields. Verify `removeUndefined` doesn't strip needed fields — it only removes `undefined` values, so `null` values for `buildingName`/`roomNumber` will be preserved. This should work as-is.
**Step 5: Remove unused imports**
Remove the `SalesforceAccountService` import if it's no longer directly used (the `addressWriter` handles SF writes). However, this service is still used for `findByEmail()` and `createAccount()` on lines 74 and 88, so keep it.
**Step 6: Commit**
```bash
git add apps/bff/src/modules/auth/infra/workflows/guest-eligibility-workflow.service.ts
git commit -m "refactor: update guest-eligibility workflow to use AddressWriterService"
```
---
### Task 5: Update SfCompletionWorkflow (Outcome B - account completion) to use AddressWriterService
**Files:**
- Modify: `apps/bff/src/modules/auth/infra/workflows/sf-completion-workflow.service.ts`
**Context:** This workflow handles both NEW_CUSTOMER (complete-account) and SF_UNMAPPED paths. It resolves address from the request or session (line 122). Now the address in session/request is `BilingualAddress` (partial). The WHMCS step needs English fields resolved via postal code. For SF_UNMAPPED users returning days later, we may only have Japanese fields + postcode from the session.
**Step 1: Add AddressWriterService dependency**
```typescript
import { AddressWriterService } from "@bff/modules/address/address-writer.service.js";
// In constructor:
private readonly addressWriter: AddressWriterService,
```
**Step 2: Update `resolveAddress()` method (lines 217-230)**
The method currently requires `address1`, `city`, `state`, `postcode`. For bilingual addresses, we need `postcode` and at least `prefectureJa` or `prefecture`. Update:
```typescript
private resolveAddress(
requestAddress: CompleteAccountRequest["address"] | undefined,
sessionAddress: Record<string, string | undefined> | undefined
): NonNullable<CompleteAccountRequest["address"]> {
const address = requestAddress ?? sessionAddress;
if (!address || !address.postcode) {
throw new BadRequestException(
"Address information is incomplete. Please ensure postcode is provided."
);
}
return address as NonNullable<CompleteAccountRequest["address"]>;
}
```
**Step 3: Update `executeCompletion()` — WHMCS client creation (lines 136-153)**
Replace the inline address composition with `addressWriter.resolveWhmcsAddress()`:
```typescript
// Resolve English address from postal code for WHMCS
const whmcsAddress = await this.addressWriter.resolveWhmcsAddress({
postcode: address.postcode,
townJa: address.townJa,
streetAddress: address.streetAddress || "",
buildingName: address.buildingName,
roomNumber: address.roomNumber,
residenceType: address.residenceType || "house",
});
// Step 2: Create WHMCS client (CRITICAL, has rollback)
const whmcsResult = await this.whmcsStep.execute({
firstName: finalFirstName,
lastName: finalLastName,
email: session.email,
password,
phone: phone ?? "",
address: {
address1: whmcsAddress.address1 || "",
...(whmcsAddress.address2 && { address2: whmcsAddress.address2 }),
city: whmcsAddress.city,
state: whmcsAddress.state,
postcode: whmcsAddress.postcode,
country: whmcsAddress.country,
},
customerNumber: sfResult.customerNumber ?? null,
dateOfBirth,
gender,
});
```
**Step 4: Add SF address write for NEW_CUSTOMER path**
After SF account creation (line 126-133), write Japanese address to SF if this is a new customer:
```typescript
// Write Japanese address to Salesforce for new customers
if (isNewCustomer && address.prefectureJa) {
try {
await this.addressWriter.writeToSalesforce(sfResult.sfAccountId, address as BilingualAddress);
} catch (addressError) {
this.logger.warn(
{ error: extractErrorMessage(addressError), email: session.email },
"SF address write failed (non-critical, continuing)"
);
}
}
```
**Step 5: Commit**
```bash
git add apps/bff/src/modules/auth/infra/workflows/sf-completion-workflow.service.ts
git commit -m "refactor: update sf-completion workflow to use AddressWriterService"
```
---
### Task 6: Update Portal Frontend — Stop converting to WHMCS format, send bilingual data
**Files:**
- Modify: `apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/useCompleteAccountForm.ts`
- Modify: `apps/portal/src/features/get-started/machines/get-started.types.ts`
- Modify: `apps/portal/src/features/get-started/machines/get-started.actors.ts`
- Modify: `apps/portal/src/features/services/stores/eligibility-check.store.ts`
**Context:** The frontend already collects `BilingualAddress` via `JapanAddressForm`. But `useCompleteAccountForm.ts` (lines 63-79) converts it to WHMCS format before storing, and the XState machine types (`GetStartedAddress`) use simple address fields. We need to pass bilingual data through to the API.
**Step 1: Update `GetStartedAddress` type in `get-started.types.ts` (lines 24-32)**
Replace:
```typescript
export interface GetStartedAddress {
address1?: string | undefined;
address2?: string | undefined;
city?: string | undefined;
state?: string | undefined;
postcode?: string | undefined;
country?: string | undefined;
countryCode?: string | undefined;
}
```
With:
```typescript
import type { BilingualAddress } from "@customer-portal/domain/address";
export type GetStartedAddress = Partial<BilingualAddress>;
```
**Step 2: Update `handleAddressChange` in `useCompleteAccountForm.ts` (lines 63-79)**
Replace:
```typescript
const handleAddressChange = useCallback(
(data: JapanAddressFormData, isComplete: boolean) => {
setIsAddressComplete(isComplete);
const whmcsFields = prepareWhmcsAddressFields(data);
updateFormData({
address: {
address1: whmcsFields.address1 || "",
address2: whmcsFields.address2 || "",
city: whmcsFields.city || "",
state: whmcsFields.state || "",
postcode: whmcsFields.postcode || "",
country: "JP",
},
});
},
[updateFormData]
);
```
With:
```typescript
const handleAddressChange = useCallback(
(data: JapanAddressFormData, isComplete: boolean) => {
setIsAddressComplete(isComplete);
updateFormData({ address: data });
},
[updateFormData]
);
```
Remove the unused import of `prepareWhmcsAddressFields` from line 3.
**Step 3: Update `completeAccountActor` in `get-started.actors.ts` (lines 42-71)**
The actor currently destructures `formData.address` into simple fields. Update to pass bilingual address:
```typescript
export const completeAccountActor = fromPromise<CompleteAccountOutput, CompleteAccountInput>(
async ({ input }) => {
const { sessionToken, formData, accountStatus } = input;
const isNewCustomer = accountStatus === "new_customer";
return getStartedApi.completeAccount({
sessionToken,
password: formData.password,
phone: formData.phone,
dateOfBirth: formData.dateOfBirth,
gender: formData.gender as "male" | "female" | "other",
acceptTerms: formData.acceptTerms,
marketingConsent: formData.marketingConsent,
...(isNewCustomer || formData.firstName ? { firstName: formData.firstName } : {}),
...(isNewCustomer || formData.lastName ? { lastName: formData.lastName } : {}),
...(formData.address?.postcode ? { address: formData.address as BilingualAddress } : {}),
});
}
);
```
Add the import:
```typescript
import type { BilingualAddress } from "@customer-portal/domain/address";
```
**Step 4: Update eligibility check store — `buildEligibilityAddress` and `completeAccountAction`**
In `apps/portal/src/features/services/stores/eligibility-check.store.ts`:
The `buildEligibilityAddress` function (lines 172-192) currently converts to WHMCS format. Since the `guestEligibilityRequestSchema` now expects `bilingualAddressSchema`, we pass the `JapanAddressFormData` directly:
Replace `buildEligibilityAddress` and its type with:
```typescript
// Remove the entire buildEligibilityAddress function and EligibilityCheckAddress interface (lines 157-192)
```
Update `submitOnlyAction` (line 216):
```typescript
// OLD:
address: buildEligibilityAddress(formData.address),
// NEW:
address: formData.address,
```
Update `completeAccountAction` (lines 365-379):
Replace:
```typescript
const whmcsAddress = prepareWhmcsAddressFields(formData.address);
const result = await signupWithEligibility({
sessionToken,
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(),
address: {
address1: whmcsAddress.address1 || "",
...(whmcsAddress.address2 && { address2: whmcsAddress.address2 }),
city: whmcsAddress.city || "",
state: whmcsAddress.state || "",
postcode: whmcsAddress.postcode || "",
country: "JP",
},
...
});
```
With:
```typescript
const result = await signupWithEligibility({
sessionToken,
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(),
address: formData.address,
phone: accountData.phone.trim(),
password: accountData.password,
dateOfBirth: accountData.dateOfBirth,
gender: accountData.gender as "male" | "female" | "other",
acceptTerms: accountData.acceptTerms,
marketingConsent: accountData.marketingConsent,
});
```
Remove unused imports: `prepareWhmcsAddressFields` from line 20.
**Step 5: Run type-check**
Run: `pnpm type-check`
Expected: Should pass (or show only pre-existing issues).
**Step 6: Commit**
```bash
git add apps/portal/src/features/get-started/ apps/portal/src/features/services/stores/
git commit -m "refactor: send bilingual address directly from portal instead of converting to WHMCS format"
```
---
### Task 7: Update BFF Verification Workflow — Prefill bilingual address from Salesforce
**Files:**
- Modify: `apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts`
**Context:** When a returning user (SF_UNMAPPED, B2 scenario) verifies their email, the BFF prefills address data from Salesforce. Currently it prefills simple address fields. We need to prefill bilingual fields — at minimum `postcode`, `prefectureJa`, `cityJa`, `townJa`, `streetAddress` from the SF contact's Mailing\* fields.
**Step 1: Find the prefill logic**
Search for `prefill` in the verification workflow. The `determineAccountStatus()` method builds the prefill object that gets returned to the frontend.
**Step 2: Update the SF prefill to include bilingual fields**
When building the prefill for SF_UNMAPPED users, map Salesforce fields to bilingual schema:
```typescript
prefill: {
firstName: sfContact?.FirstName,
lastName: sfContact?.LastName,
email: session.email,
address: {
// Japanese fields from SF Contact
prefectureJa: sfContact?.MailingState,
cityJa: sfContact?.MailingCity,
townJa: extractTownFromMailingStreet(sfContact?.MailingStreet),
postcode: sfContact?.MailingPostalCode,
// streetAddress may be embedded in MailingStreet after town name
streetAddress: extractStreetFromMailingStreet(sfContact?.MailingStreet),
},
eligibilityStatus: sfAccount?.eligibilityStatus,
}
```
Note: `MailingStreet` in SF contains `townJa + streetAddress` (e.g., "東麻布1-5-3"). We need to parse this. The town and street address were concatenated without delimiter in `prepareSalesforceContactAddressFields()`. This parsing may be imperfect — consider storing them separately in the session. For now, store `MailingStreet` as-is and let the BFF resolve at WHMCS write time using only the postal code (which is enough for the Japan Post API lookup).
Actually, the simpler approach: just pass the postcode and whatever SF fields we have. The `AddressWriterService.resolveWhmcsAddress()` only needs `postcode` to derive English fields. The frontend won't show the address form for B2 users (address is stored). So the prefill doesn't need to be perfectly parsed — it just needs the postal code for the WHMCS resolution.
**Step 3: Commit**
```bash
git add apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts
git commit -m "refactor: prefill bilingual address fields from Salesforce for SF_UNMAPPED users"
```
---
### Task 8: Ensure AuthModule imports AddressModule
**Files:**
- Modify: `apps/bff/src/modules/auth/auth.module.ts` (or the module that registers the workflow services)
**Context:** The workflow services now depend on `AddressWriterService`, which is provided by `AddressModule`. The auth module needs to import it.
**Step 1: Find the auth module**
```bash
# Find the file
ls apps/bff/src/modules/auth/auth.module.ts
```
**Step 2: Add AddressModule import**
```typescript
import { AddressModule } from "@bff/modules/address/address.module.js";
@Module({
imports: [
// ... existing imports
AddressModule,
],
// ...
})
export class AuthModule {}
```
**Step 3: Run type-check and build**
Run: `pnpm domain:build && pnpm type-check`
Expected: All type errors resolved.
**Step 4: Run lint**
Run: `pnpm lint`
Expected: Passes (may have unused import warnings from removed code — fix those).
**Step 5: Commit**
```bash
git add apps/bff/src/modules/auth/auth.module.ts
git commit -m "chore: import AddressModule in AuthModule for AddressWriterService access"
```
---
### Task 9: End-to-end verification and cleanup
**Files:** Various (cleanup pass)
**Step 1: Search for remaining references to old patterns**
Search for:
- `addressFormSchema` usage in get-started contexts (should be replaced)
- `bilingualEligibilityAddressSchema` references (should be removed)
- `prepareWhmcsAddressFields` in portal files (should be removed from signup flows — still needed in profile/settings)
- `buildEligibilityAddress` (should be removed)
```bash
# Check for remaining old references
pnpm exec grep -r "addressFormSchema" packages/domain/get-started/
pnpm exec grep -r "bilingualEligibilityAddressSchema" packages/domain/ apps/
pnpm exec grep -r "buildEligibilityAddress" apps/portal/
```
**Step 2: Full build check**
Run: `pnpm domain:build && pnpm type-check && pnpm lint`
Expected: All pass.
**Step 3: Verify nothing is broken in the domain package exports**
Check that `packages/domain/get-started/index.ts` and `packages/domain/address/index.ts` export everything needed.
**Step 4: Final commit**
```bash
git add -A
git commit -m "chore: cleanup remaining references to old address schemas"
```

View File

@ -16,7 +16,6 @@ import type {
sendVerificationCodeResponseSchema,
verifyCodeRequestSchema,
verifyCodeResponseSchema,
bilingualEligibilityAddressSchema,
guestEligibilityRequestSchema,
guestEligibilityResponseSchema,
guestHandoffTokenSchema,
@ -87,7 +86,6 @@ export type GetStartedErrorCode =
export type SendVerificationCodeRequest = z.infer<typeof sendVerificationCodeRequestSchema>;
export type VerifyCodeRequest = z.infer<typeof verifyCodeRequestSchema>;
export type BilingualEligibilityAddress = z.infer<typeof bilingualEligibilityAddressSchema>;
export type GuestEligibilityRequest = z.infer<typeof guestEligibilityRequestSchema>;
export type CompleteAccountRequest = z.infer<typeof completeAccountRequestSchema>;
export type SignupWithEligibilityRequest = z.infer<typeof signupWithEligibilityRequestSchema>;

View File

@ -24,7 +24,6 @@ export {
type SendVerificationCodeResponse,
type VerifyCodeRequest,
type VerifyCodeResponse,
type BilingualEligibilityAddress,
type GuestEligibilityRequest,
type GuestEligibilityResponse,
type GuestHandoffToken,
@ -49,7 +48,6 @@ export {
verifyCodeResponseSchema,
accountStatusSchema,
// Guest eligibility schemas (no OTP required)
bilingualEligibilityAddressSchema,
guestEligibilityRequestSchema,
guestEligibilityResponseSchema,
guestHandoffTokenSchema,

View File

@ -13,7 +13,7 @@ import {
phoneSchema,
genderEnum,
} from "../common/schema.js";
import { addressFormSchema } from "../customer/schema.js";
import { bilingualAddressSchema } from "../address/schema.js";
// ============================================================================
// Validation Message Constants
@ -95,7 +95,7 @@ export const verifyCodeResponseSchema = z.object({
lastName: z.string().optional(),
email: z.string().optional(),
phone: z.string().optional(),
address: addressFormSchema.partial().optional(),
address: bilingualAddressSchema.partial().optional(),
eligibilityStatus: z.string().optional(),
})
.optional(),
@ -117,28 +117,6 @@ const isoDateOnlySchema = z
// Guest Eligibility Check Schemas (No OTP Required)
// ============================================================================
/**
* Bilingual address schema for eligibility requests
* Contains both English (for WHMCS) and Japanese (for Salesforce) address fields
*/
export const bilingualEligibilityAddressSchema = z.object({
// English/Romanized fields (for WHMCS)
address1: z.string().min(1, "Address is required").max(200).trim(),
address2: z.string().max(200).trim().optional(),
city: z.string().min(1, "City is required").max(100).trim(),
state: z.string().min(1, "Prefecture is required").max(100).trim(),
postcode: z.string().min(1, "Postcode is required").max(20).trim(),
country: z.string().max(100).trim().optional(),
// Japanese fields (for Salesforce)
prefectureJa: z.string().max(100).trim().optional(),
cityJa: z.string().max(100).trim().optional(),
townJa: z.string().max(200).trim().optional(),
streetAddress: z.string().max(50).trim().optional(),
buildingName: z.string().max(200).trim().nullable().optional(),
roomNumber: z.string().max(50).trim().nullable().optional(),
});
/**
* Request for guest eligibility check - NO email verification required
* Allows users to check availability without verifying email first
@ -150,8 +128,8 @@ export const guestEligibilityRequestSchema = z.object({
firstName: nameSchema,
/** Customer last name */
lastName: nameSchema,
/** Full address with both English and Japanese fields for eligibility check */
address: bilingualEligibilityAddressSchema,
/** Full bilingual address for eligibility check */
address: bilingualAddressSchema,
/** Optional phone number */
phone: phoneSchema.optional(),
/** Whether user wants to continue to account creation */
@ -192,7 +170,7 @@ export const guestHandoffTokenSchema = z.object({
/** Last name */
lastName: z.string(),
/** Address from eligibility check */
address: addressFormSchema.partial().optional(),
address: bilingualAddressSchema.partial().optional(),
/** Phone number if provided */
phone: z.string().optional(),
/** SF Account ID created during eligibility check */
@ -220,7 +198,7 @@ export const completeAccountRequestSchema = z.object({
/** Customer last name (required for new customers, optional for SF-only) */
lastName: nameSchema.optional(),
/** Address (required for new customers, optional for SF-only who have it in session) */
address: addressFormSchema.optional(),
address: bilingualAddressSchema.optional(),
/** Password for the new portal account */
password: passwordSchema,
/** Phone number (may be pre-filled from SF) */
@ -253,8 +231,8 @@ export const signupWithEligibilityRequestSchema = z.object({
firstName: nameSchema,
/** Customer last name */
lastName: nameSchema,
/** Full address for eligibility check and WHMCS */
address: addressFormSchema,
/** Full bilingual address for eligibility check and WHMCS */
address: bilingualAddressSchema,
/** Phone number */
phone: phoneSchema,
/** Password for the new portal account */
@ -327,7 +305,7 @@ export const getStartedSessionSchema = z.object({
/** Last name (if provided during quick check) */
lastName: z.string().optional(),
/** Address (if provided during quick check) */
address: addressFormSchema.partial().optional(),
address: bilingualAddressSchema.partial().optional(),
/** Phone number (if provided) */
phone: z.string().optional(),
/** Account status after verification */