import { randomUUID } from "node:crypto"; import { getPool } from "../db/pool"; import { createPaidLedgerPlaceholder } from "./ledgerStore"; export type TransactionStatus = | "initiated" | "awaiting_payment" | "paid" | "failed" | "expired" | "reversed"; export type TransactionEventType = | "INITIATED" | "STATE_CHANGED" | "CALLBACK_RECEIVED" | "CALLBACK_REJECTED" | "CALLBACK_DUPLICATE" | "PUSH_QUEUED" | "DYNAMIC_QR_CREATED"; export type TransactionEventSource = "webhook" | "system" | "admin" | "device"; export interface TransactionEntity { id: string; transaction_code: string; merchant_id: string; outlet_id: string; terminal_id: string; device_id?: string; qr_mode: "static" | "dynamic"; initiation_mode: "static" | "manual" | "dynamic_api" | "dynamic_mqtt"; partner_reference: string; amount: number; currency: string; status: TransactionStatus; created_at: string; paid_at?: string; expired_at?: string; updated_at: string; } export interface TransactionEventEntity { id: string; transaction_id: string; event_type: TransactionEventType; source: TransactionEventSource; payload_json: Record; created_at: string; } function nowIso() { return new Date().toISOString(); } function makeCode(id: string) { return `tx_${id.slice(0, 8)}`; } function mapTransaction(row: any): TransactionEntity { return { id: row.id, transaction_code: row.transaction_code, merchant_id: row.merchant_id, outlet_id: row.outlet_id, terminal_id: row.terminal_id, device_id: row.device_id || undefined, qr_mode: row.qr_mode, initiation_mode: row.initiation_mode, partner_reference: row.partner_reference, amount: Number(row.amount), currency: row.currency, status: row.status, created_at: row.created_at, paid_at: row.paid_at || undefined, expired_at: row.expired_at || undefined, updated_at: row.updated_at }; } function mapEvent(row: any): TransactionEventEntity { return { id: row.id, transaction_id: row.transaction_id, event_type: row.event_type, source: row.source, payload_json: row.payload_json || {}, created_at: row.created_at }; } const TRANSACTION_STATE_TRANSITIONS: Record = { initiated: ["initiated", "awaiting_payment", "paid", "failed", "expired", "reversed"], awaiting_payment: ["awaiting_payment", "paid", "failed", "expired", "reversed"], paid: ["paid", "reversed"], failed: ["failed", "reversed"], expired: ["expired", "reversed"], reversed: ["reversed"] }; function isValidTransactionTransition(from: TransactionStatus, to: TransactionStatus) { return TRANSACTION_STATE_TRANSITIONS[from]?.includes(to) ?? false; } export async function createTransaction(payload: { merchant_id: string; outlet_id: string; terminal_id: string; device_id?: string; qr_mode?: "static" | "dynamic"; initiation_mode?: "static" | "manual" | "dynamic_api" | "dynamic_mqtt"; partner_reference: string; amount: number; currency?: string; status?: TransactionStatus; paid_at?: string; expired_at?: string; }): Promise { const id = randomUUID(); const now = nowIso(); const entity: TransactionEntity = { id, transaction_code: makeCode(id), merchant_id: payload.merchant_id, outlet_id: payload.outlet_id, terminal_id: payload.terminal_id, device_id: payload.device_id, qr_mode: payload.qr_mode || "static", initiation_mode: payload.initiation_mode || "static", partner_reference: payload.partner_reference, amount: payload.amount, currency: payload.currency || "IDR", status: payload.status || "initiated", created_at: now, paid_at: payload.paid_at, expired_at: payload.expired_at, updated_at: now }; const txResult = await getPool().query( `INSERT INTO transactions ( id, transaction_code, merchant_id, outlet_id, terminal_id, device_id, qr_mode, initiation_mode, partner_reference, amount, currency, status, created_at, paid_at, expired_at, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING *`, [ entity.id, entity.transaction_code, entity.merchant_id, entity.outlet_id, entity.terminal_id, entity.device_id || null, entity.qr_mode, entity.initiation_mode, entity.partner_reference, entity.amount, entity.currency, entity.status, entity.created_at, entity.paid_at || null, entity.expired_at || null, entity.updated_at ] ); await addTransactionEvent({ transaction_id: txResult.rows[0].id, event_type: "INITIATED", source: "system", payload_json: { status: txResult.rows[0].status, partner_reference: payload.partner_reference } }); return mapTransaction(txResult.rows[0]); } export async function addTransactionEvent(payload: { transaction_id: string; event_type: TransactionEventType; source: TransactionEventSource; payload_json?: Record; }): Promise { const id = randomUUID(); const { rows } = await getPool().query( `INSERT INTO transaction_events (id, transaction_id, event_type, source, payload_json, created_at) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [id, payload.transaction_id, payload.event_type, payload.source, payload.payload_json || {}, nowIso()] ); return mapEvent(rows[0]); } export async function updateTransactionStatus( id: string, to: TransactionStatus, options: { source: TransactionEventSource; eventContext?: Record; paid_at?: string; expired_at?: string; } ): Promise { const entity = await getTransactionById(id); if (!entity) { throw new Error("TRANSACTION_NOT_FOUND"); } if (!isValidTransactionTransition(entity.status, to)) { throw new Error(`INVALID_TRANSACTION_STATE_TRANSITION:${entity.status}->${to}`); } if (entity.status === to) { return entity; } const now = nowIso(); const next: TransactionEntity = { ...entity, status: to, paid_at: options.paid_at || entity.paid_at, expired_at: options.expired_at || entity.expired_at, updated_at: now }; if (to === "paid" && !next.paid_at) { next.paid_at = now; } if (to === "expired" && !next.expired_at) { next.expired_at = now; } const { rows } = await getPool().query( `UPDATE transactions SET status = $2, paid_at = $3, expired_at = $4, updated_at = $5 WHERE id = $1 RETURNING *`, [id, next.status, next.paid_at || null, next.expired_at || null, next.updated_at] ); await addTransactionEvent({ transaction_id: id, event_type: "STATE_CHANGED", source: options.source, payload_json: { from: entity.status, to, ...options.eventContext } }); const updated = mapTransaction(rows[0]); if (to === "paid") { await createPaidLedgerPlaceholder(updated); } return updated; } export async function getTransactionById(id: string): Promise { const { rows } = await getPool().query("SELECT * FROM transactions WHERE id = $1", [id]); return rows[0] ? mapTransaction(rows[0]) : null; } export async function findTransactionByPartnerReference(partnerReference: string): Promise { const { rows } = await getPool().query( "SELECT * FROM transactions WHERE partner_reference = $1", [partnerReference] ); return rows[0] ? mapTransaction(rows[0]) : null; } export async function listTransactions(filter?: { status?: TransactionStatus; merchant_id?: string; }): Promise { if (filter?.status && filter?.merchant_id) { const { rows } = await getPool().query( "SELECT * FROM transactions WHERE status = $1 AND merchant_id = $2 ORDER BY created_at DESC", [filter.status, filter.merchant_id] ); return rows.map(mapTransaction); } if (filter?.status) { const { rows } = await getPool().query( "SELECT * FROM transactions WHERE status = $1 ORDER BY created_at DESC", [filter.status] ); return rows.map(mapTransaction); } if (filter?.merchant_id) { const { rows } = await getPool().query( "SELECT * FROM transactions WHERE merchant_id = $1 ORDER BY created_at DESC", [filter.merchant_id] ); return rows.map(mapTransaction); } const { rows } = await getPool().query("SELECT * FROM transactions ORDER BY created_at DESC"); return rows.map(mapTransaction); } export async function getTransactionEvents(transactionId: string): Promise { const { rows } = await getPool().query( "SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC", [transactionId] ); return rows.map(mapEvent); } export function toTransactionPayload(transaction: TransactionEntity) { return { ...transaction }; } export function toTransactionEventPayload(event: TransactionEventEntity) { return { ...event, payload_json: { ...event.payload_json } }; }