feat: Enhance payment processing with additional validations and sequence generation
Deploy Application / deploy (push) Successful in 30s
Details
Deploy Application / deploy (push) Successful in 30s
Details
This commit is contained in:
parent
2f0eae4f7e
commit
d6effb7fd8
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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" });
|
||||
|
|
@ -43,6 +56,7 @@ export async function authMiddleware(
|
|||
req.user = user;
|
||||
req.session = session;
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
// helper to resolve a user+session from a raw token string
|
||||
|
|
|
|||
Loading…
Reference in New Issue