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:
303
apps/api/dist/goals/goals.service.js
vendored
Executable file
303
apps/api/dist/goals/goals.service.js
vendored
Executable 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
|
||||
Reference in New Issue
Block a user