import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest' // Standard WS/notification mocks (same as the other public-app route tests). vi.mock('../../src/plugins/websocket.js', () => ({ sendToUser: vi.fn(() => false), sendToSessionParticipant: vi.fn(() => false), registerWebSocketPlugin: vi.fn(async () => {}), registerWebSocketRoute: vi.fn(), isUserOnlineWs: vi.fn(() => false), getSessionConnections: vi.fn(() => ({})), })) vi.mock('../../src/services/notification.service.js', () => ({ sendPushNotification: vi.fn(async () => true), registerDeviceToken: vi.fn(async () => {}), })) const { buildPublic } = await import('../helpers/server.js') const { resetDb, resetAppConfig, db } = await import('../helpers/db.js') const { createCustomer } = await import('../helpers/fixtures.js') const { PaymentRequestStatus } = await import('../../src/constants.js') const { requestPayment } = await import('../../src/services/payment.service.js') const WEBHOOK_TOKEN = 'test-webhook-token-abcdefghijklmnop' const fireWebhook = (app, body, token = WEBHOOK_TOKEN) => app.inject({ method: 'POST', url: '/api/shared/payment/webhooks/xendit', headers: { 'x-callback-token': token, 'content-type': 'application/json' }, payload: body, }) describe('POST /api/shared/payment/webhooks/xendit', () => { let app let customer beforeAll(async () => { vi.stubEnv('XENDIT_WEBHOOK_TOKEN', WEBHOOK_TOKEN) await resetAppConfig() app = await buildPublic() }) beforeEach(async () => { await resetDb() const phone = `+628${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}` customer = await createCustomer({ callName: 'XenditTester', phone }) }) afterAll(async () => { await app?.close() vi.unstubAllEnvs() }) it('401s when x-callback-token is missing or wrong', async () => { const session = await requestPayment({ productType: 'chat_session', customerId: customer.id, durationMinutes: 12, amount: 50_000, }) const wrong = await fireWebhook(app, { id: 'inv_x', external_id: session.id, status: 'PAID', amount: 50_000, }, 'totally-wrong-token-of-same-shape-len') expect(wrong.statusCode).toBe(401) const missing = await app.inject({ method: 'POST', url: '/api/shared/payment/webhooks/xendit', payload: { id: 'inv_x', external_id: session.id, status: 'PAID', amount: 50_000 }, }) expect(missing.statusCode).toBe(401) }) it('PAID confirms pending request and stamps xendit_* columns', async () => { const session = await requestPayment({ productType: 'chat_session', customerId: customer.id, durationMinutes: 12, amount: 50_000, }) const res = await fireWebhook(app, { id: 'inv_abc', external_id: session.id, status: 'PAID', amount: 50_000, payment_method: 'BCA', }) expect(res.statusCode).toBe(200) expect(res.json().ok).toBe(true) const [row] = await db()` SELECT status, confirmed_at, xendit_invoice_id, xendit_payment_method, xendit_paid_amount FROM payment_requests WHERE id = ${session.id} ` expect(row.status).toBe(PaymentRequestStatus.CONFIRMED) expect(row.confirmed_at).not.toBeNull() expect(row.xendit_invoice_id).toBe('inv_abc') expect(row.xendit_payment_method).toBe('BCA') expect(row.xendit_paid_amount).toBe(50_000) }) it('PAID with amount mismatch returns 409 and leaves row pending', async () => { const session = await requestPayment({ productType: 'chat_session', customerId: customer.id, durationMinutes: 12, amount: 50_000, }) const res = await fireWebhook(app, { id: 'inv_bad', external_id: session.id, status: 'PAID', amount: 999, }) expect(res.statusCode).toBe(409) expect(res.json().error).toBe('amount_mismatch') const [row] = await db()`SELECT status, xendit_invoice_id FROM payment_requests WHERE id = ${session.id}` expect(row.status).toBe(PaymentRequestStatus.PENDING) expect(row.xendit_invoice_id).toBeNull() }) it('idempotent: second PAID delivery for the same row ACKs without erroring', async () => { const session = await requestPayment({ productType: 'chat_session', customerId: customer.id, durationMinutes: 12, amount: 50_000, }) const first = await fireWebhook(app, { id: 'inv_dup', external_id: session.id, status: 'PAID', amount: 50_000, payment_method: 'BCA', }) expect(first.statusCode).toBe(200) const second = await fireWebhook(app, { id: 'inv_dup', external_id: session.id, status: 'PAID', amount: 50_000, payment_method: 'BCA', }) expect(second.statusCode).toBe(200) expect(second.json().ok).toBe(true) const [row] = await db()`SELECT status FROM payment_requests WHERE id = ${session.id}` expect(row.status).toBe(PaymentRequestStatus.CONFIRMED) }) it('EXPIRED flips pending → expired (idempotent on repeat)', async () => { const session = await requestPayment({ productType: 'chat_session', customerId: customer.id, durationMinutes: 12, amount: 50_000, }) const res = await fireWebhook(app, { id: 'inv_exp', external_id: session.id, status: 'EXPIRED', }) expect(res.statusCode).toBe(200) const [row] = await db()`SELECT status FROM payment_requests WHERE id = ${session.id}` expect(row.status).toBe(PaymentRequestStatus.EXPIRED) // Second delivery is a no-op (WHERE status = 'pending' filters it out) const repeat = await fireWebhook(app, { id: 'inv_exp', external_id: session.id, status: 'EXPIRED', }) expect(repeat.statusCode).toBe(200) }) it('unknown external_id ACKs without 5xx so Xendit stops retrying', async () => { const res = await fireWebhook(app, { id: 'inv_orphan', external_id: '00000000-0000-0000-0000-000000000000', status: 'PAID', amount: 50_000, }) expect(res.statusCode).toBe(200) expect(res.json().ignored).toBe('unknown_payment_request') }) it('missing external_id ACKs (forward-compat for non-Invoice event types)', async () => { const res = await fireWebhook(app, { id: 'evt_x', status: 'SOMETHING_ELSE' }) expect(res.statusCode).toBe(200) expect(res.json().ignored).toBe('no_external_id') }) it('unhandled status ACKs as ignored', async () => { const session = await requestPayment({ productType: 'chat_session', customerId: customer.id, durationMinutes: 12, amount: 50_000, }) const res = await fireWebhook(app, { id: 'inv_partial', external_id: session.id, status: 'PARTIAL_REFUND', amount: 50_000, }) expect(res.statusCode).toBe(200) expect(res.json().ignored).toBe('PARTIAL_REFUND') const [row] = await db()`SELECT status FROM payment_requests WHERE id = ${session.id}` expect(row.status).toBe(PaymentRequestStatus.PENDING) }) })