diff --git a/.env.example b/.env.example index 4c820ec..393fab7 100644 --- a/.env.example +++ b/.env.example @@ -111,23 +111,47 @@ NEXT_PUBLIC_MAPBOX_TOKEN= # ----------------------------------------------------------------------------- # Payment Gateways (VNPay, MoMo, ZaloPay) -# Leave empty if not using payment features +# Leave empty if not using payment features. +# +# IMPORTANT: The values below default to SANDBOX endpoints. When deploying +# with NODE_ENV=production, swap each *_BASE_URL / *_ENDPOINT to the +# production URL and set *_TMN_CODE / *_PARTNER_CODE / *_APP_ID / secret +# values to live merchant credentials issued by the gateway. See +# docs/payment-go-live-checklist.md for the full cutover procedure. +# The API logs a startup warning if production mode is detected with +# sandbox-looking credentials. # ----------------------------------------------------------------------------- + +# VNPay — sandbox by default +# Production: VNPAY_BASE_URL=https://pay.vnpay.vn/vpcpay.html +# Production: VNPAY_API_URL=https://merchant.vnpay.vn/merchant_webapi/api/transaction VNPAY_TMN_CODE= VNPAY_HASH_SECRET= VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction +# MoMo — sandbox by default +# Production: MOMO_ENDPOINT=https://payment.momo.vn/v2/gateway/api MOMO_PARTNER_CODE= MOMO_ACCESS_KEY= MOMO_SECRET_KEY= MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api +# ZaloPay — sandbox by default +# Production: ZALOPAY_ENDPOINT=https://openapi.zalopay.vn/v2 ZALOPAY_APP_ID= ZALOPAY_KEY1= ZALOPAY_KEY2= ZALOPAY_ENDPOINT=https://sb-openapi.zalopay.vn/v2 +# Backend base URL used to construct IPN (server-to-server) callback URLs for +# MoMo (ipnUrl) and ZaloPay (callback_url). Must point to the API server, NOT +# the frontend. Example: https://api.goodgo.vn +# Individual gateway callback paths are appended automatically: +# MoMo → {PAYMENT_CALLBACK_BASE_URL}/api/v1/payments/callback/momo +# ZaloPay → {PAYMENT_CALLBACK_BASE_URL}/api/v1/payments/callback/zalopay +PAYMENT_CALLBACK_BASE_URL=https://api.goodgo.vn + BANK_TRANSFER_ACCOUNT_NUMBER= BANK_TRANSFER_BANK_NAME= BANK_TRANSFER_ACCOUNT_HOLDER= diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/momo.service.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/momo.service.spec.ts index e5f8b55..486235e 100644 --- a/apps/api/src/modules/payments/infrastructure/__tests__/momo.service.spec.ts +++ b/apps/api/src/modules/payments/infrastructure/__tests__/momo.service.spec.ts @@ -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(); + }); }); diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts index d01223c..502787d 100644 --- a/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts +++ b/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts @@ -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(); + }); }); diff --git a/apps/api/src/modules/payments/infrastructure/services/momo.service.ts b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts index e93f3a0..0797c9c 100644 --- a/apps/api/src/modules/payments/infrastructure/services/momo.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts @@ -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('MOMO_ACCESS_KEY'); this.secretKey = this.config.getOrThrow('MOMO_SECRET_KEY'); this.endpoint = this.config.get('MOMO_ENDPOINT', 'https://test-payment.momo.vn/v2/gateway/api'); + this.callbackBaseUrl = this.config.get('PAYMENT_CALLBACK_BASE_URL', 'https://api.goodgo.vn'); } async createPaymentUrl(params: CreatePaymentUrlParams): Promise { @@ -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, diff --git a/apps/api/src/modules/payments/infrastructure/services/payment-gateway.interface.ts b/apps/api/src/modules/payments/infrastructure/services/payment-gateway.interface.ts index 39147c1..f1f9e3c 100644 --- a/apps/api/src/modules/payments/infrastructure/services/payment-gateway.interface.ts +++ b/apps/api/src/modules/payments/infrastructure/services/payment-gateway.interface.ts @@ -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; } diff --git a/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts index c8d4a69..967fb74 100644 --- a/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts @@ -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('ZALOPAY_KEY1'); this.key2 = this.config.getOrThrow('ZALOPAY_KEY2'); this.endpoint = this.config.get('ZALOPAY_ENDPOINT', 'https://sb-openapi.zalopay.vn/v2'); + this.callbackBaseUrl = this.config.get('PAYMENT_CALLBACK_BASE_URL', 'https://api.goodgo.vn'); } async createPaymentUrl(params: CreatePaymentUrlParams): Promise { @@ -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, }; diff --git a/libs/mcp-servers/src/__tests__/property-search.server.test.ts b/libs/mcp-servers/src/__tests__/property-search.server.test.ts index 6f44760..32ba7a3 100644 --- a/libs/mcp-servers/src/__tests__/property-search.server.test.ts +++ b/libs/mcp-servers/src/__tests__/property-search.server.test.ts @@ -65,6 +65,17 @@ describe('PropertySearchServer', () => { }); describe('search_properties', () => { + it('always filters by uppercase ACTIVE status matching Prisma enum', async () => { + client._search.mockResolvedValue(makeHits([])); + const handler = getToolHandler(server, 'search_properties'); + + await handler({ query: '*', page: 1, perPage: 20 }); + + const filterBy = client._search.mock.calls[0][0].filter_by as string; + expect(filterBy).toContain('status:=ACTIVE'); + expect(filterBy).not.toContain('status:=active'); + }); + it('searches with basic query', async () => { const handler = getToolHandler(server, 'search_properties'); const result = await handler({ query: 'căn hộ Quận 7', page: 1, perPage: 20 }); @@ -101,7 +112,7 @@ describe('PropertySearchServer', () => { }); const filterBy = client._search.mock.calls[0][0].filter_by as string; - expect(filterBy).toContain('status:=active'); + expect(filterBy).toContain('status:=ACTIVE'); expect(filterBy).toContain('propertyType:=apartment'); expect(filterBy).toContain('transactionType:=sale'); expect(filterBy).toContain('priceVND:>=1000000000'); diff --git a/libs/mcp-servers/src/property-search/property-search.server.ts b/libs/mcp-servers/src/property-search/property-search.server.ts index 62d2fe3..3be5f3d 100644 --- a/libs/mcp-servers/src/property-search/property-search.server.ts +++ b/libs/mcp-servers/src/property-search/property-search.server.ts @@ -45,7 +45,7 @@ export function createPropertySearchServer(deps: PropertySearchDeps): McpServer 'Search property listings using natural language queries with filters.', SearchPropertiesSchema, async (params: z.infer>) => { - const filters: string[] = ['status:=active']; + const filters: string[] = ['status:=ACTIVE']; if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`); if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`);