feat: Implement Mandiri payment processing and signature verification

- Added postPayment.ts for handling Mandiri payment requests.
- Introduced stringifyMandiri.ts for stable stringification and hashing of request data.
- Created verifySignature.ts to validate signatures for transactions and general requests.
- Implemented dbPools.ts and dbQueryClient.ts for efficient database connection pooling and querying.
- Added formatTimestamp.ts for consistent timestamp formatting.
- Developed gate.ts and gateClient.ts for dynamic handler invocation based on client actions.
- Introduced getCBPartnerVA.ts for fetching business partner details from the database.
- Re-exported lodash as a shorthand for easier usage across the project.
- Created signature.ts for HMAC and RSA signature verification utilities.
- Added middleware for authentication and virtual account verification (vaAuth.ts).
- Set up Prisma client in prisma.ts for database interactions.
- Established routes in index.ts for various API endpoints including authentication, file uploads, and Mandiri payment processing.
- Defined custom request and response types in index.ts for better type safety.
- Configured TypeScript settings in tsconfig.json for improved development experience.
- Added various binary files for report templates and certificates in uploads directory.
This commit is contained in:
faisolavolut 2025-10-21 12:03:13 +07:00
commit 7feaeff415
46 changed files with 9694 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build output
dist/
build/
# Environment files
.env
.env.*
# Logs
logs/
*.log
npm-debug.log*
# OS files
.DS_Store
# Test coverage
coverage/
# IDEs and editors
.vscode/
.idea/
*.sublime-workspace
# Docker
.dockerignore
# Prisma
.prisma/
# Misc
tmp/
*.tmp
*.swp

30
Dockerfile Normal file
View File

@ -0,0 +1,30 @@
### Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# install dependencies
COPY package.json package-lock.json* ./
RUN npm ci
# copy sources and generate prisma client, then build
COPY . .
RUN npx prisma generate
RUN npm run build
### Run stage
FROM node:20-alpine AS runner
WORKDIR /app
# only install production deps
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
# copy compiled app and prisma client artifacts from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/app.js"]

12
docker-compose.yml Normal file
View File

@ -0,0 +1,12 @@
version: "3.8"
services:
app:
build: .
environment:
# public database; replace if you want another connection string
DATABASE_URL: "postgresql://postgres:gEJIfovgvDAroHhqRiKhYVvrkn2OqXfF3tw8xJmnvw7JhrZgN24pTD9iWMIUIUL6@prasi.avolut.com:8741/kig-bank"
NODE_ENV: production
ports:
- "5998:3000"
volumes:
- ./uploads:/app/uploads

132
migrate/table.sql Normal file
View File

@ -0,0 +1,132 @@
-- ==========================================
-- 1⃣ Master Tenant
-- ==========================================
CREATE TABLE tenants (
tenant_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(150) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ==========================================
-- 2⃣ Master Bank
-- ==========================================
CREATE TABLE banks (
bank_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(150) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ==========================================
-- 3⃣ Kredensial users
-- ==========================================
CREATE TABLE users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(tenant_id),
bank_id UUID NOT NULL REFERENCES banks(bank_id),
partnerServiceId TEXT,
client_id TEXT,
client_secret TEXT,
api_key TEXT,
api_secret TEXT,
username TEXT,
password TEXT,
token_access TEXT,
token_refresh TEXT,
token_expiry TIMESTAMPTZ,
extra_json JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (tenant_id),
);
-- ==========================================
-- 3⃣ Kredensial Master Access
-- ==========================================
CREATE TABLE access (
access_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
access_type VARCHAR(50) NOT NULL,
access_value TEXT NOT NULL,
is_created BOOLEAN NOT NULL DEFAULT FALSE,
is_updated BOOLEAN NOT NULL DEFAULT FALSE,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, access_type)
);
-- ==========================================
-- 3⃣ Kredensial User Access
-- ==========================================
CREATE TABLE user_access (
access_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
access_type VARCHAR(50) NOT NULL,
access_value TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, access_type)
);
-- ==========================================
-- 4⃣ Parameter
-- ==========================================
CREATE TABLE parameters (
parameter_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
param_key VARCHAR(100) NOT NULL,
param_value TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, param_key)
);
-- ==========================================
-- 5⃣ Log Activity
-- ==========================================
CREATE TABLE activity_logs (
log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
action VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL,
message TEXT,
extra_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ==========================================
-- 6⃣ Dashboard
-- ==========================================
CREATE TABLE dashboards (
dashboard_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
title VARCHAR(150) NOT NULL,
value DECIMAL(20,2) NOT NULL,
date TIMESTAMPTZ NOT NULL DEFAULT now(),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ==========================================
-- 7⃣ Auth Logs
-- ==========================================
CREATE TABLE auth_logs (
log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
action VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL,
message TEXT,
extra_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

6486
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "next-express-ts-app",
"version": "1.0.0",
"main": "src/app.ts",
"scripts": {
"start": "ts-node src/app.ts",
"dev": "ts-node-dev src/app.ts",
"build": "tsc",
"test": "jest"
},
"dependencies": {
"@prisma/client": "^6.17.1",
"axios": "^1.12.2",
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"decimal.js": "^10.6.0",
"express": "^4.17.1",
"lodash": "^4.17.21",
"multer": "^2.0.2",
"pg": "^8.16.3"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.11",
"@types/jest": "^27.0.2",
"@types/lodash": "^4.17.20",
"@types/multer": "^2.0.0",
"@types/pg": "^8.15.5",
"jest": "^27.0.6",
"prisma": "^6.17.1",
"ts-node": "^10.9.1",
"ts-node-dev": "^2.0.0",
"typescript": "^5.2.2"
}
}

369
prisma/schema.prisma Normal file
View File

@ -0,0 +1,369 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model users {
user_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tenant_id String @unique @db.Uuid
bank_id String @db.Uuid
partnerserviceid String?
client_id String?
client_secret String?
api_key String?
api_secret String?
username String?
password String?
token_access String?
token_refresh String?
token_expiry DateTime? @db.Timestamptz(6)
extra_json Json?
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
db_id String? @db.Uuid
last_login DateTime? @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
pharaphrase String?
private_key String?
private_key_file String?
public_key String?
public_key_file String?
clientbank_id String @unique
access access[]
activity_logs activity_logs[]
auth_logs auth_logs[]
dashboards dashboards[]
invoice invoice[]
parameters parameters[]
sessions sessions[]
transactions transactions[]
user_access user_access[]
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)
tenants tenants @relation(fields: [tenant_id], references: [tenant_id], onDelete: NoAction, onUpdate: NoAction)
}
model database {
db_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String @unique @db.VarChar(100)
host String @db.VarChar(100)
port Int
username String @db.VarChar(100)
password String @db.VarChar(100)
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
db_name String @db.VarChar(100)
invoice invoice[]
invoice_lines invoice_lines[]
transactions_lines transactions_lines[]
users users[]
transactions transactions[]
}
model access {
access_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
user_id String @db.Uuid
access_type String @db.VarChar(50)
access_value String
is_created Boolean @default(false)
is_updated Boolean @default(false)
is_deleted Boolean @default(false)
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
read_only Boolean @default(true)
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
@@unique([user_id, access_type])
}
model activity_logs {
log_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
user_id String @db.Uuid
action String @db.VarChar(100)
status String @db.VarChar(50)
message String?
extra_json Json?
created_at DateTime @default(now()) @db.Timestamptz(6)
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
}
model auth_logs {
log_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
user_id String @db.Uuid
action String @db.VarChar(100)
status String @db.VarChar(50)
message String?
extra_json Json?
created_at DateTime @default(now()) @db.Timestamptz(6)
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
}
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[]
}
model dashboards {
dashboard_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
user_id String @db.Uuid
title String @db.VarChar(150)
value Decimal @db.Decimal(20, 2)
date DateTime @default(now()) @db.Timestamptz(6)
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
}
model parameters {
parameter_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
user_id String @db.Uuid
param_key String @db.VarChar(100)
param_value String
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
@@unique([user_id, param_key])
}
model tenants {
tenant_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)
invoice invoice[]
transactions transactions[]
users users?
}
model user_access {
access_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
user_id String @db.Uuid
access_type String @db.VarChar(50)
access_value String
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
@@unique([user_id, access_type])
}
model sessions {
session_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
user_id String @db.Uuid
token_hash String? @db.VarChar(128)
signature String? @db.Text
expires_at DateTime? @db.Timestamptz(6)
is_revoked Boolean @default(false)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
@@index([user_id])
@@index([token_hash])
@@index([signature])
}
/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client.
model rv_openitem {
ad_org_id Decimal? @db.Decimal(10, 0)
ad_client_id Decimal? @db.Decimal(10, 0)
documentno String? @db.VarChar(30)
c_invoice_id Decimal? @db.Decimal(10, 0)
c_order_id Decimal? @db.Decimal(10, 0)
c_bpartner_id Decimal? @db.Decimal(10, 0)
issotrx String? @db.Char(1)
dateinvoiced DateTime? @db.Timestamp(6)
dateacct DateTime? @db.Timestamp(6)
netdays Decimal? @db.Decimal
duedate DateTime? @db.Timestamptz(6)
daysdue Int?
discountdate DateTime? @db.Timestamp(6)
discountamt Decimal? @db.Decimal
grandtotal Decimal? @db.Decimal
paidamt Decimal? @db.Decimal
openamt Decimal? @db.Decimal
c_currency_id Decimal? @db.Decimal(10, 0)
c_conversiontype_id Decimal? @db.Decimal(10, 0)
c_paymentterm_id Decimal? @db.Decimal(10, 0)
ispayschedulevalid String? @db.Char(1)
c_invoicepayschedule_id Decimal? @db.Decimal
invoicecollectiontype String? @db.Char(1)
c_campaign_id Decimal? @db.Decimal(10, 0)
c_project_id Decimal? @db.Decimal(10, 0)
c_activity_id Decimal? @db.Decimal(10, 0)
ad_orgtrx_id Decimal? @db.Decimal(10, 0)
ad_user_id Decimal? @db.Decimal(10, 0)
c_bpartner_location_id Decimal? @db.Decimal(10, 0)
c_charge_id Decimal? @db.Decimal(10, 0)
c_doctype_id Decimal? @db.Decimal(10, 0)
c_doctypetarget_id Decimal? @db.Decimal(10, 0)
c_dunninglevel_id Decimal? @db.Decimal(10, 0)
chargeamt Decimal? @db.Decimal
c_payment_id Decimal? @db.Decimal(10, 0)
created DateTime? @db.Timestamp(6)
createdby Decimal? @db.Decimal(10, 0)
dateordered DateTime? @db.Timestamp(6)
dateprinted DateTime? @db.Timestamp(6)
description String? @db.VarChar(255)
docaction String? @db.Char(2)
docstatus String? @db.Char(2)
dunninggrace DateTime? @db.Date
generateto String? @db.Char(1)
isactive String? @db.Char(1)
isapproved String? @db.Char(1)
isdiscountprinted String? @db.Char(1)
isindispute String? @db.Char(1)
ispaid String? @db.Char(1)
isprinted String? @db.Char(1)
isselfservice String? @db.Char(1)
istaxincluded String? @db.Char(1)
istransferred String? @db.Char(1)
m_pricelist_id Decimal? @db.Decimal(10, 0)
m_rma_id Decimal? @db.Decimal(10, 0)
paymentrule String? @db.Char(1)
poreference String? @db.VarChar(50)
posted String? @db.Char(1)
processedon Decimal? @db.Decimal
processing String? @db.Char(1)
ref_invoice_id Decimal? @db.Decimal(10, 0)
reversal_id Decimal? @db.Decimal(10, 0)
salesrep_id Decimal? @db.Decimal(10, 0)
sendemail String? @db.Char(1)
totallines Decimal? @db.Decimal
updated DateTime? @db.Timestamp(6)
updatedby Decimal? @db.Decimal(10, 0)
user1_id Decimal? @db.Decimal(10, 0)
user2_id Decimal? @db.Decimal(10, 0)
kodebp String? @db.VarChar(40)
noorder String? @db.VarChar(30)
orderdesc String? @db.VarChar(255)
db_id String? @db.Uuid
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
is_pay String? @default("N") @db.Char(1)
}
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)
invoice_id String? @db.Uuid
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)
partnerServiceId String? @db.VarChar(50)
customerNo String? @db.VarChar(50)
virtualAccountNo String? @db.VarChar(50)
virtualAccountName String? @db.VarChar(255)
trxDateTime DateTime? @db.Timestamptz(6)
channelCode Int? @db.Integer
referenceNo String? @db.VarChar(50)
hashedSourceAccountNo String? @db.VarChar(255)
paymentRequestId String? @db.VarChar(25)
paidBills String? @db.VarChar(10)
flagAdvise String? @db.Char(1)
db_id String? @db.Uuid
invoice invoice? @relation(fields: [invoice_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
database database? @relation(fields: [db_id], references: [db_id], onDelete: NoAction, onUpdate: NoAction)
banks banks @relation(fields: [bank_id], references: [bank_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 {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
transaction_id String @db.Uuid
description String? @db.VarChar(255)
amount Decimal? @db.Decimal(20, 2)
db_id String? @db.Uuid
c_invoice_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
line_no Int @default(1)
database database? @relation(fields: [db_id], references: [db_id], onDelete: NoAction, onUpdate: NoAction)
transactions transactions @relation(fields: [transaction_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
}
model bank_code {
bank_code_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
code String @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)
banks banks[]
}
model invoice {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
user_id String? @db.Uuid
db_id String? @db.Uuid
inquiryRequestId String? @db.VarChar(25)
date DateTime? @db.Timestamptz(6)
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)
partnerServiceId String? @db.VarChar(50)
customerNo String? @db.VarChar(50)
virtualAccountNo String? @db.VarChar(50)
trxDateInit DateTime? @db.Timestamptz(6)
channelCode Int?
bank_id String @db.Uuid
tenant_id String @db.Uuid
description String? @db.VarChar(255)
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)
tenants tenants @relation(fields: [tenant_id], references: [tenant_id], onDelete: NoAction, onUpdate: NoAction)
users users? @relation(fields: [user_id], references: [user_id])
invoice_lines invoice_lines[]
transactions transactions[]
}
model invoice_lines {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
description String? @db.VarChar(255)
amount Decimal? @db.Decimal(20, 2)
c_invoice_id Decimal? @db.Decimal(10, 0)
invoice_id String? @db.Uuid
db_id String? @db.Uuid
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
line_no Int @default(1)
billcode String? @db.VarChar(10)
billname String? @db.VarChar(50)
database database? @relation(fields: [db_id], references: [db_id], onDelete: NoAction, onUpdate: NoAction)
invoice invoice? @relation(fields: [invoice_id], references: [id])
}

BIN
public/mandiri/MIDSUIT.jks Normal file

Binary file not shown.

BIN
public/mandiri/midsuit.cer Normal file

Binary file not shown.

View File

@ -0,0 +1,50 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIE9jAoBgoqhkiG9w0BDAEDMBoEFBoweltnH/cXGWgpnf48R23zh+1DAgIEAASC
BMhEU7JLa3I76Vg5p9EpJ0eVyRunPOYN9y44AB0K5wSG1ZHTakHhrykR0mPMcxAc
MYJTLkNOMlXVm88w31f32pI6MmUDyUNAjEaVm4DuR39a3v1p6I13yfyYFvKelrFJ
FcitLBp2X4SYjfuYnADVnqg19Lg5IuPTPIHWtHKACrCiYr7+R7GQ1NLU4l5XeoYM
tavXINquSLni394wsuMFjaUzXtG74uKJuMmcVw8IfN7oM2/WYGLAKPG03bIBMnzj
Ky6UFZbXaJiDKFuNMl6DDe9e2m4HJP3m2RIBvfdjRutE8JeUoLXS/MDP3LNAWAT2
Ibne+S1+Ey5BZdUHpno1YesCttul/t9FUodoN/U3k5OFNqwZ1QXNqLiJK47Or8qr
allBaUaJ/03qggIx30NiAB5H3bxVtbkgFe8w6C2QMUWGjqJpHWKCNvgtNVBVDqLx
olxwRLj6GlQeAOPU7++0/pAvMCRpmqSzjWIBiYsrqsltO1epBAYHGyoybl1DgXfE
7rKvBrw9X9rBGxbxuXSTaCesd2SG1K3Q8JuoeXGU1rh9yeNLZYF2j/+O5F7ncSxU
8BGe+9B5em04yyXYlov0ohWxam6j0aMCSrcoaHdB3wB/fcSzuWo4H8l5PEy49qCX
348f6iTX10zRVVifHB82bxFWmjQJou3FwJbOF9OSpIEZ94TQlgAivRvh/iRJtdQb
xDt9JO19ol5oppED+xr7wublGQeKYauwxdPPevYJboP7nUMXwAet7735sYpE1EWx
M1ZCshSYTQ3El0nUcg4dmmL0UIpeTkzlNVN7ekWXy06qiOCRxZbrcqvsyAWtuVco
vH3bldjTsmk6sa+Y3yNdHL++4qmq6kiPKgDSLUCAuXJwE3RnFTnQUwqCa6hdmVcr
BydwDaWHNjSdqyOX9N5bd72ypjOksdSapOxZ5mMAWKFIxGWIyQaeuzYbj6uPowHk
zoHEiV92I9HD5icgOtoc+Axc5VIsvUGRMxHh26sSewN58uOyxQzVmTx63FDu+qY1
R8t6JPzbZbDiKuZn/l2RSkpCx1ykkQ161HKikpieJaXuypOaiurxy1VLuDCnLaWj
OkM1rs2I1PYN6v2SAbS7rpL+CkFrCBR/cvD/2aYMafc9ntmuRQGgxC82Lw32htmE
M49zlCnTh2VJeH3g5MAEvzYJn6m9X+2kLiNFJN+diD3Xgl41mP5Bpa8K78Wsilo6
4VTTZA2UE+JMp3lhZN4kTUs7IK7Ew6ofj4L0p3veKUOyDZO/3NAZ2teGV7SU8VxF
Zw74TGQbc5ECkBlDnbPM+5RgSMkPn/7+En8YPLpQwdXizWSKJ6vgHER54BkG87UR
RRmcLvbep5nkywKwZRpT4mrwEWbfOvUB8418Uqzp/UQQa8SAFx9fkeDcQnSpJCI+
Em8ASE+MoQ49RI8hip4qseAjto/EUXcQKCNsLUp+TKmKc/F0Esgz84hIDW3Q7k6/
GtRNVkNdGrKWJWS9XKjrAtqxOmKfMd0bEdXTi1emKurExWlS8Ypmif3N5Dg8ttGn
uy+VdS7XEIi/Mu3nFezeUjB6mOZgwa27Kwz4hck761Ax0n9nTTXWQzaxqWdANGFp
GfQKu40kf+UKsGnBt6VvlSsxtjGVpNhtKGw=
-----END ENCRYPTED PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDdjCCAl6gAwIBAgIUcYZImdyq8uwwZRR/fDUBme4FfGIwDQYJKoZIhvcNAQEL
BQAwdTELMAkGA1UEBhMCSUQxETAPBgNVBAgMCFN1cmFiYXlhMREwDwYDVQQHDAhT
dXJhYmF5YTEPMA0GA1UECgwGQXZvbHV0MR0wGwYDVQQLDBRQYXltZW5kIGFuZCBE
ZWxpdmVyeTEQMA4GA1UEAwwHTUlEU1VJVDAeFw0yNTEwMTYxNzE2MDBaFw0yNjEw
MTYxNzE2MDBaMHUxCzAJBgNVBAYTAklEMREwDwYDVQQIDAhTdXJhYmF5YTERMA8G
A1UEBwwIU3VyYWJheWExDzANBgNVBAoMBkF2b2x1dDEdMBsGA1UECwwUUGF5bWVu
ZCBhbmQgRGVsaXZlcnkxEDAOBgNVBAMMB01JRFNVSVQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCCPS9oByk00FV3adzcjFtES0vwOqL2/4c5QsA8u1l6
hdBsqxDtKKiVBApiOfk4BGdZPiawQ3/uKOJ4pBaklq4N+U7X1E8SJ08UcSes8HKE
dSShMpWy3VSR/qOPu/6uVkcTdTXsnZpokoegfG0ALM3YIZDi3uwNx3n3yb3JABM9
FqFxmAcQvI9Wk6qXL9I4N2rJEKFWtB0FegxYRfZX6Cz5Uvcc3D5HV/XHe+x7RBnX
c22XB21PSdzIYlTcb7EG1CSgJV7Qqs2gIMJErv3uW/VDMGS6vedktDuJ1uncti/I
ncUbIUhqCmOnq+g3LE6b9VwBNPVGt4TgkisYdof8YenXAgMBAAEwDQYJKoZIhvcN
AQELBQADggEBAAybKW2ERAz5n9AkP3qu1LiP6Sv76B/GEhL067FrTLwSOICBvyax
ZhAdcLimYho2qnjR2yi9UD9QEcDTbS/QvRpBlnsDUP+UuT+jCZTshYe7gPRGfrh8
k6d1gcZ1cTwOIru4+NcCiqSuQh/3oaI7+cBVqoLhuMhUOLXdb0dWzEBjqOt94oc6
RNRXZ+YCVewU7bZWhnXHHX/EB08ob3rXY4g56m9fhBUwMTyHc2077Z0iDfeIS/U6
uBE8qUIyVpPNid3IMQ+tojHEXuBqqlL7zMVs+ldqhXrnlt65C3/ytPfOphb0kdVv
E06hTtigs1SsIkh0M0gqjihzRpv/O0qQC14=
-----END CERTIFICATE-----

38
src/app.ts Normal file
View File

@ -0,0 +1,38 @@
import express from "express";
import cors from "cors";
import { setRoutes } from "./routes/index";
import prisma from "./prisma";
import path from "path";
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
// Allow requests from frontend dev servers on ports 3001 and 3000
app.use(cors({ origin: ["http://localhost:3001", "http://localhost:3000"] }));
app.use(
express.json({
verify: (req: any, _res, buf: Buffer) => {
req.rawBody = buf;
},
})
);
app.use(express.urlencoded({ extended: true }));
// Set up routes
app.use(setRoutes());
app.use("/uploads", express.static(path.join(__dirname, "../uploads")));
// Health check that verifies DB connection
app.get("/health", async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`;
return res.status(200).json({ status: "ok" });
} catch (err) {
console.error("DB health check failed", err);
return res.status(500).json({ status: "error" });
}
});
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});

View File

@ -0,0 +1,150 @@
import { Request, Response } from "express";
import prisma from "../prisma";
import { compare } from "bcryptjs";
import { hashToken } from "../middleware/auth";
import crypto from "crypto";
export class AuthController {
async login(req: Request, res: Response) {
const { username, password } = req.body;
if (!username || !password)
return res.status(400).json({ error: "username and password required" });
const user = await prisma.users.findFirst({ where: { username } });
if (!user) return res.status(401).json({ error: "invalid credentials" });
// If passwords are stored hashed, compare. If stored plaintext (not recommended), compare directly.
const passwordMatches = user.password
? await compare(password, user.password)
: password === user.password;
if (!passwordMatches)
return res.status(401).json({ error: "invalid credentials" });
// Simple token (for demo). In production, issue JWT or opaque token and store session.
// create opaque token
const token = crypto.randomBytes(48).toString("hex");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); // 30 days
await prisma.sessions.create({
data: {
user_id: user.user_id,
token_hash: tokenHash,
expires_at: expiresAt,
},
});
await prisma.auth_logs.create({
data: {
user_id: user.user_id,
action: "login",
status: "success",
message: "user logged in",
extra_json: { ip: req.ip },
},
});
return res.json({ token, expiresAt });
}
async logout(req: Request, res: Response) {
const auth = req.header("Authorization") || "";
if (!auth.startsWith("Bearer "))
return res.status(400).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(400).json({ error: "invalid token" });
await prisma.sessions.update({
where: { session_id: session.session_id },
data: { is_revoked: true },
});
await prisma.auth_logs.create({
data: {
user_id: session.user_id,
action: "logout",
status: "success",
message: "user logged out",
},
});
return res.json({ ok: true });
}
async me(req: Request, res: Response) {
// expects authMiddleware to set req.user
const user = (req as any).user;
if (!user) return res.status(401).json({ error: "not authenticated" });
// hide password
const { password, ...rest } = user;
return res.json({ user: rest });
}
// Create a superadmin user without requiring prior authentication.
// This endpoint should be used only once to bootstrap the system.
async createSuperAdmin(req: Request, res: Response) {
const { tenantCode, tenantName, bankCode, bankName, username, password } =
req.body;
if (
!tenantCode ||
!tenantName ||
!bankCode ||
!bankName ||
!username ||
!password
) {
return res.status(400).json({
error:
"tenantCode, tenantName, bankCode, bankName, username and password are required",
});
}
// create or find tenant
const tenant = await prisma.tenants.upsert({
where: { code: tenantCode },
update: { name: tenantName },
create: { code: tenantCode, name: tenantName },
});
const bank = await prisma.banks.upsert({
where: { code: bankCode },
update: { name: bankName },
create: { code: bankCode, name: bankName },
});
// Hash password with bcryptjs
const { hash } = await import("bcryptjs");
const hashed = await hash(password, 10);
const user = await prisma.users.create({
data: {
tenant_id: tenant.tenant_id,
bank_id: bank.bank_id,
username,
clientbank_id: "superadmin-" + bankCode,
password: hashed,
is_active: true,
extra_json: { role: "superadmin" },
},
});
await prisma.activity_logs.create({
data: {
user_id: user.user_id,
action: "create_superadmin",
status: "success",
message: "superadmin created",
extra_json: { tenant: tenant.code, bank: bank.code },
},
});
return res.status(201).json({ user_id: user.user_id });
}
}

View File

@ -0,0 +1,163 @@
import { Request, Response } from "express";
import prisma from "../prisma";
import { hash } from "bcryptjs";
type DbRequest = {
table: string;
action: string;
data?: any;
};
export class DbController {
async handle(req: Request, res: Response) {
const body: DbRequest = req.body;
if (!body || !body.table || !body.action)
return res.status(400).json({ error: "table and action required" });
const model = (prisma as any)[body.table];
if (!model)
return res.status(400).json({ error: `unknown table ${body.table}` });
// Only allow safe actions
const allowed = [
"findFirst",
"findMany",
"findUnique",
"create",
"update",
"delete",
"upsert",
"count",
];
if (!allowed.includes(body.action))
return res.status(400).json({ error: "action not allowed" });
// Tables that require access checks
const protectedTables = new Set([
"tenants",
"banks",
"users",
"access",
"dashboards",
"user_access",
"parameters",
"database",
]);
// remove false if
// If the request targets a protected table, enforce per-user access control.
// if (false && protectedTables.has(body.table)) {
// // require authenticated user
// const authUser = (req as any).user;
// if (!authUser)
// return res
// .status(401)
// .json({ error: "authentication required for this table" });
// // find explicit access entries (primary: access table which contains read_only flag)
// const accessEntry = await prisma.access.findFirst({
// where: {
// user_id: authUser.user_id,
// access_type: body.table,
// is_active: true,
// },
// });
// const userAccessEntry = await prisma.user_access.findFirst({
// where: {
// user_id: authUser.user_id,
// access_type: body.table,
// is_active: true,
// },
// });
// if (!accessEntry && !userAccessEntry) {
// return res.status(403).json({ error: "no access to this resource" });
// }
// const readOnly = accessEntry ? !!accessEntry.read_only : false;
// // Determine if action is a CUD operation
// const isCud = ["create", "update", "delete", "upsert"].includes(
// body.action
// );
// if (readOnly && isCud) {
// return res
// .status(403)
// .json({ error: "insufficient permissions: read-only" });
// }
// }
try {
// Special handling for creating users: hash password and ensure username unique
if (body.table === "users" && body.action === "create") {
const payload = body.data?.data || body.data || {};
if (!payload.username || !payload.password) {
return res.status(400).json({
error: "username and password are required for users.create",
});
}
// check unique username
const existing = await prisma.users.findFirst({
where: { username: payload.username },
});
if (existing)
return res.status(409).json({ error: "username already exists" });
const hashed = await hash(payload.password, 10);
const createData = {
...payload,
password: hashed,
};
// Ensure payload is nested under data if using Prisma create syntax
const createArg = { data: createData };
const result = await model.create(createArg);
return res.status(201).json({ result });
}
// Special handling for updating users: hash password and check username uniqueness
if (body.table === "users" && body.action === "update") {
// Expecting Prisma update signature: { where: {...}, data: {...} }
const where = body.data?.where;
const data = body.data?.data || body.data;
if (!where || !data)
return res
.status(400)
.json({ error: "where and data are required for users.update" });
// If username is being changed, ensure it's not taken by another user
if (data.username) {
const existing = await prisma.users.findFirst({
where: { username: data.username },
});
if (
existing &&
existing.user_id !== where.user_id &&
existing.user_id !== where.id &&
existing.user_id !== where.userId
) {
return res.status(409).json({ error: "username already exists" });
}
}
// If password is provided, hash it
if (data.password) {
const hashed = await hash(data.password, 10);
data.password = hashed;
}
const result = await model.update({ where, data });
return res.json({ result });
}
const result = await model[body.action](body.data || {});
return res.json({ result });
} catch (err) {
console.error("DB dispatch error", err);
return res.status(500).json({
error: "internal error",
detail: err instanceof Error ? err.message : String(err),
});
}
}
}

20
src/controllers/index.ts Normal file
View File

@ -0,0 +1,20 @@
import { Request, Response } from "express";
import { AuthController } from "./authController";
import { DbController } from "./dbController";
import * as transferController from "./transferController";
import * as signController from "./signController";
import * as paymentVAController from "./paymentVAController";
export class IndexController {
public getIndex(req: Request, res: Response): void {
res.send("Welcome to the Express and Next.js TypeScript application!");
}
}
export {
paymentVAController,
AuthController,
DbController,
transferController,
signController,
};

View File

@ -0,0 +1,59 @@
import { Request, Response } from "express";
import callGateFromReq from "../lib/gateClient";
import { get } from "lodash";
export async function payment(req: Request, res: Response) {
try {
const { version } = req.params;
if (version === "v1") {
const body = req.body || {};
const requiredBody = [
"partnerServiceId",
"customerNo",
"virtualAccountNo",
"virtualAccountName",
"trxDateTime",
"channelCode",
"referenceNo",
"hashedSourceAccountNo",
"paymentRequestId",
"paidBills",
"flagAdvise",
];
for (const f of requiredBody) {
if (body[f] === undefined || body[f] === null) {
return res.status(400).json({ error: `missing body field ${f}` });
}
}
try {
const result = await callGateFromReq(req, {
client: "mandiri",
action: "paymentInvoiceVirtualAccount",
data: { ...body },
});
return res.status(200).json({
responseCode: "2002500",
responseMessage: "Successful",
virtualAccountData: result,
});
} catch (ex) {
const message: any = get(ex, "message", "internal_error");
if (message === "Paid amount does not match invoice amount") {
return res
.status(404)
.json({ responseCode: "4042513", responseMessage: message });
} else {
return res
.status(500)
.json({ responseCode: "5002500", responseMessage: message });
}
}
} else {
return res.status(400).json({ error: "unsupported version" });
}
} catch (err) {
console.error("transfer inquiry error:", err);
return res.status(500).json({ error: "internal_error" });
}
}

View File

@ -0,0 +1,312 @@
import { Request, Response } from "express";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import db from "../db";
import { get, set } from "lodash";
import { hashToken } from "../middleware/auth";
const KEY_PATH =
process.env.PRIVATE_KEY_PATH || path.join(__dirname, "../../secrets/key.pem");
const ALT_KEY_PATH = path.join(__dirname, "../../public/mandiri/midsuit.pem");
function loadPrivateKeyFromPath(): Buffer | null {
try {
// prefer a user-provided midsuit.pem in public/mandiri if present
if (fs.existsSync(ALT_KEY_PATH)) return fs.readFileSync(ALT_KEY_PATH);
if (fs.existsSync(KEY_PATH)) return fs.readFileSync(KEY_PATH);
return null;
} catch (err) {
return null;
}
}
// Format timestamp like: 2025-10-17T14:23:05+07:00 (yyyy-MM-dd'T'HH:mm:ss±HH:MM)
function formatTimestamp(d = new Date()): string {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
const offsetMinutes = -d.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const abs = Math.abs(offsetMinutes);
const oh = String(Math.floor(abs / 60)).padStart(2, "0");
const om = String(abs % 60).padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${ss}${sign}${oh}:${om}`;
}
// Stable deterministic JSON stringify (keys sorted) for minify
function stableStringify(value: any): string {
if (value === null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "number" || typeof value === "boolean")
return String(value);
if (Array.isArray(value)) {
return `[` + value.map((v) => stableStringify(v)).join(",") + `]`;
}
if (typeof value === "object") {
const keys = Object.keys(value).sort();
return (
"{" +
keys
.map(
(k) => `${JSON.stringify(k)}:${stableStringify((value as any)[k])}`
)
.join(",") +
"}"
);
}
return JSON.stringify(value);
}
function sha256HexLower(input: string) {
return crypto
.createHash("sha256")
.update(input, "utf8")
.digest("hex")
.toLowerCase();
}
/**
* Generate RSA-SHA256 signature for the string: `${timestamp}|${rawBody}`.
*
* Behavior:
* - If `privateKeyPem` is provided in the JSON body it will be used (PEM text).
* - Otherwise the server will attempt to load PRIVATE_KEY_PATH on disk.
* - If only a `.cer` (public certificate) is supplied the API returns an
* explicit error because a public certificate cannot be used to create a
* signature.
*/
export async function generateSignature(req: Request, res: Response) {
try {
const clientId =
(req.body && (req.body.clientId || req.body.client_id)) ||
req.query.clientId ||
req.query.client_id;
const data = req.body || {};
const signature = await createSignature({
client_id: clientId,
data,
passphrase: get(req.body, "passphrase"),
pattern: get(req.body, "pattern"),
});
return res.json({
...signature,
algorithm: "RSA-SHA256",
});
} catch (err: any) {
console.error("generateSignature error", err);
return res.status(500).json({ error: err.message || "internal error" });
}
}
export async function createSignature({
client_id,
data,
pattern,
passphrase,
}: {
client_id: string;
data: any;
pattern: string;
passphrase?: string;
}) {
console.log(data);
if (data?.accesstoken) {
const session = await db.sessions.findFirst({
where: { token_hash: hashToken(data.accesstoken) },
include: { users: true },
});
if (!session) {
throw new Error("Invalid access token; session not found");
}
// expired check
if (session.expires_at) {
const exp = new Date(session.expires_at);
if (exp.getTime() < Date.now()) {
throw new Error("Access token expired");
}
}
client_id = session.users.clientbank_id;
}
if (!client_id) throw new Error("client_id is required for signing");
const user = await db.users.findFirst({
where: { clientbank_id: client_id },
});
if (!user) throw new Error("Invalid client_id; user not found");
const timestamp = get(data, "timestamp") || formatTimestamp();
if (!user.private_key_file) {
throw new Error("No private key file associated with the user");
}
const dir = path.join(__dirname, "..", "..", user.private_key_file);
if (!fs.existsSync(dir)) {
throw new Error(`Private key file not found`);
}
const privateKey: Buffer = fs.readFileSync(dir);
// example pattern: "{client_id}|{timestamp}|{order_id}|{amount}"
let stringToSign = pattern;
let dataResult = {} as any;
const regex = /\{(\w+)\}/g;
let match;
while ((match = regex.exec(pattern)) !== null) {
const key = match[1];
const value =
key === "client_id"
? client_id
: key === "timestamp"
? timestamp
: get(data, key);
if (typeof value === "object" || Array.isArray(value)) {
// Lowercase(HexEncode(SHA-256(minify(RequestBody))))
const minified = stableStringify(value);
const hash = sha256HexLower(minified);
stringToSign = stringToSign.replace(match[0], hash);
set(dataResult, key, hash);
continue;
}
stringToSign = stringToSign.replace(match[0], String(value));
set(dataResult, key, String(value));
}
const signer = crypto.createSign("RSA-SHA256");
signer.update(stringToSign);
signer.end();
// If a passphrase is provided, pass an object to signer.sign. Ensure Buffer -> string
let signatureBase64: string;
if (passphrase) {
const keyStr = Buffer.isBuffer(privateKey)
? privateKey.toString("utf8")
: String(privateKey);
signatureBase64 = signer.sign({ key: keyStr, passphrase }, "base64");
} else {
const keyArg: any = Buffer.isBuffer(privateKey)
? privateKey.toString("utf8")
: privateKey;
signatureBase64 = signer.sign(keyArg, "base64");
}
const transaction = [] as any[];
transaction.push(
db.auth_logs.create({
data: {
user_id: user.user_id,
action: "signature",
status: "success",
message: "user created signature",
},
})
);
transaction.push(
db.sessions.create({
data: {
user_id: user.user_id,
signature: signatureBase64,
expires_at: new Date(new Date(timestamp).getTime() + 15 * 60 * 1000), // 15 minutes
},
})
);
await db.$transaction(transaction);
return {
signature: signatureBase64,
expires_at: formatTimestamp(
new Date(new Date(timestamp).getTime() + 15 * 60 * 1000)
), // 15 minutes
...dataResult,
stringToSign,
};
}
export async function verifySignature(req: Request, res: Response) {
try {
// timestamp may be in body or query; default to now if missing (verification should fail if timestamp mismatch)
const timestamp =
req.body.timestamp ||
req.query.timestamp ||
String(Math.floor(Date.now() / 1000));
const raw = (req as any).rawBody
? (req as any).rawBody.toString("utf8")
: JSON.stringify(req.body.payload || req.body || {});
const signatureBase64 = req.body.signature || req.headers["x-signature"];
const suppliedCer = req.body && req.body.cer;
const suppliedCerPath = req.body && req.body.cerPath;
if (!signatureBase64) {
return res.status(400).json({
error: "Missing signature (body.signature or X-Signature header)",
});
}
// Load certificate: prefer raw cer text in body, then a provided path, then the default public file
let cerPem: Buffer | null = null;
if (suppliedCer) {
// accept either base64 or raw PEM
const text = String(suppliedCer).trim();
if (text.includes("-----BEGIN CERTIFICATE-----")) {
cerPem = Buffer.from(text, "utf8");
} else {
// maybe base64-encoded DER
try {
cerPem = Buffer.from(text, "base64");
} catch {
cerPem = Buffer.from(text, "utf8");
}
}
} else if (suppliedCerPath) {
try {
if (fs.existsSync(suppliedCerPath))
cerPem = fs.readFileSync(suppliedCerPath);
} catch (err) {
// fall through
}
} else {
// default public cert bundled in repo
const defaultPath = path.join(
__dirname,
"../../public/mandiri/midsuit.cer"
);
try {
if (fs.existsSync(defaultPath)) cerPem = fs.readFileSync(defaultPath);
} catch (err) {
// ignore
}
}
if (!cerPem) {
return res
.status(400)
.json({ error: "No public certificate available for verification" });
}
// crypto.verify expects a KeyObject or PEM string; ensure we pass PEM string
// If cert is DER (binary), convert to PEM wrapper
let publicKeyPem: string;
const cerStr = cerPem.toString("utf8");
if (cerStr.includes("-----BEGIN CERTIFICATE-----")) {
publicKeyPem = cerStr;
} else {
// assume DER base64 -> wrap
const b64 = cerPem.toString("base64");
publicKeyPem = `-----BEGIN CERTIFICATE-----\n${
b64.match(/.{1,64}/g)?.join("\n") || b64
}\n-----END CERTIFICATE-----`;
}
const stringToVerify = `${timestamp}|${raw}`;
const verifier = crypto.createVerify("RSA-SHA256");
verifier.update(stringToVerify);
verifier.end();
const sigBuffer = Buffer.isBuffer(signatureBase64)
? signatureBase64
: Buffer.from(String(signatureBase64), "base64");
// Use the certificate PEM directly as the public key; Node will extract the public key
const valid = verifier.verify(publicKeyPem, sigBuffer);
return res.json({ valid, details: { algorithm: "RSA-SHA256", timestamp } });
} catch (err: any) {
console.error("verifySignature error", err);
return res.status(500).json({ error: err.message || "internal error" });
}
}

View File

@ -0,0 +1,182 @@
import { Request, Response } from "express";
import { getPoolForDbId } from "../lib/dbPools";
import db from "../db";
// Map a row from source DB to shape expected by Prisma rv_openitem model
function mapRowToRvOpenitem(row: any) {
return {
db_id: row.db_id || null,
is_pay: row.is_pay,
ad_org_id: row.ad_org_id,
ad_client_id: row.ad_client_id,
documentno: row.documentno,
c_invoice_id: row.c_invoice_id,
c_order_id: row.c_order_id,
c_bpartner_id: row.c_bpartner_id,
issotrx: row.issotrx,
dateinvoiced: row.dateinvoiced,
dateacct: row.dateacct,
netdays: row.netdays,
duedate: row.duedate,
daysdue: row.daysdue,
discountdate: row.discountdate,
discountamt: row.discountamt,
grandtotal: row.grandtotal,
paidamt: row.paidamt,
openamt: row.openamt,
c_currency_id: row.c_currency_id,
c_conversiontype_id: row.c_conversiontype_id,
c_paymentterm_id: row.c_paymentterm_id,
ispayschedulevalid: row.ispayschedulevalid,
c_invoicepayschedule_id: row.c_invoicepayschedule_id,
invoicecollectiontype: row.invoicecollectiontype,
c_campaign_id: row.c_campaign_id,
c_project_id: row.c_project_id,
c_activity_id: row.c_activity_id,
ad_orgtrx_id: row.ad_orgtrx_id,
ad_user_id: row.ad_user_id,
c_bpartner_location_id: row.c_bpartner_location_id,
c_charge_id: row.c_charge_id,
c_doctype_id: row.c_doctype_id,
c_doctypetarget_id: row.c_doctypetarget_id,
c_dunninglevel_id: row.c_dunninglevel_id,
chargeamt: row.chargeamt,
c_payment_id: row.c_payment_id,
created: row.created,
createdby: row.createdby,
dateordered: row.dateordered,
dateprinted: row.dateprinted,
description: row.description,
docaction: row.docaction,
docstatus: row.docstatus,
dunninggrace: row.dunninggrace,
generateto: row.generateto,
isactive: row.isactive,
isapproved: row.isapproved,
isdiscountprinted: row.isdiscountprinted,
isindispute: row.isindispute,
ispaid: row.ispaid,
isprinted: row.isprinted,
isselfservice: row.isselfservice,
istaxincluded: row.istaxincluded,
istransferred: row.istransferred,
m_pricelist_id: row.m_pricelist_id,
m_rma_id: row.m_rma_id,
paymentrule: row.paymentrule,
poreference: row.poreference,
posted: row.posted,
processedon: row.processedon,
processing: row.processing,
ref_invoice_id: row.ref_invoice_id,
reversal_id: row.reversal_id,
salesrep_id: row.salesrep_id,
sendemail: row.sendemail,
totallines: row.totallines,
updated: row.updated,
updatedby: row.updatedby,
user1_id: row.user1_id,
user2_id: row.user2_id,
kodebp: row.kodebp,
noorder: row.noorder,
orderdesc: row.orderdesc,
};
}
export default class SyncController {
static async syncRvOpenitem(req: Request, res: Response) {
try {
const databases = await db.database.findMany({
where: {
name: {
in: ["KIG PROD"],
},
},
});
// sinkron per db satu per satu (each database work is wrapped in a transaction)
let totalInserted = 0;
let totalUpdated = 0;
for (const database of databases) {
const pool = await getPoolForDbId(database.db_id);
const q = "SELECT * FROM rv_openitem";
const result = await pool.query(q);
const rows = result.rows || [];
const freshDB = await db.rv_openitem.count({
where: { db_id: database.db_id },
});
// perform upserts in smaller transactions (per-batch) to avoid long-lived transactions
const batchSize = 200;
const transactions = [];
for (let i = 0; i < rows.length; i += batchSize) {
const batch = rows.slice(i, i + batchSize);
const ops = batch.map((r: any) => mapRowToRvOpenitem(r));
for (const op of ops) {
if (freshDB === 0) {
transactions.push(
db.rv_openitem.create({
data: { ...op, db_id: database.db_id },
})
);
totalInserted++;
} else {
const existing = await db.rv_openitem.findFirst({
where: { c_invoice_id: op.c_invoice_id },
});
if (existing) {
transactions.push(
db.rv_openitem.update({
where: { id: existing.id },
data: op,
})
);
totalUpdated++;
} else {
transactions.push(
db.rv_openitem.create({
data: { ...op, db_id: database.db_id },
})
);
totalInserted++;
}
}
}
}
// cleanup: mark local records as not paid if they no longer exist in source
if (freshDB > 0) {
const existingIds = rows
.map((r: any) => r.c_invoice_id)
.filter((v: any) => v !== undefined && v !== null);
if (existingIds.length > 0) {
const toUpdate = await db.rv_openitem.findMany({
where: {
db_id: database.db_id,
c_invoice_id: { notIn: existingIds },
},
});
for (const item of toUpdate) {
transactions.push(
db.rv_openitem.update({
where: { id: item.id },
data: { is_pay: "N" },
})
);
}
}
}
if (transactions.length > 0) await db.$transaction(transactions);
}
return res.json({
status: "success",
inserted: totalInserted,
updated: totalUpdated,
});
} catch (err) {
console.error("syncRvOpenitem error", err);
return res.status(500).json({
error: "internal_error",
detail: err instanceof Error ? err.message : String(err),
});
}
}
}

View File

@ -0,0 +1,181 @@
import { Request, Response } from "express";
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { hashToken } from "../middleware/auth";
import db from "../db";
import { formatTimestamp } from "../lib/formatTimestamp";
function loadCertForUser(user: any): string | null {
// try deriving .cer from private_key_file
try {
if (user && user.private_key_file) {
const p = path.join(__dirname, "..", "..", user.private_key_file);
const cer = p.replace(/\.[^.]+$/, ".cer");
if (fs.existsSync(cer)) return fs.readFileSync(cer, "utf8");
// if there's a PEM instead of CER, try to derive public cert from it
const pem = p.replace(/\.[^.]+$/, ".pem");
if (fs.existsSync(pem)) {
const pemBuf = fs.readFileSync(pem);
const pemStr = pemBuf.toString("utf8");
// If it's already a public cert/key, try to return it directly
if (
/-----BEGIN CERTIFICATE-----/.test(pemStr) ||
/-----BEGIN PUBLIC KEY-----/.test(pemStr)
) {
return pemStr;
}
// If it's a private key (possibly encrypted), attempt to derive public key using optional paraphrase
try {
const passphrase =
user?.paraphrase || process.env.PRIVATE_KEY_PASSPHRASE || undefined;
// createPrivateKey works for both encrypted and unencrypted PEMs
const keyObject = crypto.createPrivateKey({
key: pemBuf,
format: "pem",
passphrase,
});
const pubPem = keyObject.export({
type: "spki",
format: "pem",
}) as string;
return pubPem;
} catch (e) {
// fallback: return raw pem string (verification may still work if it's a cert)
return pemStr;
}
}
}
} catch {}
return null;
}
export async function b2bAccessToken(req: Request, res: Response) {
try {
const clientKey = (req.headers["x-client-key"] ||
req.headers["X-CLIENT-KEY"] ||
req.headers["x-client-key" as any]) as string;
const timestamp = (req.headers["x-timestamp"] ||
req.headers["X-TIMESTAMP"] ||
req.body.timestamp) as string;
let signature = (req.headers["x-signature"] ||
req.headers["X-SIGNATURE"] ||
req.body.signature) as string;
if (!clientKey || !timestamp || !signature) {
return res.status(400).json({
responseCode: "4000001",
responseMessage: "Missing required headers",
});
}
const signatureSession = await db.sessions.findFirst({
where: {
signature: signature,
},
include: {
users: {
include: {
database: true,
parameters: true,
tenants: true,
banks: true,
},
},
},
});
if (!signatureSession) {
return res.status(401).json({
responseCode: "4017300",
responseMessage: "Unauthorized. Signature not found",
});
} else if (signatureSession.expires_at) {
const exp = new Date(signatureSession.expires_at);
if (exp < new Date()) {
return res.status(401).json({
responseCode: "4017300",
responseMessage: "Unauthorized. Signature expired",
});
}
}
// normalize signature (remove whitespace/newlines)
signature = signature.replace(/\s+/g, "");
// find user by client key
const user = await db.users.findFirst({
where: { clientbank_id: String(clientKey) },
});
if (!user)
return res.status(401).json({
responseCode: "4017300",
responseMessage: "Unauthorized. Client ID not found",
});
// load cert
const certPem = loadCertForUser(user);
if (!certPem)
return res.status(500).json({
responseCode: "4017300",
responseMessage:
"Unauthorized. No public certificate available for client",
});
const stringToVerify = `${clientKey}|${timestamp}`;
const verifier = crypto.createVerify("RSA-SHA256");
verifier.update(stringToVerify);
verifier.end();
const sigBuf = Buffer.from(signature, "base64");
const ok = verifier.verify(certPem, sigBuf);
if (!ok)
return res.status(401).json({
responseCode: "4017300",
responseMessage: "Invalid signature format",
});
// validate grant type
const grantType =
(req.body && req.body.grantType) || req.body.grant_type || "";
if (String(grantType).toLowerCase() !== "client_credentials") {
return res.status(400).json({
responseCode: "4000004",
responseMessage: "Unsupported grantType",
});
}
// generate token and store session
const token = crypto.randomBytes(48).toString("hex");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // expired 15 minute
await db.auth_logs.create({
data: {
user_id: user.user_id,
action: "login",
status: "success",
message: "user logged in",
},
});
await db.sessions.create({
data: {
user_id: user.user_id,
token_hash: tokenHash,
expires_at: expiresAt,
},
});
return res.json({
responseCode: "2007300",
responseMessage: "Success",
accessToken: token,
tokenType: "Bearer",
expiresIn: formatTimestamp(expiresAt),
});
} catch (err: any) {
console.error("b2bAccessToken error", err);
return res
.status(500)
.json({ responseCode: "5000000", responseMessage: "internal error" });
}
}

View File

@ -0,0 +1,41 @@
import { Request, Response } from "express";
import callGateFromReq from "../lib/gateClient";
export async function inquiry(req: Request, res: Response) {
try {
const { version } = req.params;
if (version === "v1") {
const body = req.body || {};
const requiredBody = [
"partnerServiceId",
"customerNo",
"virtualAccountNo",
"trxDateInit",
"amount",
"inquiryRequestId",
];
for (const f of requiredBody) {
if (body[f] === undefined || body[f] === null) {
return res.status(400).json({ error: `missing body field ${f}` });
}
}
const result = await callGateFromReq(req, {
client: "mandiri",
action: "getInvoiceVirtualAccount",
data: { ...body },
});
return res.status(200).json({
responseCode: "2002400",
responseMessage: "Successful",
virtualAccountData: result,
});
} else {
return res.status(400).json({ error: "unsupported version" });
}
} catch (err) {
console.error("transfer inquiry error:", err);
return res.status(500).json({ error: "internal_error" });
}
}

View File

@ -0,0 +1,211 @@
import { Request, Response } from "express";
import fs from "fs";
import path from "path";
// Note: install `multer` and `@types/multer` for full TypeScript support:
// npm install multer && npm install -D @types/multer
import multer, { FileFilterCallback } from "multer";
import { RequestHandler } from "express";
const UPLOAD_DIR = path.join(__dirname, "../../uploads");
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
function formatDate(d = new Date()) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
// File types to explicitly reject even if mime lies
const DISALLOWED_EXT = new Set([".exe", ".sh", ".bat", ".js", ".jar", ".jar"]);
function getMimeTypeFromExt(ext: string) {
const e = (ext || "").toLowerCase();
switch (e) {
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".png":
return "image/png";
case ".gif":
return "image/gif";
case ".pdf":
return "application/pdf";
case ".txt":
return "text/plain";
default:
return "application/octet-stream";
}
}
const storage = multer.diskStorage({
destination: function (
_req: any,
_file: any,
cb: (err: Error | null, dest: string) => void
) {
const dateFolder = formatDate(new Date());
const destDir = path.join(UPLOAD_DIR, dateFolder);
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
cb(null as any, destDir);
},
filename: function (
_req: any,
file: any,
cb: (err: Error | null, filename: string) => void
) {
const ext = path.extname(file.originalname) || "";
const base = path.basename(file.originalname || "", ext);
// sanitize base: keep alphanumeric, dash, underscore; replace spaces with dash; limit length
const sanitized = base
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9-_]/g, "")
.slice(0, 40);
const name = `${sanitized || "file"}-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}${ext}`;
cb(null as any, name);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB per file
fileFilter: function (_req: any, file: any, cb: FileFilterCallback) {
const ext = path.extname(file.originalname || "").toLowerCase();
if (DISALLOWED_EXT.has(ext)) return cb(new Error("disallowed file type"));
cb(null, true);
},
});
function publicUrlForRelative(relPath: string) {
// relPath is relative to UPLOAD_DIR and should use forward slashes
return `/uploads/${relPath.replace(/\\/g, "/")}`;
}
export const singleUploadHandler: RequestHandler = upload.single("file") as any;
export const multiUploadHandler: RequestHandler = upload.array(
"files",
10
) as any;
export class UploadController {
async single(req: Request, res: Response) {
// multer will populate req.file
const f: any = (req as any).file;
if (!f) return res.status(400).json({ error: "no file uploaded" });
// multer stored file in a date folder; compute relative path to UPLOAD_DIR
const rel = path.relative(UPLOAD_DIR, f.path);
return res.json({
url: publicUrlForRelative(rel),
filename: rel,
size: f.size,
});
}
async multiple(req: Request, res: Response) {
const files: any[] = (req as any).files || [];
if (!files.length)
return res.status(400).json({ error: "no files uploaded" });
const urls = files.map((f) => {
const rel = path.relative(UPLOAD_DIR, f.path);
return { url: publicUrlForRelative(rel), filename: rel, size: f.size };
});
return res.json({ files: urls });
}
/**
* List uploaded files with metadata. Query params:
* - prefix: filter by filename prefix
* - limit: number of items (default 100)
* - offset: pagination offset
*/
async list(req: Request, res: Response) {
try {
const prefix = String(req.query.prefix || "");
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const offset = Math.max(Number(req.query.offset) || 0, 0);
const files = await fs.promises.readdir(UPLOAD_DIR);
const matched = [] as Array<{
filename: string;
size: number;
mtime: string;
url: string;
mime: string;
}>;
for (const f of files) {
if (prefix && !f.startsWith(prefix)) continue;
const p = path.join(UPLOAD_DIR, f);
try {
const st = await fs.promises.stat(p);
if (!st.isFile()) continue;
matched.push({
filename: f,
size: st.size,
mtime: st.mtime.toISOString(),
url: publicUrlForRelative(f),
mime: getMimeTypeFromExt(path.extname(f)),
});
} catch (err) {
// ignore stat errors per-file
}
}
// sort by mtime desc
matched.sort((a, b) => (a.mtime < b.mtime ? 1 : -1));
const page = matched.slice(offset, offset + limit);
return res.json({ count: matched.length, offset, limit, files: page });
} catch (err: any) {
console.error("upload list error", err);
return res.status(500).json({ error: err.message || "internal error" });
}
}
/**
* Stream a file for preview/download. Protected. Filename is sanitized to avoid traversal.
*/
async preview(req: Request, res: Response) {
try {
// filename may be provided as date/filename or just filename
let filename = String(req.params.filename || "");
// also support route /uploads/preview/:date/:filename
if ((req.params as any).date && (req.params as any).filename) {
filename = `${(req.params as any).date}/${
(req.params as any).filename
}`;
}
if (!filename) return res.status(400).json({ error: "missing filename" });
// prevent path traversal
if (filename.includes("..") || path.isAbsolute(filename))
return res.status(400).json({ error: "invalid filename" });
const abs = path.join(UPLOAD_DIR, filename);
const resolved = path.resolve(abs);
if (!resolved.startsWith(path.resolve(UPLOAD_DIR)))
return res.status(400).json({ error: "invalid filename" });
if (!fs.existsSync(resolved))
return res.status(404).json({ error: "not found" });
const stat = await fs.promises.stat(resolved);
if (!stat.isFile()) return res.status(404).json({ error: "not found" });
const mime = getMimeTypeFromExt(path.extname(filename));
res.setHeader("Content-Type", mime);
// let browser preview inline where possible
res.setHeader(
"Content-Disposition",
`inline; filename="${path.basename(filename)}"`
);
const stream = fs.createReadStream(resolved);
stream.on("error", (err) => {
console.error("stream error", err);
if (!res.headersSent) res.status(500).end("internal error");
});
stream.pipe(res);
} catch (err: any) {
console.error("preview error", err);
return res.status(500).json({ error: err.message || "internal error" });
}
}
}
export default new UploadController();

9
src/db.ts Normal file
View File

@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma?: PrismaClient };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
export default db;

View File

@ -0,0 +1,133 @@
import { database, users } from "@prisma/client";
import { getCBPartnerVA } from "../../getCBPartnerVA";
import { get } from "lodash";
import db from "../../../db";
import lo from "../../lodash";
import Decimal from "decimal.js";
const twoDigits = (n: number) => String(n).padStart(2, "0");
export const formatMoney = (v: any, decimal?: boolean) => {
const d = new Decimal(v ?? 0);
if (decimal) {
return d.toDecimalPlaces(2, Decimal.ROUND_CEIL);
}
return d.toDecimalPlaces(2, Decimal.ROUND_CEIL).toFixed(2);
};
export default async function ({
user,
session,
data,
}: {
user: users & { database?: database | null };
session: any;
data: any;
}) {
console.log(user);
// sample implementation — adapt to real bank payload and API calls
const { invoiceId } = data || {};
if (!user.database) throw new Error("User database information is missing");
// For demo, return a mocked VA info
const c_bpartner = await getCBPartnerVA({
virtualAccountNo: invoiceId,
dbName: get(user, "database.name", ""),
});
if (!c_bpartner) {
throw new Error("C_BPartner_ID not found for the given Virtual Account No");
}
const grandtotal = await db.rv_openitem.aggregate({
_sum: {
grandtotal: true,
},
where: {
c_bpartner_id: c_bpartner.c_bpartner_id,
db_id: user.database.db_id,
is_pay: "N",
},
});
if (lo.get(grandtotal, "_sum.grandtotal") === null) {
throw new Error(
"No outstanding amount found for the given Virtual Account No"
);
}
const lines = await db.rv_openitem.findMany({
where: {
c_bpartner_id: c_bpartner.c_bpartner_id,
db_id: user.database.db_id,
is_pay: "N",
},
});
const invoice = await db.invoice.create({
data: {
tenant_id: user.tenant_id,
bank_id: user.bank_id,
amount: formatMoney(grandtotal._sum.grandtotal, true) || 0,
description: `Payment for Virtual Account ${invoiceId}`,
is_pay: false,
status: "DRAFT",
c_bpartner_id: get(c_bpartner, "c_bpartner_id"),
inquiryRequestId: get(data, "inquiryRequestId"),
partnerServiceId: get(data, "partnerServiceId"),
customerNo: get(data, "customerNo"),
virtualAccountNo: get(data, "virtualAccountNo"),
trxDateInit: get(data, "trxDateInit")
? new Date(get(data, "trxDateInit"))
: null,
channelCode: get(data, "channelCode"),
db_id: get(user, "database.db_id"),
date: get(data, "trxDateInit")
? new Date(get(data, "trxDateInit"))
: null,
},
});
if (!invoice) throw new Error("Failed to create invoice record");
const transactions: any[] = [];
const billDetails: any[] = [];
lines.map((item, index) => {
billDetails.push({
billCode: twoDigits(index + 1),
billName: get(item, "documentno"),
billAmount: {
value: formatMoney(lo.get(item, "grandtotal", 0)),
currency: "IDR",
},
});
transactions.push(
db.invoice_lines.create({
data: {
invoice_id: invoice.id,
description: get(item, "description"),
billcode: twoDigits(index + 1),
line_no: index + 1,
billname: get(item, "documentno"),
amount: lo.get(item, "grandtotal", 0),
c_invoice_id: get(item, "c_invoice_id"),
db_id: lo.get(user, "database.db_id"),
bank_id: get(user, "bank_id"),
tenant_id: get(user, "tenant_id"),
},
})
);
});
if (transactions.length > 0) await db.$transaction(transactions);
return {
inquiryStatus: "00",
inquiryReason: {
english: "Successful",
indonesia: "Sukses",
},
inquiryRequestId: get(data, "inquiryRequestId"),
partnerServiceId: get(data, "partnerServiceId"),
customerNo: get(data, "customerNo"),
virtualAccountNo: get(data, "virtualAccountNo"),
virtualAccountName: get(c_bpartner, "name"),
totalAmount: {
value: formatMoney(lo.get(grandtotal, "_sum.grandtotal", 0)),
currency: "IDR",
},
billDetails: billDetails,
feeAmount: {
value: "0.00",
currency: "IDR",
},
};
}

View File

@ -0,0 +1,44 @@
import { get } from "lodash";
import { createSignature } from "../../../controllers/signController";
import { db } from "../../../db";
import { formatTimestamp } from "../../formatTimestamp";
import axios from "axios";
export default async function ({ data }: { data: any }) {
const user = await db.users.findFirst({
where: {
clientbank_id: data.clientbank_id,
},
});
if (!user) throw new Error("User not found");
const clientId = data.clientbank_id;
const timestamp = formatTimestamp();
const signature = await createSignature({
client_id: clientId,
data: {
timestamp,
},
passphrase: get(user, "pharaphrase") as any,
pattern: "{client_id}:{timestamp}",
});
if (!signature) throw new Error("Failed to create signature");
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
"X-CLIENT-KEY": clientId,
"X-TIMESTAMP": timestamp,
"X-SIGNATURE": signature,
};
const mandiriUrl = String(
get(user, "endpoint") || process.env.MANDIRI_TOKEN_URL
);
const resp = await axios.post(
mandiriUrl,
{ grantType: "client_credentials" },
{
headers,
timeout: 15000,
}
);
return resp.data;
}

View File

@ -0,0 +1,103 @@
import { database, users } from "@prisma/client";
import { get } from "lodash";
import db from "../../../db";
import lo from "../../lodash";
import Decimal from "decimal.js";
const twoDigits = (n: number) => String(n).padStart(2, "0");
export const formatMoney = (v: any) => {
const d = new Decimal(v ?? 0);
return d.toDecimalPlaces(2, Decimal.ROUND_CEIL).toFixed(2);
};
export default async function ({
user,
session,
data,
}: {
user: users & { database?: database | null };
session: any;
data: any;
}) {
const invoice = await db.invoice.findFirst({
where: {
tenant_id: user.tenant_id,
inquiryRequestId: get(data, "inquiryRequestId"),
},
include: {
invoice_lines: true,
},
});
if (!invoice) {
throw new Error("Invoice not found for the given inquiryRequestId");
}
if (formatMoney(invoice.amount) !== get(data, "paidAmount.value")) {
throw new Error("Paid amount does not match invoice amount");
}
if (!user.database) throw new Error("User database information is missing");
const lines = get(invoice, "invoice_lines", []);
const flagAdvise = get(data, "flagAdvise") === "Y" ? true : false;
if (!flagAdvise) {
const transaction = await db.transactions.create({
data: {
tenant_id: user.tenant_id,
bank_id: user.bank_id,
amount: invoice.amount as any,
description: `Payment for Virtual Account ${invoice.inquiryRequestId}`,
is_pay: false,
status: "DRAFT",
c_bpartner_id: get(invoice, "c_bpartner_id"),
paymentRequestId: get(data, "paymentRequestId"),
paidBills: get(data, "paidBills"),
hashedSourceAccountNo: get(data, "hashedSourceAccountNo"),
flagAdvise: get(data, "flagAdvise"),
partnerServiceId: get(data, "partnerServiceId"),
customerNo: get(data, "customerNo"),
virtualAccountNo: get(data, "virtualAccountNo"),
referenceNo: get(data, "referenceNo"),
channelCode: get(data, "channelCode"),
db_id: get(user, "database.db_id"),
trxDateTime: get(data, "trxDateTime")
? new Date(get(data, "trxDateTime"))
: null,
},
});
if (!transaction) throw new Error("Failed to create invoice record");
const transactions: any[] = [];
lines.map((item, index) => {
transactions.push(
db.transactions_lines.create({
data: {
transaction_id: transaction.id,
description: get(item, "description"),
line_no: get(item, "line_no"),
amount: lo.get(item, "grandtotal", 0),
c_invoice_id: get(item, "c_invoice_id"),
db_id: lo.get(user, "database.db_id"),
bank_id: get(user, "bank_id"),
tenant_id: get(user, "tenant_id"),
},
})
);
});
if (transactions.length > 0) await db.$transaction(transactions);
}
return {
paymentFlagStatus: "00",
paymentFlagReason: {
english: "Successful",
indonesia: "Sukses",
},
paymentRequestId: get(data, "paymentRequestId"),
partnerServiceId: get(data, "partnerServiceId"),
customerNo: get(data, "customerNo"),
virtualAccountNo: get(data, "virtualAccountNo"),
virtualAccountName: get(invoice, "virtualAccountName"),
totalAmount: {
value: formatMoney(invoice.amount),
currency: "IDR",
},
trxDateTime: get(data, "trxDateTime"),
};
}

View File

@ -0,0 +1,52 @@
import { get } from "lodash";
import { createSignature } from "../../../controllers/signController";
import { db } from "../../../db";
import { formatTimestamp } from "../../formatTimestamp";
import axios from "axios";
export default async function ({ data, session }: { data: any; session: any }) {
const user = await db.users.findFirst({
where: {
clientbank_id: data.clientbank_id,
},
});
if (!user) throw new Error("User not found");
const clientId = data.clientbank_id;
const timestamp = formatTimestamp();
const signature = await createSignature({
client_id: clientId,
data: {
timestamp,
accesstoken: data?.token,
endpointurl: "/v1/transfer-va/payment",
httpmethod: "POST",
requestbody: data?.requestbody,
},
passphrase: get(user, "pharaphrase") as any,
pattern:
"{httpmethod}:{endpointurl}:{accesstoken}:{requestbody}:{timestamp}",
});
if (!signature) throw new Error("Failed to create signature");
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
"X-CLIENT-KEY": clientId,
"X-TIMESTAMP": timestamp,
"X-SIGNATURE": signature,
"X-PARTNER-ID": "BMRI",
"X-EXTERNAL-ID": "1234567890",
"CHANNEL-ID": "IBANK",
};
const mandiriUrl = String(
get(user, "endpoint") || process.env.MANDIRI_TOKEN_URL
);
const resp = await axios.post(
mandiriUrl,
{ grantType: "client_credentials" },
{
headers,
timeout: 15000,
}
);
return resp.data;
}

View File

@ -0,0 +1,45 @@
import { database, users } from "@prisma/client";
import crypto from "crypto";
export default async function ({
user,
session,
data,
}: {
user: users & { database?: database | null };
session: any;
data: any;
}) {
function stableStringify(value: any): string {
if (value === null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "number" || typeof value === "boolean")
return String(value);
if (Array.isArray(value)) {
return `[` + value.map((v) => stableStringify(v)).join(",") + `]`;
}
if (typeof value === "object") {
const keys = Object.keys(value).sort();
return (
"{" +
keys
.map(
(k) => `${JSON.stringify(k)}:${stableStringify((value as any)[k])}`
)
.join(",") +
"}"
);
}
return JSON.stringify(value);
}
function sha256HexLower(input: string) {
return crypto
.createHash("sha256")
.update(input, "utf8")
.digest("hex")
.toLowerCase();
}
const stringified = stableStringify(data);
const hash = sha256HexLower(stringified);
return hash;
}

View File

@ -0,0 +1,108 @@
import { database, users } from "@prisma/client";
import { get } from "lodash";
import fs from "fs";
import path from "path";
import db from "../../../db";
import stringifyMandiri from "./stringifyMandiri";
import { readCertAsPEM, verifyRsaSignature } from "../../verifySignature";
import { hashToken } from "../../../middleware/auth";
export default async function ({
user,
session,
data,
}: {
user: users & { database?: database | null };
session: any;
data: any;
}) {
const typeVerify = get(data, "typeVerify");
if (typeVerify === "transaction") {
const pattern =
"{httpmethod}:{endpointurl}:{accesstoken}:{requestbody}:{timestamp}";
const sessions = await db.sessions.findFirst({
where: {
token_hash: hashToken(get(data, "accesstoken")),
},
});
if (!sessions) {
throw new Error("Invalid access token");
}
// expired check
if (sessions.expires_at) {
const exp = new Date(sessions.expires_at);
if (exp.getTime() < Date.now()) {
throw new Error("Access token expired");
}
}
const hashBody = await stringifyMandiri({
user,
session,
data: get(data, "requestbody"),
});
const stringToSign = pattern
.replace("{httpmethod}", get(data, "httpmethod").toUpperCase())
.replace("{endpointurl}", get(data, "endpointurl"))
.replace("{accesstoken}", get(data, "accesstoken"))
.replace("{requestbody}", hashBody)
.replace("{timestamp}", get(data, "timestamp"));
console.log(stringToSign);
const pathPublicKey = user.public_key_file;
console.log(user);
if (!pathPublicKey) {
throw new Error("No public key file associated with the user");
}
const fullPath = path.join(
__dirname,
"..",
"..",
"..",
"..",
pathPublicKey
);
if (!fs.existsSync(fullPath)) {
throw new Error("Public key file not found");
}
const cerPem = readCertAsPEM(fullPath);
console.log({
stringToVerify: stringToSign,
signatureBase64: get(data, "signature"),
certPem: cerPem,
});
const result = verifyRsaSignature({
stringToVerify: stringToSign,
signatureBase64: get(data, "signature"),
certPem: cerPem,
});
return result;
} else if (typeVerify === "signature") {
const pattern = "{client_id}|{timestamp}";
const stringToVerify = pattern
.replace("{client_id}", get(data, "client_id"))
.replace("{timestamp}", get(data, "timestamp"));
const pathPublicKey = user.public_key_file;
if (!pathPublicKey) {
throw new Error("No public key file associated with the user");
}
const fullPath = path.join(
__dirname,
"..",
"..",
"..",
"..",
pathPublicKey
);
if (!fs.existsSync(fullPath)) {
throw new Error("Public key file not found");
}
const cerPem = readCertAsPEM(fullPath);
const result = verifyRsaSignature({
stringToVerify: stringToVerify,
signatureBase64: get(data, "signature"),
certPem: cerPem,
});
return result;
} else {
throw new Error("Invalid typeVerify");
}
}

70
src/lib/dbPools.ts Normal file
View File

@ -0,0 +1,70 @@
import { Pool } from "pg";
import prisma from "../prisma";
type PoolEntry = {
pool: Pool;
lastUsed: number;
};
const poolCache = new Map<string, PoolEntry>();
const POOL_TTL_MS = 1000 * 60 * 30; // 30 minutes TTL for idle pools
function buildConnString(db: {
host: string;
port: number;
username: string;
password: string;
name: string;
ssl?: boolean;
}) {
// Use pg connection string
const { host, port, username, password, name, ssl } = db;
return `postgresql://${encodeURIComponent(username)}:${encodeURIComponent(
password
)}@${host}:${port}/${encodeURIComponent(name)}${
ssl ? "?sslmode=require" : ""
}`;
}
export async function getPoolForDbId(db_id: string) {
const now = Date.now();
const cached = poolCache.get(db_id);
if (cached) {
cached.lastUsed = now;
return cached.pool;
}
// load credentials from central metadata table (prisma database model)
const dbRec = await prisma.database.findUnique({ where: { db_id } });
if (!dbRec) throw new Error("database not found");
const connString = buildConnString({
host: dbRec.host,
port: Number(dbRec.port),
username: dbRec.username,
password: dbRec.password,
name: dbRec.db_name,
});
const pool = new Pool({
connectionString: connString,
max: 10,
idleTimeoutMillis: 30000,
});
poolCache.set(db_id, { pool, lastUsed: now });
return pool;
}
// optional: background cleanup
setInterval(() => {
const now = Date.now();
for (const [key, entry] of poolCache.entries()) {
if (now - entry.lastUsed > POOL_TTL_MS) {
try {
entry.pool.end().catch(() => {});
} catch {}
poolCache.delete(key);
}
}
}, 1000 * 60 * 5);

18
src/lib/dbQueryClient.ts Normal file
View File

@ -0,0 +1,18 @@
import db from "../db";
import { getPoolForDbId } from "./dbPools";
export async function dbQueryClient({
name,
query,
}: {
name: string;
query: string;
}) {
const database = await db.database.findUnique({
where: { name },
});
if (!database) throw new Error("Database not found");
const pool = await getPoolForDbId(database.db_id);
const result = await pool.query(query);
return result.rows || [];
}

View File

@ -0,0 +1,14 @@
export function formatTimestamp(d = new Date()): string {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
const offsetMinutes = -d.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const abs = Math.abs(offsetMinutes);
const oh = String(Math.floor(abs / 60)).padStart(2, "0");
const om = String(abs % 60).padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${ss}${sign}${oh}:${om}`;
}

31
src/lib/gate.ts Normal file
View File

@ -0,0 +1,31 @@
import path from "path";
export async function callGate(opts: {
client: string;
action: string;
user?: any;
session?: any;
data?: any;
}) {
const { client, action, user, session, data } = opts;
const handlerPath = path.join(
process.cwd(),
"src",
"lib",
"bank",
client,
`${action}.ts`
);
try {
const mod = await import(handlerPath);
if (!mod || typeof mod.default !== "function") {
throw new Error("handler not found or default export not a function");
}
// call the handler with (user, session, data)
return await mod.default({ user, session, data });
} catch (err) {
throw err;
}
}
export default callGate;

29
src/lib/gateClient.ts Normal file
View File

@ -0,0 +1,29 @@
import { getUserFromToken } from "../middleware/auth";
import callGate from "./gate";
export async function callGateFromReq(
req: any,
opts: { client: string; action: string; data?: any }
) {
// try to extract token from header or query
const auth =
req.header("Authorization") ||
req.header("authorization") ||
req.header("x-access-token") ||
req.query?.access_token;
if (!auth) throw new Error("missing token");
const token = auth.startsWith("Bearer ")
? auth.slice("Bearer ".length).trim()
: String(auth);
const u = await getUserFromToken(token);
if (!u) throw new Error("invalid token");
return callGate({
client: opts.client,
action: opts.action,
user: u.user,
session: u.session,
data: opts.data,
});
}
export default callGateFromReq;

28
src/lib/getCBPartnerVA.ts Normal file
View File

@ -0,0 +1,28 @@
import { dbQueryClient } from "./dbQueryClient";
import lo from "./lodash";
export async function getCBPartnerVA({
virtualAccountNo,
dbName,
}: {
virtualAccountNo: string;
dbName: string;
}) {
// const rows = await dbQueryClient({
// name: dbName,
// query: `SELECT C_BPartner_ID, Name from C_BP_BankAccount where VirtualAccountNo = ${virtualAccountNo} and IsActive='Y'
// LEFT JOIN C_BPartner ON C_BPartner.C_BPartner_ID = C_BP_BankAccount.C_BPartner_ID
// LIMIT 1`,
// });
const rows = await dbQueryClient({
name: dbName,
query: `SELECT C_BPartner_ID, Name from C_BPartner where C_BPartner_ID = 30298 and IsActive='Y'
LIMIT 1`,
});
return lo.get(rows, "[0].c_bpartner_id", null)
? {
c_bpartner_id: lo.get(rows, "[0].c_bpartner_id", null),
name: lo.get(rows, "[0].name", null),
}
: null;
}

7
src/lib/lodash.ts Normal file
View File

@ -0,0 +1,7 @@
import * as lo from "lodash";
// Re-export lodash as the project-wide shorthand `lo`.
// Usage:
// import lo from './lib/lo';
// or: const lo = require('./lib/lo').default;
export default lo;

36
src/lib/signature.ts Normal file
View File

@ -0,0 +1,36 @@
import crypto from "crypto";
export function verifySignature(
payloadJson: string,
timestamp: string,
secret: string,
signatureBase64: string
) {
try {
const h = crypto.createHmac("sha256", secret);
h.update(`${timestamp}|${payloadJson}`);
const expected = h.digest("base64");
const expBuf = Buffer.from(expected, "utf8");
const sigBuf = Buffer.from(signatureBase64, "utf8");
if (expBuf.length !== sigBuf.length) return false;
return crypto.timingSafeEqual(expBuf, sigBuf);
} catch (err) {
return false;
}
}
export function verifyRsaSignature(
stringToVerify: string,
signatureBase64: string,
certPem: string
) {
try {
const verifier = crypto.createVerify("RSA-SHA256");
verifier.update(stringToVerify, "utf8");
verifier.end();
const sigBuf = Buffer.from(signatureBase64.replace(/\s+/g, ""), "base64");
return verifier.verify(certPem, sigBuf);
} catch (err) {
return false;
}
}

View File

@ -0,0 +1,83 @@
import fs from "fs";
import crypto from "crypto";
import path from "path";
export function readCertAsPEM(filePath: string): string {
const raw = fs.readFileSync(filePath);
const text = raw.toString("utf8").trim();
// already PEM
if (text.includes("-----BEGIN CERTIFICATE-----")) return text;
// DER -> PEM
const b64 = raw.toString("base64");
// wrap at 64 chars per PEM spec
const wrapped = b64.match(/.{1,64}/g)?.join("\n") || b64;
return `-----BEGIN CERTIFICATE-----\n${wrapped}\n-----END CERTIFICATE-----\n`;
}
// --- Util: validasi timestamp and tolerance ---
export function parseIso(ts: string): Date | null {
const d = new Date(String(ts));
return isNaN(d.getTime()) ? null : d;
}
export function isFresh(tsStr: string, skewMin = 5) {
const d = parseIso(tsStr);
if (!d) return { ok: false, reason: "Invalid ISO-8601 timestamp" };
const now = new Date();
const diffMs = Math.abs(now.getTime() - d.getTime());
const ok = diffMs <= skewMin * 60 * 1000;
return ok
? { ok: true }
: { ok: false, reason: `Timestamp too far: ${Math.round(diffMs / 1000)}s` };
}
// Verify RSA-SHA256 signature for a provided string (stringToVerify)
export function verifyRsaSignature({
stringToVerify,
signatureBase64,
certPem,
}: {
stringToVerify: string;
signatureBase64: string;
certPem: string;
}): boolean {
if (!signatureBase64) return false;
const sigBuf = Buffer.from(
String(signatureBase64).replace(/\s+/g, ""),
"base64"
);
try {
const publicKey = crypto.createPublicKey(certPem);
const ok = crypto.verify(
"RSA-SHA256",
Buffer.from(stringToVerify, "utf8"),
publicKey,
sigBuf
);
return ok;
} catch (err) {
return false;
}
}
// Convenience: verify signature against default Mandiri cert (path relative to project)
export async function verifySignature({
stringToVerify,
signatureBase64,
certPath,
}: {
stringToVerify: string;
signatureBase64: string;
certPath?: string;
}): Promise<boolean> {
const CERT_PATH =
certPath || path.join(__dirname, "../../public/mandiri/midsuit.cer");
try {
const certPem = readCertAsPEM(CERT_PATH);
return verifyRsaSignature({ stringToVerify, signatureBase64, certPem });
} catch (err) {
return false;
}
}

64
src/middleware/auth.ts Normal file
View File

@ -0,0 +1,64 @@
import { Request, Response, NextFunction } from "express";
import prisma from "../prisma";
import crypto from "crypto";
export interface AuthRequest extends Request {
user?: any;
session?: any;
}
export function hashToken(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}
export async function authMiddleware(
req: AuthRequest,
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 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
export async function getUserFromToken(token: string) {
if (!token) return null;
const tokenHash = hashToken(
token.startsWith("Bearer ") ? token.slice("Bearer ".length).trim() : token
);
const session = await prisma.sessions.findFirst({
where: { token_hash: tokenHash, is_revoked: false },
});
if (!session) return null;
const user = await prisma.users.findFirst({
where: { user_id: session.user_id },
include: { tenants: true, database: true },
});
if (!user) return null;
return { user, session } as { user: any; session: any };
}

104
src/middleware/vaAuth.ts Normal file
View File

@ -0,0 +1,104 @@
import { Request, Response, NextFunction } from "express";
import { authMiddleware, hashToken } from "./auth";
import { db } from "../db";
import callGateFromReq from "../lib/gateClient";
import { get } from "lodash";
export async function vaMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
try {
const timestamp = (req.header("x-timestamp") ||
req.header("X-TIMESTAMP")) as string;
const signature = (req.header("x-signature") ||
req.header("X-SIGNATURE")) as string;
const authHeader =
req.header("Authorization") || req.header("authorization");
const token = authHeader ? authHeader.slice("Bearer ".length).trim() : null;
if (!token) {
return res.status(401).json({ error: "missing token" });
}
const session = await db.sessions.findFirst({
where: { token_hash: hashToken(token), is_revoked: false },
include: { users: true },
});
if (!session) {
return res.status(401).json({ error: "invalid token" });
}
if (!timestamp || !signature) {
return res.status(400).json({ error: "missing required headers" });
}
try {
const result = await callGateFromReq(req, {
client: "mandiri",
action: "verifySignature",
data: {
timestamp,
signature,
httpmethod: req.method === "POST" ? "POST" : "GET",
endpointurl: req.originalUrl,
accesstoken: token,
requestbody: req.body,
typeVerify: "transaction",
},
});
console.log({ result });
if (!result) {
return res.status(401).json({
responseCode: "4012400",
responseMessage: `Unauthorized`,
});
}
} catch (err) {
const message: any = get(err, "message", "internal_error");
if (
message === "Invalid access token" ||
message === "Access token expired"
) {
return res.status(401).json({
responseCode: "4012400",
responseMessage: `Unauthorized ${message}`,
});
} else {
console.error("vaMiddleware error", err);
return res.status(500).json({
responseCode: "5002401",
responseMessage: `Internal Server Error ${message}`,
});
}
}
// Prefer RSA verification if the user record contains a public cert
const user = session.users;
// some projects store certs in different user columns; access via any to avoid TS errors
const uAny = user as any;
const certPem = (uAny &&
(uAny.public_cert ||
uAny.cert_pem ||
uAny.certificate ||
uAny.publicCert)) as string | undefined;
const clientId = (uAny &&
(uAny.client_id ||
uAny.clientbank_id ||
uAny.clientKey ||
uAny.clientkey)) as string | undefined;
let ok = false;
if (authHeader) {
const token = authHeader.slice("Bearer ".length).trim();
const tokenHash = hashToken(token);
const session = await db.sessions.findFirst({
where: { token_hash: tokenHash, is_revoked: false },
});
// If we have an auth header, we're good
return authMiddleware(req as any, res, next as any);
} else {
return res.status(401).json({ error: "missing token" });
}
} catch (err) {
console.error("vaMiddleware error", err);
return res.status(500).json({ error: "internal_error" });
}
}

9
src/prisma.ts Normal file
View File

@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;

120
src/routes/index.ts Normal file
View File

@ -0,0 +1,120 @@
import { Router } from "express";
import { IndexController, AuthController, DbController } from "../controllers";
import { transferController } from "../controllers";
import { paymentVAController } from "../controllers";
import { signController } from "../controllers";
import { authMiddleware, getUserFromToken } from "../middleware/auth";
import { vaMiddleware } from "../middleware/vaAuth";
import uploadController, {
singleUploadHandler,
multiUploadHandler,
} from "../controllers/uploadController";
import * as tokenController from "../controllers/tokenController";
import SyncController from "../controllers/syncController";
import { getPoolForDbId } from "../lib/dbPools";
import db from "../db";
const router = Router();
const indexController = new IndexController();
const authController = new AuthController();
const dbController = new DbController();
export const setRoutes = () => {
router.get("/", indexController.getIndex);
router.post("/auth/login", (req, res) => authController.login(req, res));
// bootstrap superadmin (no auth required) - use only once
router.post("/auth/superadmin", (req, res) =>
authController.createSuperAdmin(req, res)
);
router.post("/auth/logout", (req, res) => authController.logout(req, res));
router.get("/auth/me", authMiddleware, (req, res) =>
authController.me(req, res)
);
router.get("/me", authMiddleware, async (req, res) => {
const authHeader =
req.header("Authorization") || req.header("authorization");
const token = authHeader ? authHeader.slice("Bearer ".length).trim() : null;
if (!token) {
return res.status(401).json({ error: "missing token" });
}
const u = await getUserFromToken(token);
if (!u) throw new Error("invalid token");
const users = await db.users.findFirst({
select: {
user_id: true,
username: true,
},
where: { user_id: u.user.user_id },
});
return res.json({ result: users });
});
router.post("/test-db", async (req, res) => {
try {
const { name, query } = req.body;
if (!name || !query)
return res.status(400).json({ error: "name and query are required" });
// find database metadata by name (or code)
const dbRec = await (
await import("../prisma")
).default.database.findFirst({ where: { name: name } });
if (!dbRec) return res.status(404).json({ error: "database not found" });
const pool = await getPoolForDbId(dbRec.db_id);
const result = await pool.query(query);
return res.json({ rows: result.rows, rowCount: result.rowCount });
} catch (err) {
console.error("Test endpoint error", err);
return res.status(500).json({
status: "error",
detail: err instanceof Error ? err.message : String(err),
});
}
});
// Signature generation for testing (uses PRIVATE_KEY_PATH file)
router.post("/generate-signature", (req, res) =>
signController.generateSignature(req, res)
);
// Verify signature using public cert (default: public/mandiri/midsuit.cer)
router.post("/verify-signature", (req, res) =>
signController.verifySignature(req, res)
);
router.post("/db", (req, res) => dbController.handle(req, res));
// File uploads (protected)
router.post(
"/upload",
authMiddleware,
(req, res, next) => singleUploadHandler(req, res, next),
(req, res) => uploadController.single(req, res)
);
router.post(
"/upload/multiple",
authMiddleware,
(req, res, next) => multiUploadHandler(req, res, next),
(req, res) => uploadController.multiple(req, res)
);
// Public access to uploaded files. All paths under /uploads/* are served by preview.
router.get("/uploads/*", (req, res) => uploadController.preview(req, res));
// versioned API group (/:version)
const versionRouter = Router({ mergeParams: true });
versionRouter.post("/transfer-va/inquiry", vaMiddleware, (req, res) =>
transferController.inquiry(req, res)
);
versionRouter.post("/transfer-va/payment", vaMiddleware, (req, res) =>
paymentVAController.payment(req, res)
);
versionRouter.post("/access-token/b2b", (req, res) =>
tokenController.b2bAccessToken(req, res)
);
router.use("/:version", versionRouter);
// Sync endpoints group
const syncRouter = Router();
syncRouter.post("/rv_openitem", authMiddleware, (req, res) =>
SyncController.syncRvOpenitem(req, res)
);
router.use("/sync", syncRouter);
// Add more routes here as needed
return router;
};

11
src/types/index.ts Normal file
View File

@ -0,0 +1,11 @@
export interface CustomRequest extends Express.Request {
user?: any; // Extend with user type if needed
}
export interface CustomResponse extends Express.Response {
// Add any custom response properties if needed
}
export interface NextFunction {
(err?: any): void;
}

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "**/*.spec.ts"]
}

View File

@ -0,0 +1,50 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIE9jAoBgoqhkiG9w0BDAEDMBoEFBoweltnH/cXGWgpnf48R23zh+1DAgIEAASC
BMhEU7JLa3I76Vg5p9EpJ0eVyRunPOYN9y44AB0K5wSG1ZHTakHhrykR0mPMcxAc
MYJTLkNOMlXVm88w31f32pI6MmUDyUNAjEaVm4DuR39a3v1p6I13yfyYFvKelrFJ
FcitLBp2X4SYjfuYnADVnqg19Lg5IuPTPIHWtHKACrCiYr7+R7GQ1NLU4l5XeoYM
tavXINquSLni394wsuMFjaUzXtG74uKJuMmcVw8IfN7oM2/WYGLAKPG03bIBMnzj
Ky6UFZbXaJiDKFuNMl6DDe9e2m4HJP3m2RIBvfdjRutE8JeUoLXS/MDP3LNAWAT2
Ibne+S1+Ey5BZdUHpno1YesCttul/t9FUodoN/U3k5OFNqwZ1QXNqLiJK47Or8qr
allBaUaJ/03qggIx30NiAB5H3bxVtbkgFe8w6C2QMUWGjqJpHWKCNvgtNVBVDqLx
olxwRLj6GlQeAOPU7++0/pAvMCRpmqSzjWIBiYsrqsltO1epBAYHGyoybl1DgXfE
7rKvBrw9X9rBGxbxuXSTaCesd2SG1K3Q8JuoeXGU1rh9yeNLZYF2j/+O5F7ncSxU
8BGe+9B5em04yyXYlov0ohWxam6j0aMCSrcoaHdB3wB/fcSzuWo4H8l5PEy49qCX
348f6iTX10zRVVifHB82bxFWmjQJou3FwJbOF9OSpIEZ94TQlgAivRvh/iRJtdQb
xDt9JO19ol5oppED+xr7wublGQeKYauwxdPPevYJboP7nUMXwAet7735sYpE1EWx
M1ZCshSYTQ3El0nUcg4dmmL0UIpeTkzlNVN7ekWXy06qiOCRxZbrcqvsyAWtuVco
vH3bldjTsmk6sa+Y3yNdHL++4qmq6kiPKgDSLUCAuXJwE3RnFTnQUwqCa6hdmVcr
BydwDaWHNjSdqyOX9N5bd72ypjOksdSapOxZ5mMAWKFIxGWIyQaeuzYbj6uPowHk
zoHEiV92I9HD5icgOtoc+Axc5VIsvUGRMxHh26sSewN58uOyxQzVmTx63FDu+qY1
R8t6JPzbZbDiKuZn/l2RSkpCx1ykkQ161HKikpieJaXuypOaiurxy1VLuDCnLaWj
OkM1rs2I1PYN6v2SAbS7rpL+CkFrCBR/cvD/2aYMafc9ntmuRQGgxC82Lw32htmE
M49zlCnTh2VJeH3g5MAEvzYJn6m9X+2kLiNFJN+diD3Xgl41mP5Bpa8K78Wsilo6
4VTTZA2UE+JMp3lhZN4kTUs7IK7Ew6ofj4L0p3veKUOyDZO/3NAZ2teGV7SU8VxF
Zw74TGQbc5ECkBlDnbPM+5RgSMkPn/7+En8YPLpQwdXizWSKJ6vgHER54BkG87UR
RRmcLvbep5nkywKwZRpT4mrwEWbfOvUB8418Uqzp/UQQa8SAFx9fkeDcQnSpJCI+
Em8ASE+MoQ49RI8hip4qseAjto/EUXcQKCNsLUp+TKmKc/F0Esgz84hIDW3Q7k6/
GtRNVkNdGrKWJWS9XKjrAtqxOmKfMd0bEdXTi1emKurExWlS8Ypmif3N5Dg8ttGn
uy+VdS7XEIi/Mu3nFezeUjB6mOZgwa27Kwz4hck761Ax0n9nTTXWQzaxqWdANGFp
GfQKu40kf+UKsGnBt6VvlSsxtjGVpNhtKGw=
-----END ENCRYPTED PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDdjCCAl6gAwIBAgIUcYZImdyq8uwwZRR/fDUBme4FfGIwDQYJKoZIhvcNAQEL
BQAwdTELMAkGA1UEBhMCSUQxETAPBgNVBAgMCFN1cmFiYXlhMREwDwYDVQQHDAhT
dXJhYmF5YTEPMA0GA1UECgwGQXZvbHV0MR0wGwYDVQQLDBRQYXltZW5kIGFuZCBE
ZWxpdmVyeTEQMA4GA1UEAwwHTUlEU1VJVDAeFw0yNTEwMTYxNzE2MDBaFw0yNjEw
MTYxNzE2MDBaMHUxCzAJBgNVBAYTAklEMREwDwYDVQQIDAhTdXJhYmF5YTERMA8G
A1UEBwwIU3VyYWJheWExDzANBgNVBAoMBkF2b2x1dDEdMBsGA1UECwwUUGF5bWVu
ZCBhbmQgRGVsaXZlcnkxEDAOBgNVBAMMB01JRFNVSVQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCCPS9oByk00FV3adzcjFtES0vwOqL2/4c5QsA8u1l6
hdBsqxDtKKiVBApiOfk4BGdZPiawQ3/uKOJ4pBaklq4N+U7X1E8SJ08UcSes8HKE
dSShMpWy3VSR/qOPu/6uVkcTdTXsnZpokoegfG0ALM3YIZDi3uwNx3n3yb3JABM9
FqFxmAcQvI9Wk6qXL9I4N2rJEKFWtB0FegxYRfZX6Cz5Uvcc3D5HV/XHe+x7RBnX
c22XB21PSdzIYlTcb7EG1CSgJV7Qqs2gIMJErv3uW/VDMGS6vedktDuJ1uncti/I
ncUbIUhqCmOnq+g3LE6b9VwBNPVGt4TgkisYdof8YenXAgMBAAEwDQYJKoZIhvcN
AQELBQADggEBAAybKW2ERAz5n9AkP3qu1LiP6Sv76B/GEhL067FrTLwSOICBvyax
ZhAdcLimYho2qnjR2yi9UD9QEcDTbS/QvRpBlnsDUP+UuT+jCZTshYe7gPRGfrh8
k6d1gcZ1cTwOIru4+NcCiqSuQh/3oaI7+cBVqoLhuMhUOLXdb0dWzEBjqOt94oc6
RNRXZ+YCVewU7bZWhnXHHX/EB08ob3rXY4g56m9fhBUwMTyHc2077Z0iDfeIS/U6
uBE8qUIyVpPNid3IMQ+tojHEXuBqqlL7zMVs+ldqhXrnlt65C3/ytPfOphb0kdVv
E06hTtigs1SsIkh0M0gqjihzRpv/O0qQC14=
-----END CERTIFICATE-----

Binary file not shown.