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)
|
bank_code bank_code? @relation(fields: [bank_code_id], references: [bank_code_id], onDelete: NoAction, onUpdate: NoAction)
|
||||||
invoice invoice[]
|
invoice invoice[]
|
||||||
transactions transactions[]
|
transactions transactions[]
|
||||||
|
transactions_to transactions[] @relation("transactions_bankto_idTobanks")
|
||||||
users users[]
|
users users[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,7 +289,17 @@ model transactions {
|
||||||
trxDateTime DateTime? @db.Timestamptz(6)
|
trxDateTime DateTime? @db.Timestamptz(6)
|
||||||
virtualAccountName String? @db.VarChar(255)
|
virtualAccountName String? @db.VarChar(255)
|
||||||
virtualAccountNo String? @db.VarChar(50)
|
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 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)
|
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)
|
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)
|
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)
|
database database? @relation(fields: [db_id], references: [db_id], onDelete: NoAction, onUpdate: NoAction)
|
||||||
invoice invoice? @relation(fields: [invoice_id], references: [id])
|
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 { Request, Response } from "express";
|
||||||
import getTokenAuth from "../lib/getTokenAuth";
|
import getTokenAuth from "../lib/getTokenAuth";
|
||||||
import { getUserFromToken } from "../middleware/auth";
|
import { getUserFromToken } from "../middleware/auth";
|
||||||
import { dbQueryClient } from "../lib/dbQueryClient";
|
|
||||||
import { get, isEmpty } from "lodash";
|
import { get, isEmpty } from "lodash";
|
||||||
import db from "../db";
|
import db from "../db";
|
||||||
import { v7 } from "uuid";
|
import { v7 } from "uuid";
|
||||||
|
|
@ -9,6 +8,8 @@ import { formatTimestamp } from "../lib/formatTimestamp";
|
||||||
import { formatMoney } from "../lib/bank/mandiri/getInvoiceVirtualAccount";
|
import { formatMoney } from "../lib/bank/mandiri/getInvoiceVirtualAccount";
|
||||||
import callGate from "../lib/gate";
|
import callGate from "../lib/gate";
|
||||||
import { getParameter } from "../lib/getParameter";
|
import { getParameter } from "../lib/getParameter";
|
||||||
|
import { dbQueryClient } from "../lib/dbQueryClient";
|
||||||
|
import sequence from "../lib/sequence";
|
||||||
|
|
||||||
export async function inquiry(req: Request, res: Response) {
|
export async function inquiry(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -69,9 +70,212 @@ export async function inquiry(req: Request, res: Response) {
|
||||||
export async function MidsuitPayment(req: Request, res: Response) {
|
export async function MidsuitPayment(req: Request, res: Response) {
|
||||||
const data = req.body || {};
|
const data = req.body || {};
|
||||||
const timestamp = formatTimestamp();
|
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 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) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: "invalid token" });
|
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")) {
|
if (get(data, "amount") != get(result, "payamt")) {
|
||||||
return res.status(400).json({ error: "amount mismatch" });
|
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"))) {
|
if (isEmpty(get(data, "bank_code"))) {
|
||||||
return res.status(400).json({ error: "invalid 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,
|
res: Response,
|
||||||
next: NextFunction
|
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") || "";
|
const auth = req.header("Authorization") || "";
|
||||||
if (!auth.startsWith("Bearer "))
|
if (!auth.startsWith("Bearer "))
|
||||||
return res.status(401).json({ error: "missing token" });
|
return res.status(401).json({ error: "missing token" });
|
||||||
|
|
@ -44,6 +57,7 @@ export async function authMiddleware(
|
||||||
req.session = session;
|
req.session = session;
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// helper to resolve a user+session from a raw token string
|
// helper to resolve a user+session from a raw token string
|
||||||
export async function getUserFromToken(token: string) {
|
export async function getUserFromToken(token: string) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue