209 lines
6.8 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|