Phase 4 Stage 10 backend: Chat-tab feeds (pending payments + cursor history)
Backend half of Stage 10 — the new Chat tab in the customer app that replaces /chat/history with a 3-sub-tab list (Aktif / Pembayaran / Selesai). - New GET /api/client/payment-sessions/pending — returns the customer's pending initial + extension payment sessions. Filter is status='pending' AND expires_at > NOW(). Mitra info comes from session_extensions → chat_sessions for extension rows, payment_sessions.targeted_mitra_id for targeted-curhat-lagi initial rows. TTL reuses the existing payment_session_timeout_minutes app_config row (default 20m) — no new config row needed since payment is still mocked. - getCustomerHistory migrated from offset (page/limit) to cursor pagination. Cursor is base64url(`<endedAtIso>|<id>`) with id-tiebreak in ORDER BY so rows with identical timestamps don't duplicate or skip across pages. SELECT now JOINs payment_sessions to surface `mode` (chat/call) for the Selesai-row voice-call pill. - requirement/flow_customer.mermaid.md: new §7 Chat Tab subgraph + Figma cross-ref entry for SChatList. - requirement/phase4-customer-flow-plan.md: Stage 10 plan section. Also carries forward earlier uncommitted "Post-Stage-8 corrections" notes from the Stage 9 sweep (boot path / SHome1st / onboarding fixes). Tests: +7 for getCustomerPendingPayments (initial null mitra, targeted-mitra fill, extension-via-session JOIN, mixed-newest-first, expired excluded, non-pending excluded, customer scoping). +10 for cursor history (empty, exact-fit, multi-page walk, same-timestamp tiebreak, limit clamp, customer scoping, CLOSING+COMPLETED only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,11 @@ import {
|
||||
createPaymentSession,
|
||||
confirmPaymentSession,
|
||||
getPaymentSession,
|
||||
getCustomerPendingPayments,
|
||||
} from '../../src/services/payment.service.js'
|
||||
import { PaymentSessionStatus } from '../../src/constants.js'
|
||||
import { resetDb, resetAppConfig } from '../helpers/db.js'
|
||||
import { createCustomer } from '../helpers/fixtures.js'
|
||||
import { PaymentSessionStatus, 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
|
||||
@@ -83,4 +84,167 @@ describe('payment.service', () => {
|
||||
expect(reloaded.status).toBe(PaymentSessionStatus.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_session_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_session_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_sessions
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user