Enhance WHMCS currency handling by adding fallback logic and improving response parsing. Updated WhmcsCurrencyService to check WHMCS availability before loading currencies, and refactored currency response handling to support both nested and flat formats. Additionally, added address management endpoints in UsersController and UsersService for better customer address handling. Updated TypeScript and package dependencies for improved compatibility.
This commit is contained in:
parent
0a2cafed76
commit
7420b77202
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
||||
CSRF_SECRET_KEY=your-secure-csrf-secret-key-minimum-32-characters-long-for-development
|
||||
@ -16,23 +16,47 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
// Check if WHMCS is available before trying to load currencies
|
||||
this.logger.debug("Checking WHMCS availability before loading currencies");
|
||||
const isAvailable = await this.connectionService.isAvailable();
|
||||
|
||||
if (!isAvailable) {
|
||||
this.logger.warn("WHMCS service is not available, using fallback currency configuration");
|
||||
this.setFallbackCurrency();
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug("WHMCS is available, attempting to load currencies");
|
||||
await this.loadCurrencies();
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to load WHMCS currencies on startup", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
// Set fallback default
|
||||
this.defaultCurrency = {
|
||||
id: 1,
|
||||
code: "JPY",
|
||||
prefix: "¥",
|
||||
suffix: "",
|
||||
format: "1",
|
||||
rate: "1.00000",
|
||||
};
|
||||
this.setFallbackCurrency();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fallback currency configuration when WHMCS is not available
|
||||
*/
|
||||
private setFallbackCurrency(): void {
|
||||
this.defaultCurrency = {
|
||||
id: 1,
|
||||
code: "JPY",
|
||||
prefix: "¥",
|
||||
suffix: "",
|
||||
format: "1",
|
||||
rate: "1.00000",
|
||||
};
|
||||
|
||||
this.currencies = [this.defaultCurrency];
|
||||
|
||||
this.logger.log("Using fallback currency configuration", {
|
||||
defaultCurrency: this.defaultCurrency.code,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default currency (first currency from WHMCS or JPY fallback)
|
||||
*/
|
||||
@ -68,19 +92,35 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
||||
*/
|
||||
private async loadCurrencies(): Promise<void> {
|
||||
try {
|
||||
const response = (await this.connectionService.getCurrencies()) as WhmcsCurrenciesResponse;
|
||||
// The connection service returns the raw WHMCS API response data
|
||||
// (the WhmcsResponse wrapper is unwrapped by the API methods service)
|
||||
const response = await this.connectionService.getCurrencies() as WhmcsCurrenciesResponse;
|
||||
|
||||
if (response.result === "success" && response.currencies?.currency) {
|
||||
this.currencies = response.currencies.currency;
|
||||
// Set first currency as default (WHMCS typically returns the primary currency first)
|
||||
this.defaultCurrency = this.currencies[0] || null;
|
||||
// Check if response has currencies data (success case) or error fields
|
||||
if (response.result === "success" || (response.currencies && !response.error)) {
|
||||
// Parse the WHMCS response format into currency objects
|
||||
this.currencies = this.parseWhmcsCurrenciesResponse(response);
|
||||
|
||||
if (this.currencies.length > 0) {
|
||||
// Set first currency as default (WHMCS typically returns the primary currency first)
|
||||
this.defaultCurrency = this.currencies[0];
|
||||
|
||||
this.logger.log(`Loaded ${this.currencies.length} currencies from WHMCS`, {
|
||||
defaultCurrency: this.defaultCurrency?.code,
|
||||
allCurrencies: this.currencies.map(c => c.code),
|
||||
});
|
||||
this.logger.log(`Loaded ${this.currencies.length} currencies from WHMCS`, {
|
||||
defaultCurrency: this.defaultCurrency?.code,
|
||||
allCurrencies: this.currencies.map(c => c.code),
|
||||
});
|
||||
} else {
|
||||
throw new Error("No currencies found in WHMCS response");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Invalid response from WHMCS GetCurrencies");
|
||||
this.logger.error("WHMCS GetCurrencies returned error", {
|
||||
result: response?.result,
|
||||
message: response?.message,
|
||||
error: response?.error,
|
||||
errorcode: response?.errorcode,
|
||||
fullResponse: JSON.stringify(response, null, 2),
|
||||
});
|
||||
throw new Error(`WHMCS GetCurrencies error: ${response?.message || response?.error || "Unknown error"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to load currencies from WHMCS", {
|
||||
@ -90,6 +130,67 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse WHMCS response format into currency objects
|
||||
* Handles both flat format (currencies[currency][0][id]) and nested format (currencies.currency[])
|
||||
*/
|
||||
private parseWhmcsCurrenciesResponse(response: WhmcsCurrenciesResponse): WhmcsCurrency[] {
|
||||
const currencies: WhmcsCurrency[] = [];
|
||||
|
||||
// Check if response has nested currency structure
|
||||
if (response.currencies && typeof response.currencies === 'object' && 'currency' in response.currencies) {
|
||||
const currencyArray = Array.isArray(response.currencies.currency)
|
||||
? response.currencies.currency
|
||||
: [response.currencies.currency];
|
||||
|
||||
for (const currencyData of currencyArray) {
|
||||
const currency: WhmcsCurrency = {
|
||||
id: parseInt(String(currencyData.id)) || 0,
|
||||
code: String(currencyData.code || ''),
|
||||
prefix: String(currencyData.prefix || ''),
|
||||
suffix: String(currencyData.suffix || ''),
|
||||
format: String(currencyData.format || '1'),
|
||||
rate: String(currencyData.rate || '1.00000'),
|
||||
};
|
||||
|
||||
// Validate that we have essential currency data
|
||||
if (currency.id && currency.code) {
|
||||
currencies.push(currency);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: try to parse flat format (currencies[currency][0][id], etc.)
|
||||
const currencyKeys = Object.keys(response).filter(key =>
|
||||
key.startsWith('currencies[currency][') && key.includes('][id]')
|
||||
);
|
||||
|
||||
// Extract currency indices
|
||||
const currencyIndices = currencyKeys.map(key => {
|
||||
const match = key.match(/currencies\[currency\]\[(\d+)\]\[id\]/);
|
||||
return match ? parseInt(match[1]) : null;
|
||||
}).filter(index => index !== null) as number[];
|
||||
|
||||
// Build currency objects from the flat response
|
||||
for (const index of currencyIndices) {
|
||||
const currency: WhmcsCurrency = {
|
||||
id: parseInt(String(response[`currencies[currency][${index}][id]`])) || 0,
|
||||
code: String(response[`currencies[currency][${index}][code]`] || ''),
|
||||
prefix: String(response[`currencies[currency][${index}][prefix]`] || ''),
|
||||
suffix: String(response[`currencies[currency][${index}][suffix]`] || ''),
|
||||
format: String(response[`currencies[currency][${index}][format]`] || '1'),
|
||||
rate: String(response[`currencies[currency][${index}][rate]`] || '1.00000'),
|
||||
};
|
||||
|
||||
// Validate that we have essential currency data
|
||||
if (currency.id && currency.code) {
|
||||
currencies.push(currency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return currencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh currencies from WHMCS (can be called manually if needed)
|
||||
*/
|
||||
|
||||
@ -43,6 +43,7 @@ import { WhmcsApiMethodsService } from "./connection/services/whmcs-api-methods.
|
||||
WhmcsCacheService,
|
||||
WhmcsOrderService,
|
||||
WhmcsPaymentService,
|
||||
WhmcsCurrencyService,
|
||||
],
|
||||
})
|
||||
export class WhmcsModule {}
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
updateCustomerProfileRequestSchema,
|
||||
type UpdateCustomerProfileRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import { addressSchema, type Address } from "@customer-portal/domain/customer";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
|
||||
@Controller("me")
|
||||
@ -38,6 +39,26 @@ export class UsersController {
|
||||
return this.usersService.getUserSummary(req.user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /me/address - Get customer address only
|
||||
*/
|
||||
@Get("address")
|
||||
async getAddress(@Req() req: RequestWithUser): Promise<Address | null> {
|
||||
return this.usersService.getAddress(req.user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /me/address - Update address fields
|
||||
*/
|
||||
@Patch("address")
|
||||
@UsePipes(new ZodValidationPipe(addressSchema.partial()))
|
||||
async updateAddress(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() address: Partial<Address>
|
||||
): Promise<Address> {
|
||||
return this.usersService.updateAddress(req.user.id, address);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /me - Update customer profile (can update profile fields and/or address)
|
||||
* All fields optional - only send what needs to be updated
|
||||
|
||||
@ -8,7 +8,13 @@ import {
|
||||
updateCustomerProfileRequestSchema,
|
||||
type UpdateCustomerProfileRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import { combineToUser, Providers as CustomerProviders, type User } from "@customer-portal/domain/customer";
|
||||
import {
|
||||
combineToUser,
|
||||
Providers as CustomerProviders,
|
||||
addressSchema,
|
||||
type Address,
|
||||
type User,
|
||||
} from "@customer-portal/domain/customer";
|
||||
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||
import type { Invoice } from "@customer-portal/domain/billing";
|
||||
import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard";
|
||||
@ -143,6 +149,69 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only the customer's address information
|
||||
*/
|
||||
async getAddress(userId: string): Promise<Address | null> {
|
||||
const validId = validateUuidV4OrThrow(userId);
|
||||
const profile = await this.getProfile(validId);
|
||||
return profile.address ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update customer address in WHMCS
|
||||
*/
|
||||
async updateAddress(userId: string, addressUpdate: Partial<Address>): Promise<Address> {
|
||||
const validId = validateUuidV4OrThrow(userId);
|
||||
const parsed = addressSchema.partial().parse(addressUpdate ?? {});
|
||||
const hasUpdates = Object.values(parsed).some(value => value !== undefined);
|
||||
|
||||
if (!hasUpdates) {
|
||||
throw new BadRequestException("No address fields provided for update");
|
||||
}
|
||||
|
||||
const mapping = await this.mappingsService.findByUserId(validId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new NotFoundException("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
try {
|
||||
await this.whmcsService.updateClientAddress(mapping.whmcsClientId, parsed);
|
||||
await this.whmcsService.invalidateUserCache(validId);
|
||||
|
||||
this.logger.log("Successfully updated customer address in WHMCS", {
|
||||
userId: validId,
|
||||
whmcsClientId: mapping.whmcsClientId,
|
||||
});
|
||||
|
||||
const refreshedProfile = await this.getProfile(validId);
|
||||
if (refreshedProfile.address) {
|
||||
return refreshedProfile.address;
|
||||
}
|
||||
|
||||
const refreshedAddress = await this.whmcsService.getClientAddress(mapping.whmcsClientId);
|
||||
return addressSchema.parse(refreshedAddress ?? {});
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
this.logger.error(
|
||||
{ userId: validId, whmcsClientId: mapping.whmcsClientId, error: msg },
|
||||
"Failed to update customer address in WHMCS"
|
||||
);
|
||||
|
||||
if (msg.includes("WHMCS API Error")) {
|
||||
throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
|
||||
}
|
||||
if (msg.includes("HTTP ")) {
|
||||
throw new BadRequestException("Upstream WHMCS error. Please try again.");
|
||||
}
|
||||
if (msg.includes("Missing required WHMCS configuration")) {
|
||||
throw new BadRequestException("Billing system not configured. Please contact support.");
|
||||
}
|
||||
|
||||
throw new BadRequestException("Unable to update address.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user (auth state only in portal DB)
|
||||
*/
|
||||
|
||||
@ -248,14 +248,20 @@ export type WhmcsCurrency = z.infer<typeof whmcsCurrencySchema>;
|
||||
|
||||
/**
|
||||
* WHMCS GetCurrencies API response schema
|
||||
*
|
||||
* WHMCS can return currencies in different formats:
|
||||
* 1. Nested format: { currencies: { currency: [...] } }
|
||||
* 2. Flat format: currencies[currency][0][id], currencies[currency][0][code], etc.
|
||||
* 3. Missing result field in some cases
|
||||
*/
|
||||
export const whmcsCurrenciesResponseSchema = z.object({
|
||||
result: z.enum(["success", "error"]),
|
||||
totalresults: z.number(),
|
||||
result: z.enum(["success", "error"]).optional(),
|
||||
totalresults: z.string().transform(val => parseInt(val, 10)).or(z.number()).optional(),
|
||||
currencies: z.object({
|
||||
currency: z.array(whmcsCurrencySchema),
|
||||
}),
|
||||
});
|
||||
currency: z.array(whmcsCurrencySchema).or(whmcsCurrencySchema),
|
||||
}).optional(),
|
||||
// Allow any additional flat currency keys for flat format
|
||||
}).catchall(z.string().or(z.number()));
|
||||
|
||||
export type WhmcsCurrenciesResponse = z.infer<typeof whmcsCurrenciesResponseSchema>;
|
||||
|
||||
|
||||
@ -36,13 +36,14 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"type-check": "NODE_OPTIONS=\"--max-old-space-size=2048 --max-semi-space-size=128\" tsc --project tsconfig.json --noEmit",
|
||||
"typecheck": "pnpm run type-check"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^4.1.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -341,6 +341,9 @@ importers:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.3.0
|
||||
version: 24.3.1
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.2
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./packages/contracts" },
|
||||
{ "path": "./packages/schemas" },
|
||||
{ "path": "./packages/integrations/whmcs" },
|
||||
{ "path": "./packages/integrations/freebit" },
|
||||
{ "path": "./packages/domain" },
|
||||
{ "path": "./packages/logging" },
|
||||
{ "path": "./packages/validation" }
|
||||
{ "path": "./packages/validation" },
|
||||
{ "path": "./packages/integrations/whmcs" },
|
||||
{ "path": "./packages/integrations/freebit" },
|
||||
{ "path": "./apps/bff" },
|
||||
{ "path": "./apps/portal" }
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user