import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest' import { createPaymentSession, confirmPaymentSession, getPaymentSession, getCustomerPendingPayments, } from '../../src/services/payment.service.js' import { PaymentRequestStatus, SessionStatus } from '../../src/constants.js' import { resetDb, resetAppConfig, db } from '../helpers/db.js' import { createCustomer, createMitra } from '../helpers/fixtures.js' describe('payment.service', () => { let customer let otherCustomer beforeAll(async () => { await resetAppConfig() }) beforeEach(async () => { await resetDb() customer = await createCustomer({ callName: 'Alice' }) otherCustomer = await createCustomer({ callName: 'Bob' }) }) afterAll(async () => { // Leave the seeded users alone for the next test file's speed. }) it('createPaymentSession writes a row with status pending and expires_at in the future', async () => { const before = Date.now() const session = await createPaymentSession({ customerId: customer.id, durationMinutes: 15, amount: 30000, }) expect(session.status).toBe(PaymentRequestStatus.PENDING) expect(session.customer_id).toBe(customer.id) expect(session.duration_minutes).toBe(15) expect(session.amount).toBe(30000) expect(session.is_first_session_discount).toBe(false) expect(session.is_extension).toBe(false) expect(session.mode).toBe('chat') expect(new Date(session.expires_at).getTime()).toBeGreaterThan(before) // Verify it's actually persisted (not just returned from the INSERT) const reloaded = await getPaymentSession(session.id) expect(reloaded.id).toBe(session.id) expect(reloaded.status).toBe(PaymentRequestStatus.PENDING) }) it('confirmPaymentSession transitions pending → confirmed', async () => { const session = await createPaymentSession({ customerId: customer.id, durationMinutes: 30, amount: 60000, }) expect(session.status).toBe(PaymentRequestStatus.PENDING) const confirmed = await confirmPaymentSession(session.id, customer.id) expect(confirmed.status).toBe(PaymentRequestStatus.CONFIRMED) expect(confirmed.confirmed_at).toBeTruthy() expect(new Date(confirmed.confirmed_at).getTime()).toBeGreaterThan(0) }) it('confirmPaymentSession throws when the session belongs to a different customer', async () => { const session = await createPaymentSession({ customerId: customer.id, durationMinutes: 15, amount: 30000, }) await expect( confirmPaymentSession(session.id, otherCustomer.id), ).rejects.toMatchObject({ code: 'FORBIDDEN', statusCode: 403, }) // Row should still be pending — the failed confirm must not have side effects. const reloaded = await getPaymentSession(session.id) expect(reloaded.status).toBe(PaymentRequestStatus.PENDING) expect(reloaded.confirmed_at).toBeNull() }) // Phase 4 Stage 10 — Chat Tab Pembayaran feed. describe('getCustomerPendingPayments', () => { it('returns empty when customer has no payments', async () => { const result = await getCustomerPendingPayments(customer.id) expect(result.items).toEqual([]) expect(result.total).toBe(0) }) it('returns pending initial-session payment with null mitra info', async () => { const pay = await createPaymentSession({ customerId: customer.id, durationMinutes: 15, amount: 5000, }) const result = await getCustomerPendingPayments(customer.id) expect(result.total).toBe(1) expect(result.items[0]).toMatchObject({ id: pay.id, is_extension: false, amount: 5000, duration_minutes: 15, mode: 'chat', mitra_id: null, mitra_display_name: null, }) }) it('fills mitra info from targeted_mitra_id for targeted initial payments', async () => { const mitra = await createMitra({ callName: 'kak Dimas' }) await createPaymentSession({ customerId: customer.id, durationMinutes: 30, amount: 10000, targetedMitraId: mitra.id, }) const result = await getCustomerPendingPayments(customer.id) expect(result.total).toBe(1) expect(result.items[0].mitra_id).toBe(mitra.id) expect(result.items[0].mitra_display_name).toBe('kak Dimas') }) it('fills mitra info via session_extensions → chat_sessions for extension payments', async () => { const sql = db() const mitra = await createMitra({ callName: 'kak Sari' }) // The initial chat session this extension belongs to. const [chatSession] = await sql` INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes) VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.ACTIVE}, 12) RETURNING id ` const extPay = await createPaymentSession({ customerId: customer.id, durationMinutes: 10, amount: 2500, isExtension: true, }) await sql` INSERT INTO session_extensions ( session_id, requested_duration_minutes, requested_price, status, payment_request_id ) VALUES (${chatSession.id}, 10, 2500, 'pending', ${extPay.id}) ` const result = await getCustomerPendingPayments(customer.id) expect(result.total).toBe(1) expect(result.items[0]).toMatchObject({ id: extPay.id, is_extension: true, amount: 2500, mitra_id: mitra.id, mitra_display_name: 'kak Sari', }) }) it('orders newest first and returns mixed initial + extension rows', async () => { const sql = db() const mitra = await createMitra({ callName: 'kak Sari' }) // Initial first const initial = await createPaymentSession({ customerId: customer.id, durationMinutes: 15, amount: 5000, }) // Then extension (newer) const [chatSession] = await sql` INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes) VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.ACTIVE}, 12) RETURNING id ` const extension = await createPaymentSession({ customerId: customer.id, durationMinutes: 10, amount: 2500, isExtension: true, }) await sql` INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_request_id) VALUES (${chatSession.id}, 10, 2500, 'pending', ${extension.id}) ` const result = await getCustomerPendingPayments(customer.id) expect(result.total).toBe(2) // Newest first expect(result.items[0].id).toBe(extension.id) expect(result.items[1].id).toBe(initial.id) }) it('excludes expired pending rows (defensive filter on expires_at)', async () => { const sql = db() const pay = await createPaymentSession({ customerId: customer.id, durationMinutes: 15, amount: 5000, }) // Manually move expires_at into the past — leaves status pending so this // simulates the gap between TTL expiry and the next sweep tick. await sql` UPDATE payment_requests SET expires_at = NOW() - INTERVAL '1 second' WHERE id = ${pay.id} ` const result = await getCustomerPendingPayments(customer.id) expect(result.total).toBe(0) }) it('excludes non-pending statuses', async () => { const pay = await createPaymentSession({ customerId: customer.id, durationMinutes: 15, amount: 5000, }) await confirmPaymentSession(pay.id, customer.id) // → confirmed const result = await getCustomerPendingPayments(customer.id) expect(result.total).toBe(0) }) it('scopes by customer — does not leak other customers payments', async () => { await createPaymentSession({ customerId: customer.id, durationMinutes: 15, amount: 5000, }) await createPaymentSession({ customerId: otherCustomer.id, durationMinutes: 30, amount: 10000, }) const result = await getCustomerPendingPayments(customer.id) expect(result.total).toBe(1) expect(result.items[0].amount).toBe(5000) }) }) })