338 lines
9.1 KiB
TypeScript
338 lines
9.1 KiB
TypeScript
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<string, unknown>;
|
|
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<TransactionStatus, TransactionStatus[]> = {
|
|
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<TransactionEntity> {
|
|
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<string, unknown>;
|
|
}): Promise<TransactionEventEntity> {
|
|
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<string, unknown>;
|
|
paid_at?: string;
|
|
expired_at?: string;
|
|
}
|
|
): Promise<TransactionEntity> {
|
|
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<TransactionEntity | null> {
|
|
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<TransactionEntity | null> {
|
|
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<TransactionEntity[]> {
|
|
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<TransactionEventEntity[]> {
|
|
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 } };
|
|
}
|