2026-03-03 16:33:40 +09:00
|
|
|
/**
|
|
|
|
|
* 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 {
|
2026-03-03 17:27:50 +09:00
|
|
|
/** In-flight Japan Post lookups keyed by postcode — deduplicates concurrent calls */
|
|
|
|
|
private readonly inflightLookups = new Map<
|
|
|
|
|
string,
|
|
|
|
|
Promise<Awaited<ReturnType<JapanPostFacade["lookupByZipCode"]>>>
|
|
|
|
|
>();
|
|
|
|
|
|
2026-03-03 16:33:40 +09:00
|
|
|
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.
|
|
|
|
|
*
|
2026-03-03 17:27:50 +09:00
|
|
|
* Uses in-flight deduplication so concurrent lookups for the same
|
|
|
|
|
* postcode share a single API call.
|
2026-03-03 16:33:40 +09:00
|
|
|
*/
|
|
|
|
|
async resolveWhmcsAddress(params: ResolveWhmcsAddressParams): Promise<WhmcsAddressFields> {
|
|
|
|
|
const { postcode, townJa, streetAddress, buildingName, roomNumber, residenceType } = params;
|
|
|
|
|
|
2026-03-03 17:27:50 +09:00
|
|
|
const lookupResult = await this.lookupPostalCode(postcode);
|
2026-03-03 16:33:40 +09:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-03 17:27:50 +09:00
|
|
|
* Deduplicated Japan Post lookup — concurrent calls for the same postcode
|
|
|
|
|
* share one API request.
|
|
|
|
|
*/
|
|
|
|
|
private async lookupPostalCode(
|
|
|
|
|
postcode: string
|
|
|
|
|
): Promise<Awaited<ReturnType<JapanPostFacade["lookupByZipCode"]>>> {
|
|
|
|
|
const inflight = this.inflightLookups.get(postcode);
|
|
|
|
|
if (inflight) {
|
|
|
|
|
return inflight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const promise = this.japanPost.lookupByZipCode(postcode);
|
|
|
|
|
this.inflightLookups.set(postcode, promise);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return await promise;
|
|
|
|
|
} finally {
|
|
|
|
|
this.inflightLookups.delete(postcode);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Prepare WHMCS address fields from a full BilingualAddress.
|
|
|
|
|
* Uses the already-populated English fields — no API call needed.
|
|
|
|
|
*/
|
|
|
|
|
resolveWhmcsAddressFromBilingual(address: BilingualAddress): WhmcsAddressFields {
|
|
|
|
|
return prepareWhmcsAddressFields(address);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert WhmcsAddressFields to the shape expected by CreateWhmcsClientStep.
|
|
|
|
|
*/
|
|
|
|
|
toWhmcsStepAddress(fields: WhmcsAddressFields): {
|
|
|
|
|
address1: string;
|
|
|
|
|
address2?: string;
|
|
|
|
|
city: string;
|
|
|
|
|
state: string;
|
|
|
|
|
postcode: string;
|
|
|
|
|
country: string;
|
|
|
|
|
} {
|
|
|
|
|
return {
|
|
|
|
|
address1: fields.address1 || "",
|
|
|
|
|
...(fields.address2 && { address2: fields.address2 }),
|
|
|
|
|
city: fields.city,
|
|
|
|
|
state: fields.state,
|
|
|
|
|
postcode: fields.postcode,
|
|
|
|
|
country: fields.country,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build case address from bilingual address.
|
|
|
|
|
* Maps Japanese fields to the eligibility case step's expected format.
|
2026-03-03 16:33:40 +09:00
|
|
|
*/
|
2026-03-03 17:27:50 +09:00
|
|
|
toCaseAddress(address: BilingualAddress): {
|
|
|
|
|
address1: string;
|
|
|
|
|
city: string;
|
|
|
|
|
state: string;
|
|
|
|
|
postcode: string;
|
|
|
|
|
country: string;
|
|
|
|
|
} {
|
|
|
|
|
return {
|
|
|
|
|
address1: `${address.townJa}${address.streetAddress}`,
|
|
|
|
|
city: address.cityJa,
|
|
|
|
|
state: address.prefectureJa,
|
2026-03-03 16:33:40 +09:00
|
|
|
postcode: address.postcode,
|
2026-03-03 17:27:50 +09:00
|
|
|
country: "Japan",
|
|
|
|
|
};
|
2026-03-03 16:33:40 +09:00
|
|
|
}
|
|
|
|
|
}
|