checkpoint: goals feature, wallet balance, and goals/wallet detail UI

- Add goals feature (models, migrations, API, web pages)
- Add reserved/centralized wallet balance service
- Add wallet detail page and overview components
- Add new UI components (progress, multi-select, FAB)
- Remove stray empty -H/-d files from working tree
This commit is contained in:
Dwindi Ramadhana
2026-06-17 20:40:00 +07:00
parent 35e93b826a
commit 6a6e74562c
401 changed files with 9517 additions and 397 deletions

303
apps/api/dist/goals/goals.service.js vendored Executable file
View File

@@ -0,0 +1,303 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.GoalsService = void 0;
const common_1 = require("@nestjs/common");
const prisma_service_1 = require("../prisma/prisma.service");
const wallet_balance_service_1 = require("../wallets/wallet-balance.service");
const library_1 = require("@prisma/client/runtime/library");
let GoalsService = class GoalsService {
prisma;
walletBalanceService;
constructor(prisma, walletBalanceService) {
this.prisma = prisma;
this.walletBalanceService = walletBalanceService;
}
async create(userId, createGoalDto) {
const goal = await this.prisma.goal.create({
data: {
userId,
name: createGoalDto.name,
description: createGoalDto.description,
targetAmount: new library_1.Decimal(createGoalDto.targetAmount),
currency: createGoalDto.currency,
targetDate: createGoalDto.targetDate ? new Date(createGoalDto.targetDate) : null,
imageUrl: createGoalDto.imageUrl,
category: createGoalDto.category,
},
include: {
allocations: true,
milestones: true,
},
});
await this.createMilestones(goal.id, new library_1.Decimal(createGoalDto.targetAmount));
return this.findOne(userId, goal.id);
}
async findAll(userId, status) {
const where = { userId };
if (status) {
where.status = status;
}
return this.prisma.goal.findMany({
where,
include: {
allocations: {
include: {
wallet: true,
},
},
milestones: {
orderBy: { percentage: 'asc' },
},
},
orderBy: { createdAt: 'desc' },
});
}
async findOne(userId, id) {
const goal = await this.prisma.goal.findFirst({
where: { id, userId },
include: {
allocations: {
include: {
wallet: true,
},
orderBy: { createdAt: 'desc' },
},
milestones: {
orderBy: { percentage: 'asc' },
},
},
});
if (!goal) {
throw new common_1.NotFoundException('Goal not found');
}
return goal;
}
async update(userId, id, updateGoalDto) {
await this.findOne(userId, id);
const updateData = {};
if (updateGoalDto.name !== undefined)
updateData.name = updateGoalDto.name;
if (updateGoalDto.description !== undefined)
updateData.description = updateGoalDto.description;
if (updateGoalDto.targetAmount !== undefined) {
updateData.targetAmount = new library_1.Decimal(updateGoalDto.targetAmount);
await this.prisma.goalMilestone.deleteMany({ where: { goalId: id } });
await this.createMilestones(id, new library_1.Decimal(updateGoalDto.targetAmount));
}
if (updateGoalDto.currency !== undefined)
updateData.currency = updateGoalDto.currency;
if (updateGoalDto.targetDate !== undefined) {
updateData.targetDate = updateGoalDto.targetDate ? new Date(updateGoalDto.targetDate) : null;
}
if (updateGoalDto.imageUrl !== undefined)
updateData.imageUrl = updateGoalDto.imageUrl;
if (updateGoalDto.category !== undefined)
updateData.category = updateGoalDto.category;
if (updateGoalDto.status !== undefined) {
updateData.status = updateGoalDto.status;
if (updateGoalDto.status === 'completed') {
updateData.completedAt = new Date();
}
}
const goal = await this.prisma.goal.update({
where: { id },
data: updateData,
include: {
allocations: {
include: {
wallet: true,
},
},
milestones: true,
},
});
return goal;
}
async remove(userId, id) {
await this.findOne(userId, id);
await this.prisma.goal.delete({
where: { id },
});
return { message: 'Goal deleted successfully' };
}
async addAllocation(userId, goalId, createAllocationDto) {
const goal = await this.findOne(userId, goalId);
const wallet = await this.prisma.wallet.findFirst({
where: {
id: createAllocationDto.walletId,
userId,
deletedAt: null,
},
});
if (!wallet) {
throw new common_1.NotFoundException('Wallet not found');
}
const walletBalance = await this.walletBalanceService.calculateBalance(wallet.id);
const allocationAmountInIDR = new library_1.Decimal(createAllocationDto.amount);
let amountInWalletCurrency = allocationAmountInIDR;
let exchangeRate = new library_1.Decimal(1);
if (wallet.currency && wallet.currency !== 'IDR') {
exchangeRate = await this.getExchangeRate('IDR', wallet.currency);
amountInWalletCurrency = allocationAmountInIDR.times(exchangeRate);
}
else if (wallet.kind === 'asset' && wallet.pricePerUnit) {
amountInWalletCurrency = allocationAmountInIDR.dividedBy(wallet.pricePerUnit);
}
if (walletBalance.availableBalance.lessThan(amountInWalletCurrency)) {
const currency = wallet.kind === 'money' ? wallet.currency : wallet.unit;
throw new common_1.BadRequestException(`Insufficient available balance. Available: ${walletBalance.availableBalance.toString()} ${currency}, Reserved: ${walletBalance.reservedBalance.toString()} ${currency}`);
}
const allocation = await this.prisma.goalAllocation.create({
data: {
goalId,
walletId: wallet.id,
amount: allocationAmountInIDR,
currency: 'IDR',
exchangeRate: wallet.currency !== 'IDR' ? exchangeRate : null,
amountInGoalCurrency: allocationAmountInIDR,
notes: createAllocationDto.notes,
createdBy: userId,
},
include: {
wallet: true,
},
});
const newCurrentAmount = new library_1.Decimal(goal.currentAmount).plus(allocationAmountInIDR);
await this.prisma.goal.update({
where: { id: goalId },
data: { currentAmount: newCurrentAmount },
});
await this.prisma.wallet.update({
where: { id: wallet.id },
data: {
reservedBalance: {
increment: amountInWalletCurrency,
},
},
});
await this.updateMilestones(goalId, newCurrentAmount);
return allocation;
}
async removeAllocation(userId, goalId, allocationId) {
const goal = await this.findOne(userId, goalId);
const allocation = await this.prisma.goalAllocation.findFirst({
where: {
id: allocationId,
goalId,
},
});
if (!allocation) {
throw new common_1.NotFoundException('Allocation not found');
}
const newCurrentAmount = new library_1.Decimal(goal.currentAmount).minus(allocation.amountInGoalCurrency);
await this.prisma.goal.update({
where: { id: goalId },
data: { currentAmount: newCurrentAmount.greaterThanOrEqualTo(0) ? newCurrentAmount : new library_1.Decimal(0) },
});
await this.prisma.wallet.update({
where: { id: allocation.walletId },
data: {
reservedBalance: {
decrement: allocation.amount,
},
},
});
await this.prisma.goalAllocation.delete({
where: { id: allocationId },
});
await this.updateMilestones(goalId, newCurrentAmount);
return { message: 'Allocation removed successfully' };
}
async getStats(userId) {
const goals = await this.prisma.goal.findMany({
where: { userId },
include: {
allocations: true,
},
});
const totalGoals = goals.length;
const activeGoals = goals.filter(g => g.status === 'active').length;
const completedGoals = goals.filter(g => g.status === 'completed').length;
let totalTargetAmount = new library_1.Decimal(0);
let totalCurrentAmount = new library_1.Decimal(0);
for (const goal of goals) {
if (goal.status === 'active') {
totalTargetAmount = totalTargetAmount.plus(goal.targetAmount);
totalCurrentAmount = totalCurrentAmount.plus(goal.currentAmount);
}
}
const overallProgress = totalTargetAmount.greaterThan(0)
? totalCurrentAmount.dividedBy(totalTargetAmount).times(100).toNumber()
: 0;
return {
totalGoals,
activeGoals,
completedGoals,
totalTargetAmount: totalTargetAmount.toNumber(),
totalCurrentAmount: totalCurrentAmount.toNumber(),
overallProgress: Math.round(overallProgress * 100) / 100,
};
}
async createMilestones(goalId, targetAmount) {
const percentages = [25, 50, 75, 100];
for (const percentage of percentages) {
await this.prisma.goalMilestone.create({
data: {
goalId,
percentage,
targetAmount: targetAmount.times(percentage).dividedBy(100),
},
});
}
}
async updateMilestones(goalId, currentAmount) {
const milestones = await this.prisma.goalMilestone.findMany({
where: { goalId },
orderBy: { percentage: 'asc' },
});
for (const milestone of milestones) {
if (currentAmount.greaterThanOrEqualTo(milestone.targetAmount) && !milestone.achievedAt) {
await this.prisma.goalMilestone.update({
where: { id: milestone.id },
data: { achievedAt: new Date() },
});
}
else if (currentAmount.lessThan(milestone.targetAmount) && milestone.achievedAt) {
await this.prisma.goalMilestone.update({
where: { id: milestone.id },
data: { achievedAt: null },
});
}
}
}
async getExchangeRate(fromCurrency, toCurrency) {
const rates = {
'USD': 15000,
'EUR': 16500,
'GBP': 19000,
'JPY': 100,
'SGD': 11000,
'MYR': 3500,
'IDR': 1,
};
const fromRate = rates[fromCurrency] || 1;
const toRate = rates[toCurrency] || 1;
return new library_1.Decimal(fromRate).dividedBy(toRate);
}
};
exports.GoalsService = GoalsService;
exports.GoalsService = GoalsService = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
wallet_balance_service_1.WalletBalanceService])
], GoalsService);
//# sourceMappingURL=goals.service.js.map