feat(listings): add ROOM_RENTAL, CONDOTEL, SERVICED_APARTMENT property types (GOO-20)

- Add ROOM_RENTAL, CONDOTEL, SERVICED_APARTMENT to PropertyType enum in schema.prisma
- Create migration 20260422010000_add_room_rental_property_types with ALTER TYPE ADD VALUE
- Add DEFAULT_RANGES in PrismaPriceValidator: ROOM_RENTAL 1M-10M VND/month, CONDOTEL 20M-300M, SERVICED_APARTMENT 20M-250M VND/m²
- Add i18n translations: vi "Phòng trọ / Condotel / Căn hộ dịch vụ", en "Room Rental / Condotel / Serviced Apartment"
- Typesense indexes propertyType as a generic string facet — no schema change needed

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-22 23:26:01 +07:00
parent ee6d6d4c17
commit c478abae38
8 changed files with 151 additions and 6 deletions

View File

@@ -8,6 +8,7 @@ describe('MomoService', () => {
const secretKey = 'TESTSECRETKEY123456789012345678';
const partnerCode = 'TESTPARTNER';
const accessKey = 'TESTACCESSKEY';
const callbackBaseUrl = 'https://api.goodgo.vn';
beforeEach(() => {
const mockConfig = {
@@ -16,6 +17,7 @@ describe('MomoService', () => {
'MOMO_PARTNER_CODE': 'TESTPARTNER',
'MOMO_ACCESS_KEY': 'TESTACCESSKEY',
'MOMO_SECRET_KEY': 'TESTSECRETKEY123456789012345678',
'PAYMENT_CALLBACK_BASE_URL': callbackBaseUrl,
};
return env[key] ?? defaultValue;
}),
@@ -117,4 +119,50 @@ describe('MomoService', () => {
expect(result.isValid).toBe(true);
expect(result.isSuccess).toBe(false);
});
it('createPaymentUrl should use backend IPN URL for ipnUrl and frontend URL for redirectUrl', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({ resultCode: 0, payUrl: 'https://pay.momo.vn/abc' }),
});
vi.stubGlobal('fetch', fetchMock);
const returnUrl = 'https://goodgo.vn/payment/return';
await service.createPaymentUrl({
orderId: 'order-xyz',
amountVND: 100000n,
description: 'Test',
returnUrl,
ipAddress: '127.0.0.1',
});
expect(fetchMock).toHaveBeenCalledOnce();
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(body.redirectUrl).toBe(returnUrl);
expect(body.ipnUrl).toBe(`${callbackBaseUrl}/api/v1/payments/callback/momo`);
expect(body.ipnUrl).not.toBe(body.redirectUrl);
vi.unstubAllGlobals();
});
it('createPaymentUrl should use provided callbackUrl as ipnUrl when supplied', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({ resultCode: 0, payUrl: 'https://pay.momo.vn/abc' }),
});
vi.stubGlobal('fetch', fetchMock);
const customCallback = 'https://staging-api.goodgo.vn/api/v1/payments/callback/momo';
await service.createPaymentUrl({
orderId: 'order-xyz',
amountVND: 100000n,
description: 'Test',
returnUrl: 'https://goodgo.vn/payment/return',
callbackUrl: customCallback,
ipAddress: '127.0.0.1',
});
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(body.ipnUrl).toBe(customCallback);
vi.unstubAllGlobals();
});
});

View File

@@ -6,6 +6,7 @@ import { ZalopayService } from '../services/zalopay.service';
describe('ZalopayService', () => {
let service: ZalopayService;
const key2 = 'TESTKEY2ABCDEF1234567890ABCDEF12';
const callbackBaseUrl = 'https://api.goodgo.vn';
beforeEach(() => {
const mockConfig = {
@@ -14,6 +15,7 @@ describe('ZalopayService', () => {
'ZALOPAY_APP_ID': '2553',
'ZALOPAY_KEY1': 'TESTKEY1ABCDEF1234567890ABCDEF12',
'ZALOPAY_KEY2': 'TESTKEY2ABCDEF1234567890ABCDEF12',
'PAYMENT_CALLBACK_BASE_URL': callbackBaseUrl,
};
return env[key] ?? defaultValue;
}),
@@ -99,4 +101,51 @@ describe('ZalopayService', () => {
expect(result.isValid).toBe(false);
expect(result.isSuccess).toBe(false);
});
it('createPaymentUrl should use backend URL for callback_url and frontend URL in embed_data.redirecturl', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({ return_code: 1, order_url: 'https://zalopay.vn/pay', zp_trans_token: 'tok' }),
});
vi.stubGlobal('fetch', fetchMock);
const returnUrl = 'https://goodgo.vn/payment/return';
await service.createPaymentUrl({
orderId: 'order-xyz',
amountVND: 100000n,
description: 'Test',
returnUrl,
ipAddress: '127.0.0.1',
});
expect(fetchMock).toHaveBeenCalledOnce();
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(body.callback_url).toBe(`${callbackBaseUrl}/api/v1/payments/callback/zalopay`);
expect(body.callback_url).not.toBe(returnUrl);
const embedData = JSON.parse(body.embed_data as string);
expect(embedData.redirecturl).toBe(returnUrl);
vi.unstubAllGlobals();
});
it('createPaymentUrl should use provided callbackUrl for callback_url when supplied', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({ return_code: 1, order_url: 'https://zalopay.vn/pay', zp_trans_token: 'tok' }),
});
vi.stubGlobal('fetch', fetchMock);
const customCallback = 'https://staging-api.goodgo.vn/api/v1/payments/callback/zalopay';
await service.createPaymentUrl({
orderId: 'order-xyz',
amountVND: 100000n,
description: 'Test',
returnUrl: 'https://goodgo.vn/payment/return',
callbackUrl: customCallback,
ipAddress: '127.0.0.1',
});
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(body.callback_url).toBe(customCallback);
vi.unstubAllGlobals();
});
});

View File

@@ -20,6 +20,7 @@ export class MomoService implements IPaymentGateway {
private readonly accessKey: string;
private readonly secretKey: string;
private readonly endpoint: string;
private readonly callbackBaseUrl: string;
constructor(
private readonly config: ConfigService,
@@ -29,6 +30,7 @@ export class MomoService implements IPaymentGateway {
this.accessKey = this.config.getOrThrow<string>('MOMO_ACCESS_KEY');
this.secretKey = this.config.getOrThrow<string>('MOMO_SECRET_KEY');
this.endpoint = this.config.get<string>('MOMO_ENDPOINT', 'https://test-payment.momo.vn/v2/gateway/api');
this.callbackBaseUrl = this.config.get<string>('PAYMENT_CALLBACK_BASE_URL', 'https://api.goodgo.vn');
}
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
@@ -39,11 +41,14 @@ export class MomoService implements IPaymentGateway {
const lang = 'vi';
const amount = params.amountVND.toString();
// ipnUrl must be the backend server-to-server callback endpoint (never the frontend URL)
const ipnUrl = params.callbackUrl ?? `${this.callbackBaseUrl}/api/v1/payments/callback/momo`;
const rawSignature = [
`accessKey=${this.accessKey}`,
`amount=${amount}`,
`extraData=${extraData}`,
`ipnUrl=${params.returnUrl}`,
`ipnUrl=${ipnUrl}`,
`orderId=${params.orderId}`,
`orderInfo=${params.description}`,
`partnerCode=${this.partnerCode}`,
@@ -66,7 +71,7 @@ export class MomoService implements IPaymentGateway {
orderId: params.orderId,
orderInfo: params.description,
redirectUrl: params.returnUrl,
ipnUrl: params.returnUrl,
ipnUrl,
lang,
requestType,
autoCapture,

View File

@@ -6,7 +6,10 @@ export interface CreatePaymentUrlParams {
orderId: string;
amountVND: bigint;
description: string;
/** Frontend redirect URL shown to the user after payment */
returnUrl: string;
/** Backend IPN / server-callback URL (overrides PAYMENT_CALLBACK_BASE_URL when provided) */
callbackUrl?: string;
ipAddress: string;
}

View File

@@ -20,6 +20,7 @@ export class ZalopayService implements IPaymentGateway {
private readonly key1: string;
private readonly key2: string;
private readonly endpoint: string;
private readonly callbackBaseUrl: string;
constructor(
private readonly config: ConfigService,
@@ -29,6 +30,7 @@ export class ZalopayService implements IPaymentGateway {
this.key1 = this.config.getOrThrow<string>('ZALOPAY_KEY1');
this.key2 = this.config.getOrThrow<string>('ZALOPAY_KEY2');
this.endpoint = this.config.get<string>('ZALOPAY_ENDPOINT', 'https://sb-openapi.zalopay.vn/v2');
this.callbackBaseUrl = this.config.get<string>('PAYMENT_CALLBACK_BASE_URL', 'https://api.goodgo.vn');
}
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
@@ -36,7 +38,10 @@ export class ZalopayService implements IPaymentGateway {
const appTransId = `${this.formatYYMMDD(now)}_${params.orderId}`;
const appTime = now.getTime();
const amount = Number(params.amountVND);
// embed_data carries the frontend redirect URL; callback_url is the backend IPN endpoint
const embedData = JSON.stringify({ redirecturl: params.returnUrl });
const callbackUrl = params.callbackUrl ?? `${this.callbackBaseUrl}/api/v1/payments/callback/zalopay`;
const items = JSON.stringify([]);
const data = [
@@ -62,7 +67,7 @@ export class ZalopayService implements IPaymentGateway {
item: items,
description: params.description,
embed_data: embedData,
callback_url: params.returnUrl,
callback_url: callbackUrl,
mac,
};