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), }; } }