Prepare BizOne portal production wallet and UI

This commit is contained in:
2026-05-22 13:13:10 +07:00
parent 36be8607e0
commit 5144207c42
124 changed files with 11034 additions and 7720 deletions

View File

@ -14,6 +14,7 @@ import { UsersModule } from './users/users.module';
import { CampaignsModule } from './campaigns/campaigns.module';
import { ConversationsModule } from './conversations/conversations.module';
import { TemplatesModule } from './templates/templates.module';
import { WalletModule } from './wallet/wallet.module';
@Module({
imports: [
@ -32,6 +33,7 @@ import { TemplatesModule } from './templates/templates.module';
TemplatesModule,
CampaignsModule,
ConversationsModule,
WalletModule,
],
})
export class AppModule {}

View File

@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
@ -10,6 +10,8 @@ import { RequestPasswordResetDto } from './dto/request-password-reset.dto';
import { CompletePasswordResetDto } from './dto/complete-password-reset.dto';
import { TwoFactorCodeDto } from './dto/two-factor-code.dto';
import { VerifyTwoFactorLoginDto } from './dto/verify-two-factor-login.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
@Controller('auth')
export class AuthController {
@ -82,6 +84,28 @@ export class AuthController {
return this.authService.getCurrentSession(request.user, ipAddress);
}
@UseGuards(AuthGuard)
@Patch('profile')
updateProfile(
@Req() request: Request & { user: AuthenticatedUser },
@Body() body: UpdateProfileDto,
) {
const forwarded = request.headers['x-forwarded-for'];
const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip;
return this.authService.updateProfile(request.user, body, ipAddress);
}
@UseGuards(AuthGuard)
@Patch('profile/password')
changePassword(
@Req() request: Request & { user: AuthenticatedUser },
@Body() body: ChangePasswordDto,
) {
const forwarded = request.headers['x-forwarded-for'];
const ipAddress = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : request.ip;
return this.authService.changePassword(request.user, body.currentPassword, body.newPassword, ipAddress);
}
@UseGuards(AuthGuard)
@Get('2fa/status')
getTwoFactorStatus(@Req() request: Request & { user: AuthenticatedUser }) {

View File

@ -337,6 +337,9 @@ export class AuthService {
name: true,
email: true,
status: true,
role: {
select: { name: true },
},
lastLoginAt: true,
refreshTokenExpiresAt: true,
twoFactorEnabled: true,
@ -354,6 +357,7 @@ export class AuthService {
name: user.name,
email: user.email,
status: user.status,
roleName: user.role?.name || 'Unassigned',
lastLoginAt: user.lastLoginAt,
twoFactorEnabled: user.twoFactorEnabled,
twoFactorConfirmedAt: user.twoFactorConfirmedAt,
@ -368,6 +372,111 @@ export class AuthService {
};
}
async updateProfile(authUser: AuthenticatedUser, dto: { name?: string }, ipAddress?: string) {
const current = await this.prisma.user.findUnique({
where: { id: authUser.sub },
select: { id: true, name: true, email: true },
});
if (!current) {
throw new UnauthorizedException('User not found');
}
const name = dto.name !== undefined ? dto.name.trim() : current.name;
if (!name) {
throw new HttpException('Name is required', HttpStatus.BAD_REQUEST);
}
const user = await this.prisma.user.update({
where: { id: current.id },
data: {
name,
},
include: {
role: {
select: { name: true },
},
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: user.id,
actorName: user.name,
actorEmail: user.email,
actionType: 'Profile Updated',
module: 'Account Profile',
ipAddress: ipAddress || null,
severity: 'default',
details: `Updated profile for ${user.email}.`,
},
});
const response = {
id: user.id,
name: user.name,
email: user.email,
status: user.status,
roleName: user.role?.name || 'Unassigned',
lastLoginAt: user.lastLoginAt,
twoFactorEnabled: user.twoFactorEnabled,
twoFactorConfirmedAt: user.twoFactorConfirmedAt,
};
return response;
}
async changePassword(authUser: AuthenticatedUser, currentPassword: string, newPassword: string, ipAddress?: string) {
if (!hasMinimumPasswordLength(newPassword)) {
throw new HttpException('New password must be at least 8 characters', HttpStatus.BAD_REQUEST);
}
const user = await this.prisma.user.findUnique({
where: { id: authUser.sub },
select: {
id: true,
name: true,
email: true,
passwordHash: true,
},
});
if (!user || !user.passwordHash) {
throw new UnauthorizedException('User not found');
}
const isPasswordValid = await comparePassword(currentPassword, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException('Current password is incorrect');
}
await this.prisma.user.update({
where: { id: user.id },
data: {
passwordHash: await hashPassword(newPassword),
sessionVersion: { increment: 1 },
refreshTokenHash: null,
refreshTokenExpiresAt: null,
},
});
await this.prisma.auditLog.create({
data: {
actorUserId: user.id,
actorName: user.name,
actorEmail: user.email,
actionType: 'Password Changed',
module: 'Account Profile',
ipAddress: ipAddress || null,
severity: 'warning',
details: `Changed password for ${user.email}.`,
},
});
return { success: true };
}
async getTwoFactorStatus(userId: string) {
const twoFactor = await this.getTwoFactorRecord(userId);
if (!twoFactor) {

View File

@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class ChangePasswordDto {
@IsString()
currentPassword!: string;
@IsString()
newPassword!: string;
}

View File

@ -0,0 +1,8 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateProfileDto {
@IsString()
@IsOptional()
name?: string;
}

View File

@ -1,4 +1,4 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import type { Job as BullJob, Worker } from 'bullmq';
import { JobsService } from '../jobs/jobs.service';
import { RedisQueueService } from '../jobs/redis-queue.service';
@ -10,6 +10,7 @@ export class CampaignWorkerService implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly jobsService: JobsService,
@Inject(RedisQueueService)
private readonly redisQueueService: RedisQueueService,
private readonly campaignsService: CampaignsService,
) {}

View File

@ -81,6 +81,12 @@ export class CampaignsController {
return this.campaignsService.send(id, dto, request.user, request.ip);
}
@Get(':id/estimate-cost')
@RequirePermission('campaigns', 'view')
estimateCost(@Param('id') id: string) {
return this.campaignsService.estimateCost(id);
}
@Get(':id/export')
@RequirePermission('campaigns', 'view')
async export(

View File

@ -3,12 +3,13 @@ import { AuthModule } from '../auth/auth.module';
import { JobsModule } from '../jobs/jobs.module';
import { PrismaModule } from '../prisma/prisma.module';
import { TemplatesModule } from '../templates/templates.module';
import { WalletModule } from '../wallet/wallet.module';
import { CampaignWorkerService } from './campaign-worker.service';
import { CampaignsController } from './campaigns.controller';
import { CampaignsService } from './campaigns.service';
@Module({
imports: [PrismaModule, AuthModule, JobsModule, TemplatesModule],
imports: [PrismaModule, AuthModule, JobsModule, TemplatesModule, WalletModule],
controllers: [CampaignsController],
providers: [CampaignsService, CampaignWorkerService],
exports: [CampaignsService],

View File

@ -7,6 +7,7 @@ import { normalizeText } from '../common/normalize';
import { JobsService } from '../jobs/jobs.service';
import { PrismaService } from '../prisma/prisma.service';
import { TemplatesService } from '../templates/templates.service';
import { WalletService } from '../wallet/wallet.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { SendCampaignDto } from './dto/send-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
@ -157,6 +158,7 @@ export class CampaignsService {
private readonly prisma: PrismaService,
private readonly jobsService: JobsService,
private readonly templatesService: TemplatesService,
private readonly walletService: WalletService,
) {}
async findAll() {
@ -410,6 +412,21 @@ export class CampaignsService {
return { id: campaign.id, deleted: true };
}
async estimateCost(id: string) {
await this.ensureSeedData();
const campaign = await this.prisma.campaign.findUnique({ where: { id } });
if (!campaign) {
throw new NotFoundException('Campaign not found');
}
return {
campaignId: campaign.id,
campaignName: campaign.name,
...this.walletService.estimateBroadcastCost(campaign.totalRecipients),
};
}
async send(id: string, dto: SendCampaignDto, user: AuthenticatedUser, ipAddress?: string) {
await this.ensureSeedData();
const actor = await this.findActor(user.sub, user.email);
@ -419,6 +436,12 @@ export class CampaignsService {
throw new NotFoundException('Campaign not found');
}
const walletCheck = await this.walletService.assertSufficientBalanceForBroadcast({
id: campaign.id,
name: campaign.name,
totalRecipients: campaign.totalRecipients,
});
const requestedMode = dto.mode === 'scheduled' ? 'scheduled' : 'now';
const requestedScheduleAt = dto.scheduledAt
? new Date(dto.scheduledAt)
@ -463,6 +486,7 @@ export class CampaignsService {
campaignId: id,
jobId: job.id,
availableAt: availableAt.toISOString(),
walletCheck,
} as Prisma.InputJsonValue,
},
});
@ -472,6 +496,7 @@ export class CampaignsService {
jobId: job.id,
status: shouldSchedule ? 'Scheduled' : 'Queued',
availableAt: availableAt.toISOString(),
walletCheck,
};
}
@ -914,6 +939,12 @@ export class CampaignsService {
},
});
const walletCharge = await this.walletService.chargeSuccessfulBroadcastInTransaction(
tx,
{ id: campaign.id, name: campaign.name },
deliveredCount,
);
await tx.auditLog.create({
data: {
actorUserId: null,
@ -922,7 +953,14 @@ export class CampaignsService {
actionType: 'Campaign Delivered',
module: 'Campaigns',
severity: 'default',
details: `Processed campaign ${campaign.name} with ${totalRecipients} recipients.`,
details: `Processed campaign ${campaign.name} with ${totalRecipients} recipients and charged ${deliveredCount} successful messages.`,
metadataJson: {
campaignId: campaign.id,
totalRecipients,
deliveredCount,
failedCount,
walletCharge,
} as Prisma.InputJsonValue,
},
});
});

View File

@ -25,6 +25,7 @@ const fallbackRolePermissions: Record<string, Record<string, Partial<Record<Perm
conversations: { view: true, edit: true, delete: true, manage: true },
analytics: { view: true, edit: true, delete: true, manage: true },
settings: { view: true, edit: true, delete: true, manage: true },
wallet: { view: true, edit: true, delete: true, manage: true },
},
editor: {
campaigns: { view: true, edit: true, delete: false, manage: false },

View File

@ -26,15 +26,23 @@ export class IntegrationsService {
})
.then((storedConfig) => {
const configJson = (storedConfig?.configJson as Record<string, unknown> | null) ?? {};
const sharedSecret = String(configJson.sharedSecret || config.webhookSharedSecret || '');
const appSecret = String(configJson.appSecret || config.metaWebhookAppSecret || '');
const accessToken = typeof configJson.accessToken === 'string' ? configJson.accessToken : '';
const showSensitiveValues = !config.isProduction;
return {
provider: String(configJson.provider || storedConfig?.provider || 'meta'),
webhookUrl: `${config.publicApiUrl.replace(/\/$/, '')}/api/webhooks/whatsapp`,
verifyToken: String(configJson.webhookVerifyToken || config.webhookVerifyToken),
status: storedConfig ? (storedConfig.isEnabled ? 'configured' : 'disabled') : 'not-configured',
hasSharedSecret: Boolean(configJson.sharedSecret || config.webhookSharedSecret),
hasAppSecret: Boolean(configJson.appSecret || config.metaWebhookAppSecret),
hasAccessToken: Boolean(configJson.accessToken),
hasSharedSecret: Boolean(sharedSecret),
hasAppSecret: Boolean(appSecret),
hasAccessToken: Boolean(accessToken),
showSensitiveValues,
sharedSecret: showSensitiveValues ? sharedSecret : '',
appSecret: showSensitiveValues ? appSecret : '',
accessToken: showSensitiveValues ? accessToken : '',
phoneNumberId: typeof configJson.phoneNumberId === 'string' ? configJson.phoneNumberId : '',
isEnabled: storedConfig?.isEnabled ?? true,
subscriptions: Array.isArray(configJson.subscriptions)

View File

@ -5,13 +5,21 @@ import { getAppConfig } from '../config/env';
@Injectable()
export class RedisQueueService implements OnModuleDestroy {
private readonly connection = new IORedis(getAppConfig().redisUrl, {
maxRetriesPerRequest: null,
});
private readonly useMemoryStore = getAppConfig().redisUrl === 'memory';
private readonly connection = this.useMemoryStore
? null
: new IORedis(getAppConfig().redisUrl, {
maxRetriesPerRequest: null,
});
private readonly queues = new Map<string, Queue>();
private readonly workers: Worker[] = [];
private readonly memoryCounters = new Map<string, { value: number; expiresAt: number }>();
getQueue(name: string) {
if (!this.connection) {
return null;
}
const existing = this.queues.get(name);
if (existing) {
return existing;
@ -26,20 +34,55 @@ export class RedisQueueService implements OnModuleDestroy {
async enqueue(name: string, jobName: string, data: Record<string, unknown>, options?: JobsOptions) {
const queue = this.getQueue(name);
if (!queue) {
return;
}
await queue.add(jobName, data, options);
}
async getCounter(key: string) {
if (!this.connection) {
const item = this.memoryCounters.get(key);
if (!item || item.expiresAt <= Date.now()) {
this.memoryCounters.delete(key);
return 0;
}
return item.value;
}
const value = await this.connection.get(key);
return value ? Number(value) : 0;
}
async getTtlSeconds(key: string) {
if (!this.connection) {
const item = this.memoryCounters.get(key);
if (!item || item.expiresAt <= Date.now()) {
this.memoryCounters.delete(key);
return 0;
}
return Math.ceil((item.expiresAt - Date.now()) / 1000);
}
const ttl = await this.connection.ttl(key);
return ttl > 0 ? ttl : 0;
}
async incrementCounter(key: string, ttlSeconds: number) {
if (!this.connection) {
const now = Date.now();
const existing = this.memoryCounters.get(key);
const nextValue = existing && existing.expiresAt > now ? existing.value + 1 : 1;
this.memoryCounters.set(key, {
value: nextValue,
expiresAt: existing && existing.expiresAt > now ? existing.expiresAt : now + ttlSeconds * 1000,
});
return nextValue;
}
const nextCount = await this.connection.incr(key);
if (nextCount === 1) {
await this.connection.expire(key, ttlSeconds);
@ -49,10 +92,19 @@ export class RedisQueueService implements OnModuleDestroy {
}
async deleteKey(key: string) {
if (!this.connection) {
this.memoryCounters.delete(key);
return;
}
await this.connection.del(key);
}
createWorker(name: string, processor: Processor) {
if (!this.connection) {
return null;
}
const worker = new Worker(name, processor, {
connection: this.connection,
concurrency: 5,
@ -64,6 +116,6 @@ export class RedisQueueService implements OnModuleDestroy {
async onModuleDestroy() {
await Promise.all(this.workers.map((worker) => worker.close()));
await Promise.all(Array.from(this.queues.values()).map((queue) => queue.close()));
await this.connection.quit();
await this.connection?.quit();
}
}

View File

@ -69,8 +69,10 @@ export class LogsController {
@Query('actionType') actionType?: string,
@Query('module') moduleName?: string,
@Query('search') search?: string,
@Query('format') format?: string,
) {
const csv = await this.logsService.exportAuditTrailCsv(
const result = await this.logsService.exportAuditTrail(
format === 'xlsx' ? 'xlsx' : 'csv',
range,
actorName,
actionType,
@ -78,9 +80,9 @@ export class LogsController {
search,
);
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
response.setHeader('Content-Disposition', 'attachment; filename="audit-trail-export.csv"');
response.send(csv);
response.setHeader('Content-Type', result.contentType);
response.setHeader('Content-Disposition', `attachment; filename="${result.fileName}"`);
response.send(result.buffer);
}
@Post('audit-trail')

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import * as XLSX from 'xlsx';
import { AuthenticatedUser } from '../auth/auth.types';
import { JobsService } from '../jobs/jobs.service';
import { PrismaService } from '../prisma/prisma.service';
@ -78,7 +79,8 @@ export class LogsService {
});
}
async exportAuditTrailCsv(
async exportAuditTrail(
format: 'csv' | 'xlsx',
range?: string,
actorName?: string,
actionType?: string,
@ -86,23 +88,41 @@ export class LogsService {
search?: string,
) {
const result = await this.getAuditTrail(1, 5000, range, actorName, actionType, moduleName, search);
const rows = result.items.map((item) => ({
Timestamp: item.createdAt.toISOString(),
'Admin User': item.actorName,
'Admin Email': item.actorEmail || '',
'Action Type': item.actionType,
Module: item.module,
'IP Address': item.ipAddress || '',
Severity: item.severity,
Details: item.details,
}));
if (format === 'xlsx') {
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.json_to_sheet(rows);
XLSX.utils.book_append_sheet(workbook, worksheet, 'Audit Trail');
return {
fileName: 'audit-trail-export.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
buffer: XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer,
};
}
const header = ['Timestamp', 'Admin User', 'Admin Email', 'Action Type', 'Module', 'IP Address', 'Severity', 'Details'];
const rows = result.items.map((item) =>
[
item.createdAt.toISOString(),
item.actorName,
item.actorEmail || '',
item.actionType,
item.module,
item.ipAddress || '',
item.severity,
item.details,
]
.map((cell) => `"${String(cell).replaceAll('"', '""')}"`)
const csvRows = rows.map((row) =>
header
.map((key) => `"${String(row[key as keyof typeof row]).replaceAll('"', '""')}"`)
.join(','),
);
return [header.join(','), ...rows].join('\n');
return {
fileName: 'audit-trail-export.csv',
contentType: 'text/csv; charset=utf-8',
buffer: Buffer.from([header.join(','), ...csvRows].join('\n'), 'utf8'),
};
}
async createAuditLog(

View File

@ -0,0 +1,12 @@
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class CreateManualTopupDto {
@IsInt()
@Min(50000)
@Max(1000000000)
amountMinor!: number;
@IsOptional()
@IsString()
description?: string;
}

View File

@ -0,0 +1,12 @@
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class CreateMidtransTopupDto {
@IsInt()
@Min(50000)
@Max(1000000000)
amountMinor!: number;
@IsOptional()
@IsString()
description?: string;
}

View File

@ -0,0 +1,55 @@
import { Body, Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { AuthenticatedUser } from '../auth/auth.types';
import { AuthGuard } from '../common/auth.guard';
import { RequirePermission } from '../common/permission.decorator';
import { PermissionGuard } from '../common/permission.guard';
import { CreateMidtransTopupDto } from './dto/create-midtrans-topup.dto';
import { CreateManualTopupDto } from './dto/create-manual-topup.dto';
import { WalletService } from './wallet.service';
@UseGuards(AuthGuard, PermissionGuard)
@Controller('wallet')
export class WalletController {
constructor(private readonly walletService: WalletService) {}
@Get()
@RequirePermission('settings', 'view')
getSummary(@Query('limit') limit?: string) {
return this.walletService.getSummary(limit ? Number(limit) : 20);
}
@Get('estimate-broadcast')
@RequirePermission('campaigns', 'view')
estimateBroadcast(@Query('recipients') recipients?: string) {
return this.walletService.estimateBroadcastCost(Number(recipients || '0'));
}
@Post('topups/manual')
@RequirePermission('settings', 'manage')
createManualTopup(
@Req() request: Request & { user: AuthenticatedUser },
@Body() dto: CreateManualTopupDto,
) {
return this.walletService.createManualTopup(dto.amountMinor, dto.description, request.user, request.ip);
}
@Post('topups/midtrans')
@RequirePermission('settings', 'manage')
createMidtransTopup(
@Req() request: Request & { user: AuthenticatedUser },
@Body() dto: CreateMidtransTopupDto,
) {
return this.walletService.createMidtransTopup(dto.amountMinor, dto.description, request.user);
}
}
@Controller('wallet/midtrans')
export class WalletMidtransWebhookController {
constructor(private readonly walletService: WalletService) {}
@Post('notification')
handleNotification(@Body() payload: Record<string, unknown>) {
return this.walletService.handleMidtransNotification(payload);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { WalletController, WalletMidtransWebhookController } from './wallet.controller';
import { WalletService } from './wallet.service';
@Module({
imports: [AuthModule],
controllers: [WalletController, WalletMidtransWebhookController],
providers: [WalletService],
exports: [WalletService],
})
export class WalletModule {}

View File

@ -0,0 +1,512 @@
import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
import { AuthenticatedUser } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_OWNER_KEY = 'default-business-wallet';
const DEFAULT_CURRENCY = 'IDR';
const DEFAULT_MARKETING_RATE_MINOR = 500;
const MINIMUM_TOPUP_MINOR = 50000;
const MIDTRANS_PAYMENT_TYPES = ['qris', 'gopay', 'shopeepay', 'bank_transfer', 'credit_card'];
@Injectable()
export class WalletService {
constructor(private readonly prisma: PrismaService) {}
estimateBroadcastCost(recipientCount: number) {
const normalizedRecipients = Math.max(0, Math.floor(Number(recipientCount) || 0));
const unitCostMinor = DEFAULT_MARKETING_RATE_MINOR;
const estimatedAmountMinor = normalizedRecipients * unitCostMinor;
return {
currency: DEFAULT_CURRENCY,
recipientCount: normalizedRecipients,
unitCostMinor,
estimatedAmountMinor,
};
}
async getSummary(limit = 20) {
const wallet = await this.getOrCreateWallet();
const transactions = await this.prisma.walletTransaction.findMany({
where: { walletId: wallet.id },
orderBy: { createdAt: 'desc' },
take: Math.min(Math.max(limit, 1), 100),
});
return {
wallet: this.serializeWallet(wallet),
transactions: transactions.map((transaction) => ({
id: transaction.id,
type: transaction.type,
direction: transaction.direction,
amountMinor: transaction.amountMinor,
balanceBeforeMinor: transaction.balanceBeforeMinor,
balanceAfterMinor: transaction.balanceAfterMinor,
status: transaction.status,
referenceType: transaction.referenceType,
referenceId: transaction.referenceId,
description: transaction.description,
createdAt: transaction.createdAt.toISOString(),
})),
pricing: {
broadcastMarketingUnitCostMinor: DEFAULT_MARKETING_RATE_MINOR,
currency: DEFAULT_CURRENCY,
},
};
}
async createManualTopup(amountMinor: number, description: string | undefined, user: AuthenticatedUser, ipAddress?: string) {
const normalizedAmount = Math.floor(Number(amountMinor) || 0);
if (normalizedAmount < MINIMUM_TOPUP_MINOR) {
throw new BadRequestException('Top up amount must be at least Rp 50.000.');
}
const actor = await this.findActor(user.sub, user.email);
const wallet = await this.getOrCreateWallet();
const result = await this.prisma.$transaction(async (tx) => {
const current = await tx.wallet.findUnique({ where: { id: wallet.id } });
if (!current) throw new BadRequestException('Wallet not found');
const nextBalance = current.balanceMinor + normalizedAmount;
const updated = await tx.wallet.update({
where: { id: current.id },
data: { balanceMinor: nextBalance },
});
const transaction = await tx.walletTransaction.create({
data: {
id: randomUUID(),
walletId: current.id,
type: 'manual_topup',
direction: 'credit',
amountMinor: normalizedAmount,
balanceBeforeMinor: current.balanceMinor,
balanceAfterMinor: nextBalance,
status: 'posted',
referenceType: 'manual_adjustment',
referenceId: randomUUID(),
description: description || 'Manual wallet top up',
metadataJson: {
actorUserId: actor.id,
actorEmail: actor.email,
ipAddress: ipAddress || null,
} as Prisma.InputJsonValue,
},
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
actionType: 'Wallet Manual Top Up',
module: 'Wallet',
ipAddress: ipAddress || null,
severity: 'default',
details: `Added Rp ${normalizedAmount.toLocaleString('id-ID')} to wallet.`,
metadataJson: {
walletId: current.id,
transactionId: transaction.id,
amountMinor: normalizedAmount,
} as Prisma.InputJsonValue,
},
});
return { wallet: updated, transaction };
});
return {
wallet: this.serializeWallet(result.wallet),
transaction: {
id: result.transaction.id,
amountMinor: result.transaction.amountMinor,
direction: result.transaction.direction,
type: result.transaction.type,
createdAt: result.transaction.createdAt.toISOString(),
},
};
}
async createMidtransTopup(amountMinor: number, description: string | undefined, user: AuthenticatedUser) {
const normalizedAmount = Math.floor(Number(amountMinor) || 0);
if (normalizedAmount < MINIMUM_TOPUP_MINOR) {
throw new BadRequestException('Top up amount must be at least Rp 50.000.');
}
const serverKey = process.env.MIDTRANS_SERVER_KEY;
if (!serverKey) {
throw new BadRequestException('Midtrans server key is not configured.');
}
const actor = await this.findActor(user.sub, user.email);
const wallet = await this.getOrCreateWallet();
const providerOrderId = `BIZONE-TOPUP-${Date.now()}-${randomUUID().slice(0, 8)}`;
const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:3000';
const apiBaseUrl = this.getMidtransSnapBaseUrl();
const enabledPayments = this.getMidtransEnabledPayments();
const order = await this.prisma.paymentOrder.create({
data: {
id: randomUUID(),
walletId: wallet.id,
provider: 'midtrans',
providerOrderId,
amountMinor: normalizedAmount,
currency: DEFAULT_CURRENCY,
status: 'pending',
expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
metadataJson: {
description: description || 'Wallet top up',
actorUserId: actor.id,
actorEmail: actor.email,
enabledPayments,
gatewayFeeChargedToCustomer: true,
} as Prisma.InputJsonValue,
},
});
const response = await fetch(`${apiBaseUrl}/snap/v1/transactions`, {
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${serverKey}:`).toString('base64')}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
transaction_details: {
order_id: providerOrderId,
gross_amount: normalizedAmount,
},
enabled_payments: enabledPayments,
credit_card: {
secure: true,
},
customer_details: {
first_name: actor.name || 'BizOne User',
email: actor.email || undefined,
},
item_details: [
{
id: 'wallet-topup',
price: normalizedAmount,
quantity: 1,
name: description || 'BizOne Wallet Top Up',
},
],
callbacks: {
finish: `${frontendOrigin}/dashboard/wallet`,
},
expiry: {
unit: 'hour',
duration: 24,
},
}),
});
const payload = await response.json().catch(() => ({})) as { token?: string; redirect_url?: string; error_messages?: string[] };
if (!response.ok || !payload.token) {
await this.prisma.paymentOrder.update({
where: { id: order.id },
data: {
status: 'failed',
metadataJson: {
...(order.metadataJson && typeof order.metadataJson === 'object' && !Array.isArray(order.metadataJson) ? order.metadataJson : {}),
midtransError: payload.error_messages || [`Midtrans request failed with status ${response.status}`],
} as Prisma.InputJsonValue,
},
});
throw new BadRequestException(payload.error_messages?.join(', ') || 'Failed to create Midtrans payment.');
}
const updated = await this.prisma.paymentOrder.update({
where: { id: order.id },
data: {
snapToken: payload.token,
redirectUrl: payload.redirect_url || null,
},
});
return {
id: updated.id,
providerOrderId: updated.providerOrderId,
amountMinor: updated.amountMinor,
currency: updated.currency,
status: updated.status,
snapToken: updated.snapToken,
redirectUrl: updated.redirectUrl,
enabledPayments,
};
}
async handleMidtransNotification(payload: Record<string, unknown>) {
const serverKey = process.env.MIDTRANS_SERVER_KEY;
if (!serverKey) {
throw new BadRequestException('Midtrans server key is not configured.');
}
const orderId = String(payload.order_id || '');
const statusCode = String(payload.status_code || '');
const grossAmount = String(payload.gross_amount || '');
const signatureKey = String(payload.signature_key || '');
const transactionStatus = String(payload.transaction_status || '');
const fraudStatus = String(payload.fraud_status || '');
const expectedSignature = createHash('sha512')
.update(`${orderId}${statusCode}${grossAmount}${serverKey}`)
.digest('hex');
if (!orderId || signatureKey !== expectedSignature) {
throw new BadRequestException('Invalid Midtrans notification signature.');
}
const paymentOrder = await this.prisma.paymentOrder.findUnique({
where: { providerOrderId: orderId },
});
if (!paymentOrder) {
throw new BadRequestException('Payment order not found.');
}
const isSuccess =
transactionStatus === 'settlement' ||
(transactionStatus === 'capture' && (!fraudStatus || fraudStatus === 'accept'));
const isFailed = ['cancel', 'deny', 'expire', 'failure'].includes(transactionStatus);
const nextStatus = isSuccess ? 'paid' : isFailed ? 'failed' : transactionStatus || 'pending';
if (!isSuccess) {
await this.prisma.paymentOrder.update({
where: { id: paymentOrder.id },
data: {
status: nextStatus,
metadataJson: {
...(paymentOrder.metadataJson && typeof paymentOrder.metadataJson === 'object' && !Array.isArray(paymentOrder.metadataJson) ? paymentOrder.metadataJson : {}),
lastNotification: payload,
} as Prisma.InputJsonValue,
},
});
return { received: true, status: nextStatus };
}
const result = await this.prisma.$transaction(async (tx) => {
const currentOrder = await tx.paymentOrder.findUnique({
where: { id: paymentOrder.id },
});
if (!currentOrder) throw new BadRequestException('Payment order not found.');
if (currentOrder.status === 'paid') {
return { credited: false, alreadyPaid: true };
}
const wallet = await tx.wallet.findUnique({ where: { id: currentOrder.walletId } });
if (!wallet) throw new BadRequestException('Wallet not found.');
const nextBalance = wallet.balanceMinor + currentOrder.amountMinor;
const updatedWallet = await tx.wallet.update({
where: { id: wallet.id },
data: { balanceMinor: nextBalance },
});
const transaction = await tx.walletTransaction.create({
data: {
id: randomUUID(),
walletId: wallet.id,
type: 'midtrans_topup',
direction: 'credit',
amountMinor: currentOrder.amountMinor,
balanceBeforeMinor: wallet.balanceMinor,
balanceAfterMinor: nextBalance,
status: 'posted',
referenceType: 'payment_order',
referenceId: currentOrder.id,
description: `Midtrans top up ${currentOrder.providerOrderId}`,
metadataJson: {
providerOrderId: currentOrder.providerOrderId,
notification: payload,
} as Prisma.InputJsonValue,
},
});
await tx.paymentOrder.update({
where: { id: currentOrder.id },
data: {
status: 'paid',
paidAt: new Date(),
metadataJson: {
...(currentOrder.metadataJson && typeof currentOrder.metadataJson === 'object' && !Array.isArray(currentOrder.metadataJson) ? currentOrder.metadataJson : {}),
walletTransactionId: transaction.id,
lastNotification: payload,
} as Prisma.InputJsonValue,
},
});
return { credited: true, wallet: this.serializeWallet(updatedWallet), transactionId: transaction.id };
});
return { received: true, status: 'paid', ...result };
}
async assertSufficientBalanceForBroadcast(campaign: { id: string; name: string; totalRecipients: number }) {
const estimate = this.estimateBroadcastCost(campaign.totalRecipients);
if (estimate.estimatedAmountMinor <= 0) {
return { sufficient: true, availableAmountMinor: 0, ...estimate };
}
const wallet = await this.getOrCreateWallet();
const available = wallet.balanceMinor - wallet.heldMinor;
if (available < estimate.estimatedAmountMinor) {
throw new HttpException(
{
message: 'Insufficient wallet balance for this broadcast.',
requiredAmountMinor: estimate.estimatedAmountMinor,
availableAmountMinor: available,
currency: wallet.currency,
},
HttpStatus.PAYMENT_REQUIRED,
);
}
return {
sufficient: true,
availableAmountMinor: available,
...estimate,
};
}
async chargeSuccessfulBroadcastInTransaction(
tx: Prisma.TransactionClient,
campaign: { id: string; name: string },
successfulCount: number,
) {
const estimate = this.estimateBroadcastCost(successfulCount);
if (estimate.estimatedAmountMinor <= 0) {
return { charged: false, reason: 'no_successful_messages', ...estimate };
}
const existingCharge = await tx.walletTransaction.findFirst({
where: {
type: 'broadcast_charge',
referenceType: 'campaign',
referenceId: campaign.id,
status: 'posted',
},
});
if (existingCharge) {
return { charged: false, alreadyCharged: true, transactionId: existingCharge.id, ...estimate };
}
const current = await tx.wallet.upsert({
where: { ownerKey: DEFAULT_OWNER_KEY },
update: {},
create: {
id: randomUUID(),
ownerKey: DEFAULT_OWNER_KEY,
currency: DEFAULT_CURRENCY,
balanceMinor: 0,
heldMinor: 0,
status: 'active',
},
});
const available = current.balanceMinor - current.heldMinor;
if (available < estimate.estimatedAmountMinor) {
throw new HttpException(
{
message: 'Insufficient wallet balance for successful broadcast delivery.',
requiredAmountMinor: estimate.estimatedAmountMinor,
availableAmountMinor: available,
currency: current.currency,
},
HttpStatus.PAYMENT_REQUIRED,
);
}
const nextBalance = current.balanceMinor - estimate.estimatedAmountMinor;
const updated = await tx.wallet.update({
where: { id: current.id },
data: { balanceMinor: nextBalance },
});
const transaction = await tx.walletTransaction.create({
data: {
id: randomUUID(),
walletId: current.id,
type: 'broadcast_charge',
direction: 'debit',
amountMinor: estimate.estimatedAmountMinor,
balanceBeforeMinor: current.balanceMinor,
balanceAfterMinor: nextBalance,
status: 'posted',
referenceType: 'campaign',
referenceId: campaign.id,
description: `Broadcast success charge for ${campaign.name}`,
metadataJson: {
campaignId: campaign.id,
campaignName: campaign.name,
successfulMessageCount: estimate.recipientCount,
unitCostMinor: estimate.unitCostMinor,
} as Prisma.InputJsonValue,
},
});
return {
charged: true,
transactionId: transaction.id,
wallet: this.serializeWallet(updated),
...estimate,
};
}
private async getOrCreateWallet() {
return this.prisma.wallet.upsert({
where: { ownerKey: DEFAULT_OWNER_KEY },
update: {},
create: {
id: randomUUID(),
ownerKey: DEFAULT_OWNER_KEY,
currency: DEFAULT_CURRENCY,
balanceMinor: 0,
heldMinor: 0,
status: 'active',
},
});
}
private getMidtransSnapBaseUrl() {
return process.env.MIDTRANS_ENV === 'production'
? 'https://app.midtrans.com'
: 'https://app.sandbox.midtrans.com';
}
private getMidtransEnabledPayments() {
const configured = (process.env.MIDTRANS_ALLOWED_PAYMENT_TYPES || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
const selected = configured.length > 0 ? configured : MIDTRANS_PAYMENT_TYPES;
return selected.filter((item) => MIDTRANS_PAYMENT_TYPES.includes(item));
}
private serializeWallet(wallet: { id: string; ownerKey: string; currency: string; balanceMinor: number; heldMinor: number; status: string; updatedAt: Date }) {
return {
id: wallet.id,
ownerKey: wallet.ownerKey,
currency: wallet.currency,
balanceMinor: wallet.balanceMinor,
heldMinor: wallet.heldMinor,
availableBalanceMinor: wallet.balanceMinor - wallet.heldMinor,
status: wallet.status,
updatedAt: wallet.updatedAt.toISOString(),
};
}
private async findActor(userId: string, email?: string) {
const actor = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true },
});
return actor || { id: userId, name: email || 'System User', email: email || null };
}
}

View File

@ -1,4 +1,4 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import type { Job as BullJob, Worker } from 'bullmq';
import { JobsService } from '../jobs/jobs.service';
import { RedisQueueService } from '../jobs/redis-queue.service';
@ -10,6 +10,7 @@ export class WebhookWorkerService implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly jobsService: JobsService,
@Inject(RedisQueueService)
private readonly redisQueueService: RedisQueueService,
private readonly webhooksService: WebhooksService,
) {}