Files
Qris-Soundbox/dist/shared/store/transactionStore.js

216 lines
7.6 KiB
JavaScript

import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
import { createPaidLedgerPlaceholder } from "./ledgerStore";
function nowIso() {
return new Date().toISOString();
}
function makeCode(id) {
return `tx_${id.slice(0, 8)}`;
}
function mapTransaction(row) {
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) {
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 = {
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, to) {
return TRANSACTION_STATE_TRANSITIONS[from]?.includes(to) ?? false;
}
export async function createTransaction(payload) {
const id = randomUUID();
const now = nowIso();
const entity = {
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) {
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, to, options) {
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 = {
...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) {
const { rows } = await getPool().query("SELECT * FROM transactions WHERE id = $1", [id]);
return rows[0] ? mapTransaction(rows[0]) : null;
}
export async function findTransactionByPartnerReference(partnerReference) {
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) {
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 listDueDynamicQrTransactions(limit = 100) {
const safeLimit = Math.min(Math.max(limit, 1), 500);
const { rows } = await getPool().query(`SELECT * FROM transactions
WHERE qr_mode = 'dynamic'
AND status = 'awaiting_payment'
AND expired_at IS NOT NULL
AND expired_at <= NOW()
ORDER BY expired_at ASC
LIMIT ${safeLimit}`);
return rows.map(mapTransaction);
}
export async function getTransactionEvents(transactionId) {
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) {
return { ...transaction };
}
export function toTransactionEventPayload(event) {
return { ...event, payload_json: { ...event.payload_json } };
}