From d6effb7fd863aaad317a498c320330230e7adeec Mon Sep 17 00:00:00 2001 From: faisolavolut Date: Tue, 28 Oct 2025 12:29:02 +0700 Subject: [PATCH] feat: Enhance payment processing with additional validations and sequence generation --- prisma/schema.prisma | 103 +++++++----- src/controllers/transferController.ts | 215 +++++++++++++++++++++++++- src/lib/sequence.ts | 56 +++++++ src/middleware/auth.ts | 64 +++++--- 4 files changed, 368 insertions(+), 70 deletions(-) create mode 100644 src/lib/sequence.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index db9d821..34032ea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,17 +105,18 @@ model auth_logs { } model banks { - bank_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - code String @unique @db.VarChar(50) - name String @db.VarChar(150) - is_active Boolean @default(true) - created_at DateTime @default(now()) @db.Timestamptz(6) - updated_at DateTime @default(now()) @db.Timestamptz(6) - bank_code_id String? @db.Uuid - bank_code bank_code? @relation(fields: [bank_code_id], references: [bank_code_id], onDelete: NoAction, onUpdate: NoAction) - invoice invoice[] - transactions transactions[] - users users[] + bank_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + code String @unique @db.VarChar(50) + name String @db.VarChar(150) + is_active Boolean @default(true) + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + bank_code_id String? @db.Uuid + 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[] } model dashboards { @@ -264,36 +265,46 @@ model rv_openitem { } model transactions { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - user_id String? @db.Uuid - status String @db.VarChar(50) - amount Decimal @db.Decimal(20, 2) - is_pay Boolean @default(false) - c_bpartner_id Decimal? @db.Decimal(10, 0) - created_at DateTime? @default(now()) @db.Timestamptz(6) - updated_at DateTime? @default(now()) @db.Timestamptz(6) - bank_id String @db.Uuid - tenant_id String @db.Uuid - description String? @db.VarChar(255) - channelCode Int? - customerNo String? @db.VarChar(50) - db_id String? @db.Uuid - flagAdvise String? @db.Char(1) - hashedSourceAccountNo String? @db.VarChar(255) - invoice_id String? @db.Uuid - paidBills String? @db.VarChar(10) - partnerServiceId String? @db.VarChar(50) - paymentRequestId String? @db.VarChar(25) - referenceNo String? @db.VarChar(50) - trxDateTime DateTime? @db.Timestamptz(6) - virtualAccountName String? @db.VarChar(255) - virtualAccountNo String? @db.VarChar(50) - banks banks @relation(fields: [bank_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) - users users? @relation(fields: [user_id], references: [user_id]) - transactions_lines transactions_lines[] + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + user_id String? @db.Uuid + status String @db.VarChar(50) + amount Decimal @db.Decimal(20, 2) + is_pay Boolean @default(false) + c_bpartner_id Decimal? @db.Decimal(10, 0) + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + bank_id String @db.Uuid + tenant_id String @db.Uuid + description String? @db.VarChar(255) + channelCode Int? + customerNo String? @db.VarChar(50) + db_id String? @db.Uuid + flagAdvise String? @db.Char(1) + hashedSourceAccountNo String? @db.VarChar(255) + invoice_id String? @db.Uuid + paidBills String? @db.VarChar(10) + partnerServiceId String? @db.VarChar(50) + paymentRequestId String? @db.VarChar(25) + referenceNo String? @db.VarChar(50) + 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) + users users? @relation(fields: [user_id], references: [user_id]) + transactions_lines transactions_lines[] } model transactions_lines { @@ -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]) +} diff --git a/src/controllers/transferController.ts b/src/controllers/transferController.ts index 34dfa77..6f6e142 100644 --- a/src/controllers/transferController.ts +++ b/src/controllers/transferController.ts @@ -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); - const userToken = await getUserFromToken(token); - const user = get(userToken, "user"); + 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" }); + } + 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" }); } diff --git a/src/lib/sequence.ts b/src/lib/sequence.ts new file mode 100644 index 0000000..db0ff2b --- /dev/null +++ b/src/lib/sequence.ts @@ -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"); +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index e4e1681..e2be14a 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -16,33 +16,47 @@ export async function authMiddleware( res: Response, next: NextFunction ) { - const auth = req.header("Authorization") || ""; - if (!auth.startsWith("Bearer ")) - return res.status(401).json({ error: "missing token" }); - const token = auth.slice("Bearer ".length).trim(); - const tokenHash = hashToken(token); - - const session = await prisma.sessions.findFirst({ - where: { token_hash: tokenHash, is_revoked: false }, - }); - if (!session) return res.status(401).json({ error: "invalid token" }); - - // check expiration if set - if (session.expires_at) { - const exp = new Date(session.expires_at); - if (exp.getTime() < Date.now()) { - return res.status(401).json({ error: "token_expired" }); + 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" }); + const token = auth.slice("Bearer ".length).trim(); + const tokenHash = hashToken(token); + + const session = await prisma.sessions.findFirst({ + where: { token_hash: tokenHash, is_revoked: false }, + }); + if (!session) return res.status(401).json({ error: "invalid token" }); + + // check expiration if set + if (session.expires_at) { + const exp = new Date(session.expires_at); + if (exp.getTime() < Date.now()) { + return res.status(401).json({ error: "token_expired" }); + } + } + + const user = await prisma.users.findUnique({ + where: { user_id: session.user_id }, + }); + if (!user) return res.status(401).json({ error: "invalid session user" }); + + req.user = user; + req.session = session; + next(); } - - const user = await prisma.users.findUnique({ - where: { user_id: session.user_id }, - }); - if (!user) return res.status(401).json({ error: "invalid session user" }); - - req.user = user; - req.session = session; - next(); } // helper to resolve a user+session from a raw token string