ledgerone_backend/src/accounts/accounts.service.ts

209 lines
6.8 KiB
TypeScript

import { BadRequestException, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { PlaidService } from "../plaid/plaid.service";
import { TellerService } from "../teller/teller.service";
import { OpaqueIdService } from "../common/opaque-id.service";
import { PlanLimitsService } from "../stripe/plan-limits.service";
import { UpdateAccountOwnershipDto } from "./dto/update-account-ownership.dto";
const UI_PAGE_SIZE_LIMIT = 25;
@Injectable()
export class AccountsService {
constructor(
private readonly prisma: PrismaService,
private readonly plaidService: PlaidService,
private readonly tellerService: TellerService,
private readonly opaqueIds: OpaqueIdService,
private readonly planLimits: PlanLimitsService,
) {}
async list(userId: string, page = 1, limit = 20) {
const requestedLimit = Number.isFinite(limit) && limit ? limit : 20;
const take = Math.min(Math.max(requestedLimit, 1), UI_PAGE_SIZE_LIMIT);
const skip = (page - 1) * take;
const [accounts, total] = await Promise.all([
this.prisma.account.findMany({
where: { userId, isActive: true },
orderBy: { createdAt: "desc" },
skip,
take,
select: {
id: true,
institutionName: true,
accountType: true,
mask: true,
currentBalance: true,
availableBalance: true,
isoCurrencyCode: true,
lastBalanceSync: true,
lastTransactionSync: true,
lastSyncAttemptAt: true,
syncStatus: true,
lastSyncError: true,
syncConsecutiveFailures: true,
plaidWebhookCode: true,
plaidWebhookAt: true,
tellerAccountId: true,
householdId: true,
ownerUserId: true,
ownershipType: true,
isActive: true,
createdAt: true,
// Intentionally omit plaidAccessToken — never expose the encrypted token
},
}),
this.prisma.account.count({ where: { userId, isActive: true } }),
]);
return {
accounts: accounts.map((account) => ({
id: this.opaqueIds.encode("account", userId, account.id),
institutionName: account.institutionName,
accountType: account.accountType,
mask: account.mask,
currentBalance: account.currentBalance,
availableBalance: account.availableBalance,
isoCurrencyCode: account.isoCurrencyCode,
lastBalanceSync: account.lastBalanceSync,
lastTransactionSync: account.lastTransactionSync,
lastSyncAttemptAt: account.lastSyncAttemptAt,
syncStatus: account.syncStatus,
lastSyncError: account.lastSyncError,
syncConsecutiveFailures: account.syncConsecutiveFailures,
plaidWebhookCode: account.plaidWebhookCode,
plaidWebhookAt: account.plaidWebhookAt,
tellerConnected: Boolean(account.tellerAccountId),
householdId: account.householdId,
ownerUserId: account.ownerUserId,
ownershipType: account.ownershipType,
isActive: account.isActive,
createdAt: account.createdAt,
})),
total,
page,
limit: take,
};
}
async createLinkToken(userId: string) {
await this.planLimits.assertCanAddAccounts(userId, 1);
return this.plaidService.createLinkToken(userId);
}
async refreshBalances(userId: string) {
const [plaid, teller] = await Promise.all([
this.plaidService.syncBalancesForUser(userId),
this.tellerService.syncBalancesForUser(userId),
]);
return { updated: plaid.updated + teller.updated, plaid, teller };
}
async createManualAccount(
userId: string,
payload: { institutionName: string; accountType: string; mask?: string },
) {
await this.planLimits.assertCanAddAccounts(userId, 1);
const account = await this.prisma.account.create({
data: {
userId,
institutionName: payload.institutionName,
accountType: payload.accountType,
mask: payload.mask ?? null,
ownerUserId: userId,
ownershipType: "mine",
isActive: true,
},
select: {
id: true,
institutionName: true,
accountType: true,
mask: true,
ownerUserId: true,
ownershipType: true,
isActive: true,
createdAt: true,
},
});
return {
...account,
id: this.opaqueIds.encode("account", userId, account.id),
};
}
async updateOwnership(userId: string, accountHandle: string, payload: UpdateAccountOwnershipDto) {
const accountId = this.opaqueIds.decode("account", userId, accountHandle);
const account = await this.prisma.account.findFirst({
where: { id: accountId, userId, isActive: true },
});
if (!account) throw new BadRequestException("Account not found.");
let householdId: string | null = null;
let ownerUserId: string | null = userId;
if (payload.ownershipType === "mine") {
householdId = null;
ownerUserId = userId;
} else {
if (!payload.householdId) {
throw new BadRequestException("Household is required for shared account ownership.");
}
const requesterMembership = await this.prisma.householdMember.findFirst({
where: { householdId: payload.householdId, userId, status: "active" },
});
if (!requesterMembership) throw new BadRequestException("Household not found.");
householdId = payload.householdId;
if (payload.ownershipType === "joint") {
ownerUserId = null;
} else {
if (!payload.ownerUserId || payload.ownerUserId === userId) {
throw new BadRequestException("Owner user must be another active household member for 'theirs' accounts.");
}
const ownerMembership = await this.prisma.householdMember.findFirst({
where: { householdId, userId: payload.ownerUserId, status: "active" },
});
if (!ownerMembership) throw new BadRequestException("Owner must be an active household member.");
ownerUserId = payload.ownerUserId;
}
}
const updated = await this.prisma.account.update({
where: { id: accountId },
data: {
ownershipType: payload.ownershipType,
householdId,
ownerUserId,
},
select: {
id: true,
institutionName: true,
accountType: true,
mask: true,
householdId: true,
ownerUserId: true,
ownershipType: true,
isActive: true,
createdAt: true,
},
});
await this.prisma.auditLog.create({
data: {
userId,
action: "account.ownership.update",
metadata: {
accountId,
householdId,
ownerUserId,
ownershipType: payload.ownershipType,
},
},
});
return {
...updated,
id: this.opaqueIds.encode("account", userId, updated.id),
};
}
}