Prepare BizOne portal production wallet and UI
This commit is contained in:
@ -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 {}
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
9
backend/src/auth/dto/change-password.dto.ts
Normal file
9
backend/src/auth/dto/change-password.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
currentPassword!: string;
|
||||
|
||||
@IsString()
|
||||
newPassword!: string;
|
||||
}
|
||||
8
backend/src/auth/dto/update-profile.dto.ts
Normal file
8
backend/src/auth/dto/update-profile.dto.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
}
|
||||
@ -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,
|
||||
) {}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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(
|
||||
|
||||
12
backend/src/wallet/dto/create-manual-topup.dto.ts
Normal file
12
backend/src/wallet/dto/create-manual-topup.dto.ts
Normal 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;
|
||||
}
|
||||
12
backend/src/wallet/dto/create-midtrans-topup.dto.ts
Normal file
12
backend/src/wallet/dto/create-midtrans-topup.dto.ts
Normal 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;
|
||||
}
|
||||
55
backend/src/wallet/wallet.controller.ts
Normal file
55
backend/src/wallet/wallet.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/wallet/wallet.module.ts
Normal file
12
backend/src/wallet/wallet.module.ts
Normal 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 {}
|
||||
512
backend/src/wallet/wallet.service.ts
Normal file
512
backend/src/wallet/wallet.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
) {}
|
||||
|
||||
Reference in New Issue
Block a user