feat: Enhance payment processing with additional validations and sequence generation
Deploy Application / deploy (push) Successful in 30s Details

This commit is contained in:
faisolavolut 2025-10-28 12:29:02 +07:00
parent 2f0eae4f7e
commit d6effb7fd8
4 changed files with 368 additions and 70 deletions

View File

@ -115,6 +115,7 @@ model banks {
bank_code bank_code? @relation(fields: [bank_code_id], references: [bank_code_id], onDelete: NoAction, onUpdate: NoAction)
invoice invoice[]
transactions transactions[]
transactions_to transactions[] @relation("transactions_bankto_idTobanks")
users users[]
}
@ -288,7 +289,17 @@ model transactions {
trxDateTime DateTime? @db.Timestamptz(6)
virtualAccountName String? @db.VarChar(255)
virtualAccountNo String? @db.VarChar(50)
partnerReferenceNo String? @db.VarChar(50)
bankto_id String? @db.Uuid
external_id String? @db.VarChar(50)
beneficiaryAccountName String? @db.VarChar(255)
beneficiaryAccountNo String? @db.VarChar(255)
method String? @db.VarChar(50)
originatorCustomerName String? @db.VarChar(255)
originatorCustomerNo String? @db.VarChar(50)
sourceAccountNo String? @db.VarChar(255)
banks banks @relation(fields: [bank_id], references: [bank_id], onDelete: NoAction, onUpdate: NoAction)
banks_to banks? @relation("transactions_bankto_idTobanks", fields: [bankto_id], references: [bank_id], onDelete: NoAction, onUpdate: NoAction)
database database? @relation(fields: [db_id], references: [db_id], onDelete: NoAction, onUpdate: NoAction)
invoice invoice? @relation(fields: [invoice_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
tenants tenants @relation(fields: [tenant_id], references: [tenant_id], onDelete: NoAction, onUpdate: NoAction)
@ -367,3 +378,13 @@ model invoice_lines {
database database? @relation(fields: [db_id], references: [db_id], onDelete: NoAction, onUpdate: NoAction)
invoice invoice? @relation(fields: [invoice_id], references: [id])
}
model sequences {
sequence_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String @db.VarChar(100)
yyyymmdd String @db.Char(8)
value Int @default(0)
updated_at DateTime @default(now()) @db.Timestamptz(6)
@@unique([yyyymmdd, name])
}

View File

@ -1,7 +1,6 @@
import { Request, Response } from "express";
import getTokenAuth from "../lib/getTokenAuth";
import { getUserFromToken } from "../middleware/auth";
import { dbQueryClient } from "../lib/dbQueryClient";
import { get, isEmpty } from "lodash";
import db from "../db";
import { v7 } from "uuid";
@ -9,6 +8,8 @@ import { formatTimestamp } from "../lib/formatTimestamp";
import { formatMoney } from "../lib/bank/mandiri/getInvoiceVirtualAccount";
import callGate from "../lib/gate";
import { getParameter } from "../lib/getParameter";
import { dbQueryClient } from "../lib/dbQueryClient";
import sequence from "../lib/sequence";
export async function inquiry(req: Request, res: Response) {
try {
@ -69,9 +70,212 @@ export async function inquiry(req: Request, res: Response) {
export async function MidsuitPayment(req: Request, res: Response) {
const data = req.body || {};
const timestamp = formatTimestamp();
const token = await getTokenAuth(req);
let token = await getTokenAuth(req);
let user = null as any;
if (!token) {
const authClient = req.header("Authorization-Client") || "";
user = await db.users.findFirst({
where: {
token_access: authClient,
},
include: {
tenants: true,
database: true,
banks: true,
parameters: true,
},
});
} else {
const userToken = await getUserFromToken(token);
const user = get(userToken, "user");
user = get(userToken, "user");
}
if (!user) {
return res.status(401).json({ error: "invalid token" });
}
if (!get(user, "database")) {
return res.status(400).json({ error: "user has no database assigned" });
}
if (get(data, "amount") <= 0) {
return res.status(400).json({ error: "invalid amount" });
}
if (
isEmpty(get(data, "originatorBankCode")) ||
isEmpty(get(data, "beneficiaryBankCode"))
) {
return res.status(400).json({ error: "invalid bank code" });
}
const bankFrom = await db.bank_code.findFirst({
where: { code: String(get(data, "originatorBankCode")) },
});
const bankTo = await db.bank_code.findFirst({
where: { code: String(get(data, "beneficiaryBankCode")) },
});
if (!bankFrom) {
return res
.status(400)
.json({ error: "unsupported originator bank code midsuit" });
}
if (!bankTo) {
return res
.status(400)
.json({ error: "unsupported beneficiary bank code midsuit" });
}
const bankCodeUser = String(get(data, "originatorBankCode"));
if (isEmpty(bankCodeUser)) {
return res.status(400).json({ error: "user has no bank assigned" });
}
const partnerReferenceNo = v7();
const external_id = await sequence("midsuit_payment_external_id");
let requestBody: any = {
partnerReferenceNo: partnerReferenceNo,
amount: {
value: formatMoney(get(data, "amount")),
currency: "IDR",
},
beneficiaryAccountNo: get(data, "beneficiaryAccountNo"),
beneficiaryEmail: get(data, "beneficiaryEmail"),
currency: "IDR",
customerReference: get(data, "documentno"),
// feeType: "BEN", BIAYA ADMIN
remark: get(data, "documentno"),
sourceAccountNo: get(data, "sourceAccountNo"),
transactionDate: timestamp,
originatorInfos: [
{
originatorCustomerNo: get(data, "originatorCustomerNo"),
originatorCustomerName: get(data, "originatorCustomerName"),
originatorBankCode: bankCodeUser,
},
],
};
let isRTKGS = get(data, "amount") >= 100000000;
if (isRTKGS || get(data, "beneficiaryBankCode") !== bankCodeUser) {
requestBody = {
...requestBody,
beneficiaryAccountName: get(data, "beneficiaryAccountName"),
beneficiaryBankCode: get(data, "beneficiaryBankCode"),
beneficiaryCustomerResidence: "1",
beneficiaryCustomerType: get(data, "beneficiaryCustomerType", "1"),
senderCustomerResidence: "1",
senderCustomerType: get(data, "beneficiaryCustomerType", "2"),
};
}
const transaction = await db.transactions.create({
data: {
tenant_id: user.tenant_id,
bank_id: get(bankTo, "bank_id") as any,
bankto_id: get(bankFrom, "bank_id") as any,
external_id: external_id,
partnerReferenceNo: partnerReferenceNo,
amount: get(data, "amount"),
description: `Payment for ${get(data, "documentno")}`,
is_pay: false,
status: "DRAFT",
beneficiaryAccountName: get(data, "beneficiaryAccountName"),
beneficiaryAccountNo: get(data, "beneficiaryAccountNo"),
sourceAccountNo: get(data, "sourceAccountNo"),
originatorCustomerName: get(data, "originatorCustomerName"),
originatorCustomerNo: get(data, "originatorCustomerNo"),
method: isRTKGS
? "RTGS"
: get(data, "beneficiaryBankCode") !== bankCodeUser
? "SKN"
: "Inhouse",
},
});
if (isRTKGS) {
// method RTGS
const result = await callGate({
action: "TransferRTGS",
client: "mandiri",
data: {
clientbank_id: get(user, "clientbank_id"),
requestBody,
external_id: getParameter(user, "external_id"),
channel_id: getParameter(user, "channel_id"),
},
});
await db.transactions.update({
where: { id: transaction.id },
data: {
status: "COMPLETED",
referenceNo: get(result, "referenceNo"),
trxDateTime: get(result, "timestamp")
? new Date(get(result, "timestamp"))
: null,
},
});
return res.status(200).json(result);
} else if (get(data, "beneficiaryBankCode") !== bankCodeUser) {
// method SKN
const result = await callGate({
action: "TransferSKN",
client: "mandiri",
data: {
clientbank_id: get(user, "clientbank_id"),
requestBody,
external_id: external_id,
channel_id: getParameter(user, "channel_id"),
},
});
await db.transactions.update({
where: { id: transaction.id },
data: {
status: "COMPLETED",
referenceNo: get(result, "referenceNo"),
trxDateTime: get(result, "timestamp")
? new Date(get(result, "timestamp"))
: null,
},
});
return res.status(200).json(result);
} else if (get(data, "beneficiaryBankCode") === bankCodeUser) {
// method Inhouse
const result = await callGate({
action: "TransferInhouse",
client: "mandiri",
data: {
clientbank_id: get(user, "clientbank_id"),
requestBody,
external_id: external_id,
channel_id: getParameter(user, "channel_id"),
},
});
await db.transactions.update({
where: { id: transaction.id },
data: {
status: "COMPLETED",
referenceNo: get(result, "referenceNo"),
trxDateTime: get(result, "timestamp")
? new Date(get(result, "timestamp"))
: null,
},
});
return res.status(200).json(result);
}
}
export async function MidsuitPaymentV2(req: Request, res: Response) {
const data = req.body || {};
const timestamp = formatTimestamp();
let token = await getTokenAuth(req);
let user = null as any;
if (!token) {
const authClient = req.header("Authorization-Client") || "";
user = await db.users.findFirst({
where: {
token_access: authClient,
},
include: {
tenants: true,
database: true,
banks: true,
parameters: true,
},
});
} else {
const userToken = await getUserFromToken(token);
user = get(userToken, "user");
}
if (!user) {
return res.status(401).json({ error: "invalid token" });
}
@ -94,6 +298,9 @@ export async function MidsuitPayment(req: Request, res: Response) {
if (get(data, "amount") != get(result, "payamt")) {
return res.status(400).json({ error: "amount mismatch" });
}
if (get(data, "amount") <= 0) {
return res.status(400).json({ error: "invalid amount" });
}
if (isEmpty(get(data, "bank_code"))) {
return res.status(400).json({ error: "invalid bank code" });
}

56
src/lib/sequence.ts Normal file
View File

@ -0,0 +1,56 @@
import db from "../db";
// Generate a daily sequence number in the form YYYYMMDD + zero-padded value.
// This implementation is resilient to concurrent callers by first attempting an
// atomic increment (updateMany) and falling back to create. If create races
// with another process, we catch the unique-constraint error and retry a few
// times. This avoids unsafe find-then-create/update patterns.
export default async function (name: string, padStartLength = 10) {
const yyyymmdd = new Date().toISOString().slice(0, 10).replace(/-/g, "");
// Try a few times to handle races when many processes call concurrently.
const maxAttempts = 5;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// If a row already exists, this updateMany will atomically increment it.
const updateResult = await db.sequences.updateMany({
where: { name, yyyymmdd },
data: { value: { increment: 1 }, updated_at: new Date() },
});
if (updateResult.count && updateResult.count > 0) {
// Read back the new value and return it.
const sequence = await db.sequences.findFirst({
where: { name, yyyymmdd },
});
if (sequence) {
return `${yyyymmdd}${String(sequence.value).padStart(
padStartLength,
"0"
)}`;
}
// If for some reason the row disappeared, loop and retry.
continue;
}
// No existing row — try to create it with initial value 1.
try {
const created = await db.sequences.create({
data: { name, yyyymmdd, value: 1 },
});
return `${yyyymmdd}${String(created.value).padStart(
padStartLength,
"0"
)}`;
} catch (err: any) {
// Prisma unique-constraint error code P2002 indicates another process
// created the row in the meantime; retry the loop. Re-throw unexpected errors.
if (err && (err.code === "P2002" || err.code === "23505")) {
// unique violation, someone else created it — retry
continue;
}
throw err;
}
}
throw new Error("Failed to generate sequence after several attempts");
}

View File

@ -16,6 +16,19 @@ export async function authMiddleware(
res: Response,
next: NextFunction
) {
const authClient = req.header("Authorization-Client") || "";
if (authClient) {
const user = await prisma.users.findFirst({
where: {
token_access: authClient,
},
});
if (!user) {
return res.status(401).json({ error: "invalid client token" });
}
req.user = user;
next();
} else {
const auth = req.header("Authorization") || "";
if (!auth.startsWith("Bearer "))
return res.status(401).json({ error: "missing token" });
@ -44,6 +57,7 @@ export async function authMiddleware(
req.session = session;
next();
}
}
// helper to resolve a user+session from a raw token string
export async function getUserFromToken(token: string) {