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:
commit
7feaeff415
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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-----
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 || [];
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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.
Binary file not shown.
Loading…
Reference in New Issue