import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { Configuration, CountryCode, PlaidApi, PlaidEnvironments, Products, } from "plaid"; import * as crypto from "crypto"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { EncryptionService } from "../common/encryption.service"; import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service"; import { PlanLimitsService } from "../stripe/plan-limits.service"; @Injectable() export class PlaidService { private readonly logger = new Logger(PlaidService.name); private readonly client: PlaidApi; private readonly webhookKeys = new Map(); constructor( private readonly prisma: PrismaService, private readonly encryption: EncryptionService, private readonly planLimits: PlanLimitsService, private readonly rawPayloads: RawPayloadEncryptionService, ) { const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments; const clientId = this.requireEnv("PLAID_CLIENT_ID"); const secret = this.requireEnv("PLAID_SECRET"); const config = new Configuration({ basePath: PlaidEnvironments[env] ?? PlaidEnvironments.sandbox, baseOptions: { headers: { "PLAID-CLIENT-ID": clientId, "PLAID-SECRET": secret, "Plaid-Version": "2020-09-14", }, }, }); this.client = new PlaidApi(config); } async createLinkToken(userId: string) { const products = (process.env.PLAID_PRODUCTS ?? "transactions") .split(",") .map((item) => item.trim()) .filter(Boolean) as Products[]; const countryCodes = (process.env.PLAID_COUNTRY_CODES ?? "US") .split(",") .map((item) => item.trim()) .filter(Boolean) as CountryCode[]; const redirectUri = process.env.PLAID_REDIRECT_URI?.trim(); try { const response = await this.client.linkTokenCreate({ user: { client_user_id: userId }, client_name: "LedgerOne", products, country_codes: countryCodes, language: "en", redirect_uri: redirectUri || undefined, webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined, }); return { linkToken: response.data.link_token, expiration: response.data.expiration, }; } catch (error: unknown) { const err = error as { response?: { data?: { error_message?: string } } }; const message = err.response?.data?.error_message ?? "Plaid link token request failed."; throw new BadRequestException(message); } } async exchangePublicTokenForUser(userId: string, publicToken: string) { const exchange = await this.client.itemPublicTokenExchange({ public_token: publicToken, }); const rawAccessToken = exchange.data.access_token; const itemId = exchange.data.item_id; // Encrypt before storing const encryptedToken = this.encryption.encrypt(rawAccessToken); const accountsResponse = await this.client.accountsGet({ access_token: rawAccessToken, }); const institutionId = accountsResponse.data.item?.institution_id; const institutionName = institutionId ? await this.getInstitutionName(institutionId) : "Plaid institution"; const incomingAccountIds = accountsResponse.data.accounts.map((account) => account.account_id); const existingAccounts = await this.prisma.account.findMany({ where: { userId, plaidAccountId: { in: incomingAccountIds } }, select: { plaidAccountId: true }, }); const existingIds = new Set(existingAccounts.map((account) => account.plaidAccountId)); const newAccountCount = incomingAccountIds.filter((id) => !existingIds.has(id)).length; await this.planLimits.assertCanAddAccounts(userId, newAccountCount); for (const account of accountsResponse.data.accounts) { await this.prisma.account.upsert({ where: { plaidAccountId: account.account_id }, update: { institutionName, accountType: account.subtype ?? account.type, mask: account.mask ?? null, plaidAccessToken: encryptedToken, plaidItemId: itemId, currentBalance: account.balances.current ?? null, availableBalance: account.balances.available ?? null, isoCurrencyCode: account.balances.iso_currency_code ?? null, lastBalanceSync: new Date(), userId, }, create: { userId, institutionName, accountType: account.subtype ?? account.type, mask: account.mask ?? null, plaidAccessToken: encryptedToken, plaidItemId: itemId, plaidAccountId: account.account_id, currentBalance: account.balances.current ?? null, availableBalance: account.balances.available ?? null, isoCurrencyCode: account.balances.iso_currency_code ?? null, lastBalanceSync: new Date(), isActive: true, }, }); } return { itemId, accountCount: accountsResponse.data.accounts.length, }; } async syncBalancesForUser(userId: string) { const accounts = await this.prisma.account.findMany({ where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }, }); // Deduplicate by decrypted token const tokenMap = new Map(); for (const acct of accounts) { if (!acct.plaidAccessToken) continue; const raw = this.encryption.decrypt(acct.plaidAccessToken); tokenMap.set(acct.plaidAccessToken, raw); } let updated = 0; for (const [, rawToken] of tokenMap) { const response = await this.client.accountsBalanceGet({ access_token: rawToken }); for (const account of response.data.accounts) { const record = await this.prisma.account.updateMany({ where: { plaidAccountId: account.account_id, userId }, data: { currentBalance: account.balances.current ?? null, availableBalance: account.balances.available ?? null, isoCurrencyCode: account.balances.iso_currency_code ?? null, lastBalanceSync: new Date(), }, }); updated += record.count; } } return { updated }; } async syncTransactionsForUser(userId: string, startDate: string, endDate: string) { const accounts = await this.prisma.account.findMany({ where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }, }); // Build map: raw decrypted token → Plaid account ids for one item. const tokenMap = new Map(); for (const account of accounts) { if (!account.plaidAccessToken || !account.plaidAccountId) continue; const raw = this.encryption.decrypt(account.plaidAccessToken); const list = tokenMap.get(raw) ?? []; list.push(account.plaidAccountId); tokenMap.set(raw, list); } const now = new Date(); await this.prisma.account.updateMany({ where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }, data: { syncStatus: "syncing", lastSyncAttemptAt: now, lastSyncError: null }, }); let created = 0; for (const [rawToken, plaidAccountIds] of tokenMap) { try { const response = await this.client.transactionsGet({ access_token: rawToken, start_date: startDate, end_date: endDate, options: { count: 500, offset: 0 }, }); for (const tx of response.data.transactions) { const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id); if (!account) continue; const rawPayload = this.rawPayloads.encrypt(tx); await this.prisma.transactionRaw.upsert({ where: { bankTransactionId: tx.transaction_id }, update: { accountId: account.id, date: new Date(tx.date), amount: tx.amount, description: tx.name ?? "Plaid transaction", rawPayload, source: "plaid", ingestedAt: new Date(), }, create: { accountId: account.id, bankTransactionId: tx.transaction_id, date: new Date(tx.date), amount: tx.amount, description: tx.name ?? "Plaid transaction", rawPayload, ingestedAt: new Date(), source: "plaid", }, }); created += 1; } await this.prisma.account.updateMany({ where: { userId, plaidAccountId: { in: plaidAccountIds } }, data: { syncStatus: "idle", lastTransactionSync: new Date(), lastSyncError: null, syncConsecutiveFailures: 0, }, }); } catch (error: unknown) { const err = error as { response?: { data?: { error_message?: string; error_code?: string } }; message?: string }; const message = err.response?.data?.error_message ?? err.message ?? "Plaid transaction sync failed."; await this.prisma.account.updateMany({ where: { userId, plaidAccountId: { in: plaidAccountIds } }, data: { syncStatus: "error", lastSyncError: message.slice(0, 500), syncConsecutiveFailures: { increment: 1 }, }, }); } } return { created }; } async createUpdateModeLinkToken(userId: string, accountId: string) { const account = await this.prisma.account.findFirst({ where: { id: accountId, userId, plaidAccessToken: { not: null }, plaidItemId: { not: null }, isActive: true, }, select: { plaidAccessToken: true, }, }); if (!account?.plaidAccessToken) { throw new BadRequestException("Plaid account not found for update mode."); } const rawAccessToken = this.encryption.decrypt(account.plaidAccessToken); try { const response = await this.client.linkTokenCreate({ user: { client_user_id: userId }, client_name: "LedgerOne", country_codes: this.getCountryCodes(), language: "en", access_token: rawAccessToken, redirect_uri: process.env.PLAID_REDIRECT_URI?.trim() || undefined, webhook: process.env.PLAID_WEBHOOK_URL?.trim() || undefined, }); return { linkToken: response.data.link_token, expiration: response.data.expiration, }; } catch (error: unknown) { const err = error as { response?: { data?: { error_message?: string } } }; const message = err.response?.data?.error_message ?? "Plaid update-mode link token request failed."; throw new BadRequestException(message); } } async markItemRepairComplete(userId: string, accountId: string) { const account = await this.prisma.account.findFirst({ where: { id: accountId, userId, plaidItemId: { not: null }, isActive: true, }, select: { plaidItemId: true, }, }); if (!account?.plaidItemId) { throw new BadRequestException("Plaid account not found for repair completion."); } const updated = await this.prisma.account.updateMany({ where: { userId, plaidItemId: account.plaidItemId, }, data: { syncStatus: "idle", lastSyncError: null, syncConsecutiveFailures: 0, plaidWebhookCode: "UPDATE_MODE_COMPLETED", plaidWebhookAt: new Date(), }, }); return { updated: updated.count }; } async handleWebhook(payload: PlaidWebhookPayload, verificationHeader?: string, rawBody?: Buffer) { if (this.shouldVerifyWebhooks()) { const valid = await this.verifyWebhook(verificationHeader, rawBody); if (!valid) { throw new BadRequestException("Invalid Plaid webhook signature."); } } const webhookType = payload.webhook_type ?? "UNKNOWN"; const webhookCode = payload.webhook_code ?? "UNKNOWN"; const itemId = payload.item_id ?? null; const event = await this.prisma.plaidWebhookEvent.create({ data: { itemId, webhookType, webhookCode, payload: payload as Prisma.InputJsonValue, }, }); try { const result = await this.processWebhook(payload); await this.prisma.plaidWebhookEvent.update({ where: { id: event.id }, data: { status: "processed", processedAt: new Date(), }, }); return { received: true, processed: true, webhookType, webhookCode, ...result }; } catch (error: unknown) { const message = error instanceof Error ? error.message : "Plaid webhook processing failed."; this.logger.error(`Plaid webhook ${webhookType}:${webhookCode} failed: ${message}`); await this.prisma.plaidWebhookEvent.update({ where: { id: event.id }, data: { status: "error", error: message.slice(0, 500), processedAt: new Date(), }, }); throw error; } } private async processWebhook(payload: PlaidWebhookPayload) { if (!payload.item_id) { return { skipped: true, reason: "missing_item_id" }; } const accounts = await this.prisma.account.findMany({ where: { plaidItemId: payload.item_id }, select: { userId: true }, distinct: ["userId"], }); const userIds = accounts.map((account) => account.userId); if (userIds.length === 0) { return { skipped: true, reason: "item_not_found" }; } const webhookType = payload.webhook_type ?? "UNKNOWN"; const webhookCode = payload.webhook_code ?? "UNKNOWN"; await this.prisma.account.updateMany({ where: { plaidItemId: payload.item_id }, data: { plaidWebhookCode: webhookCode, plaidWebhookAt: new Date(), }, }); if ( webhookType === "TRANSACTIONS" && ["SYNC_UPDATES_AVAILABLE", "INITIAL_UPDATE", "HISTORICAL_UPDATE", "DEFAULT_UPDATE"].includes(webhookCode) ) { const days = Number(process.env.PLAID_WEBHOOK_LOOKBACK_DAYS ?? process.env.AUTO_SYNC_LOOKBACK_DAYS ?? 30); const endDate = new Date().toISOString().slice(0, 10); const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); let created = 0; for (const userId of userIds) { const result = await this.syncTransactionsForUser(userId, startDate, endDate); created += result.created; } return { usersSynced: userIds.length, created }; } if (webhookType === "ITEM") { return this.handleItemWebhook(payload, userIds); } return { skipped: true, reason: "unsupported_webhook" }; } private async handleItemWebhook(payload: PlaidWebhookPayload, userIds: string[]) { if (!payload.item_id) return { skipped: true, reason: "missing_item_id" }; const webhookCode = payload.webhook_code ?? "UNKNOWN"; const errorCode = payload.error?.error_code ?? webhookCode; const errorMessage = payload.error?.error_message ?? webhookCode; if (webhookCode === "ERROR" || errorCode === "ITEM_LOGIN_REQUIRED") { await this.prisma.account.updateMany({ where: { plaidItemId: payload.item_id }, data: { syncStatus: "needs_reauth", lastSyncError: errorMessage.slice(0, 500), syncConsecutiveFailures: { increment: 1 }, }, }); return { usersMarked: userIds.length, status: "needs_reauth" }; } if (["PENDING_EXPIRATION", "PENDING_DISCONNECT", "NEW_ACCOUNTS_AVAILABLE"].includes(webhookCode)) { await this.prisma.account.updateMany({ where: { plaidItemId: payload.item_id }, data: { syncStatus: "attention_required", lastSyncError: webhookCode, }, }); return { usersMarked: userIds.length, status: "attention_required" }; } if (["USER_PERMISSION_REVOKED", "USER_ACCOUNT_REVOKED"].includes(webhookCode)) { await this.prisma.account.updateMany({ where: { plaidItemId: payload.item_id }, data: { isActive: false, syncStatus: "revoked", lastSyncError: webhookCode, }, }); return { usersMarked: userIds.length, status: "revoked" }; } if (webhookCode === "LOGIN_REPAIRED") { await this.prisma.account.updateMany({ where: { plaidItemId: payload.item_id }, data: { syncStatus: "idle", lastSyncError: null, syncConsecutiveFailures: 0, }, }); return { usersMarked: userIds.length, status: "repaired" }; } return { skipped: true, reason: "unsupported_item_webhook" }; } private shouldVerifyWebhooks() { if (process.env.NODE_ENV === "test") return false; return process.env.PLAID_VERIFY_WEBHOOKS !== "false"; } private async verifyWebhook(verificationHeader?: string, rawBody?: Buffer) { if (!verificationHeader || !rawBody) return false; const parts = verificationHeader.split("."); if (parts.length !== 3) return false; const header = this.decodeJwtPart<{ alg?: string; kid?: string }>(parts[0]); if (header.alg !== "ES256" || !header.kid) return false; const key = await this.getWebhookKey(header.kid); const publicKey = crypto.createPublicKey({ key, format: "jwk" } as crypto.JsonWebKeyInput); const verifier = crypto.createVerify("SHA256"); verifier.update(`${parts[0]}.${parts[1]}`); verifier.end(); const signature = this.ecJoseSignatureToDer(Buffer.from(parts[2], "base64url")); if (!verifier.verify(publicKey, signature)) return false; const claims = this.decodeJwtPart<{ iat?: number; request_body_sha256?: string }>(parts[1]); if (!claims.iat || Math.abs(Date.now() / 1000 - claims.iat) > 300) return false; if (!claims.request_body_sha256) return false; const actualHash = crypto.createHash("sha256").update(rawBody).digest("hex"); return this.timingSafeEqual(actualHash, claims.request_body_sha256); } private async getWebhookKey(keyId: string) { const cached = this.webhookKeys.get(keyId); if (cached) return cached; const response = await (this.client as unknown as { webhookVerificationKeyGet(request: { key_id: string }): Promise<{ data: { key: JsonWebKey } }>; }).webhookVerificationKeyGet({ key_id: keyId }); this.webhookKeys.set(keyId, response.data.key); return response.data.key; } private decodeJwtPart(part: string): T { return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as T; } private timingSafeEqual(left: string, right: string) { const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); } private ecJoseSignatureToDer(signature: Buffer) { if (signature.length !== 64) return signature; const r = this.derInteger(signature.subarray(0, 32)); const s = this.derInteger(signature.subarray(32)); const length = r.length + s.length; return Buffer.concat([Buffer.from([0x30, length]), r, s]); } private derInteger(bytes: Buffer) { let value = bytes; while (value.length > 1 && value[0] === 0) { value = value.subarray(1); } if (value[0] & 0x80) { value = Buffer.concat([Buffer.from([0]), value]); } return Buffer.concat([Buffer.from([0x02, value.length]), value]); } private requireEnv(name: string) { const value = process.env[name]; if (!value) { throw new Error(`Missing ${name} environment variable.`); } return value; } private getCountryCodes() { return (process.env.PLAID_COUNTRY_CODES ?? "US") .split(",") .map((item) => item.trim()) .filter(Boolean) as CountryCode[]; } private async getInstitutionName(institutionId: string) { try { const response = await this.client.institutionsGetById({ institution_id: institutionId, country_codes: ["US" as CountryCode], }); return response.data.institution.name ?? "Plaid institution"; } catch { return "Plaid institution"; } } } type PlaidWebhookPayload = { webhook_type?: string; webhook_code?: string; item_id?: string; environment?: string; error?: { error_code?: string; error_message?: string; }; [key: string]: unknown; };