diff --git a/src/common/common.module.ts b/src/common/common.module.ts index 280e7c8..9731cfb 100644 --- a/src/common/common.module.ts +++ b/src/common/common.module.ts @@ -1,11 +1,12 @@ import { Global, Module } from "@nestjs/common"; import { EncryptionService } from "./encryption.service"; import { OpaqueIdService } from "./opaque-id.service"; +import { RawPayloadEncryptionService } from "./raw-payload-encryption.service"; import { ViewRefService } from "./view-ref.service"; @Global() @Module({ - providers: [EncryptionService, OpaqueIdService, ViewRefService], - exports: [EncryptionService, OpaqueIdService, ViewRefService], + providers: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService], + exports: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService], }) export class CommonModule {} diff --git a/src/common/raw-payload-encryption.service.ts b/src/common/raw-payload-encryption.service.ts new file mode 100644 index 0000000..a98a2e7 --- /dev/null +++ b/src/common/raw-payload-encryption.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { EncryptionService } from "./encryption.service"; + +type EncryptedRawPayload = { + encrypted: true; + version: 1; + ciphertext: string; +}; + +@Injectable() +export class RawPayloadEncryptionService { + constructor(private readonly encryption: EncryptionService) {} + + encrypt(payload: unknown): Prisma.InputJsonValue { + return { + encrypted: true, + version: 1, + ciphertext: this.encryption.encrypt(JSON.stringify(payload ?? null)), + } satisfies EncryptedRawPayload as unknown as Prisma.InputJsonValue; + } + + decrypt(payload: unknown): T { + if (!this.isEncryptedPayload(payload)) return payload as T; + return JSON.parse(this.encryption.decrypt(payload.ciphertext)) as T; + } + + private isEncryptedPayload(payload: unknown): payload is EncryptedRawPayload { + return Boolean( + payload && + typeof payload === "object" && + (payload as { encrypted?: unknown }).encrypted === true && + (payload as { version?: unknown }).version === 1 && + typeof (payload as { ciphertext?: unknown }).ciphertext === "string", + ); + } +} diff --git a/src/plaid/plaid.service.ts b/src/plaid/plaid.service.ts index 0fac87c..8ea8838 100644 --- a/src/plaid/plaid.service.ts +++ b/src/plaid/plaid.service.ts @@ -10,6 +10,7 @@ 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() @@ -22,6 +23,7 @@ export class PlaidService { 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"); @@ -204,7 +206,7 @@ export class PlaidService { for (const tx of response.data.transactions) { const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id); if (!account) continue; - const rawPayload = tx as unknown as Prisma.InputJsonValue; + const rawPayload = this.rawPayloads.encrypt(tx); await this.prisma.transactionRaw.upsert({ where: { bankTransactionId: tx.transaction_id }, update: { diff --git a/src/teller/teller.service.ts b/src/teller/teller.service.ts index da88191..e8f023c 100644 --- a/src/teller/teller.service.ts +++ b/src/teller/teller.service.ts @@ -4,6 +4,7 @@ import * as fs from "fs"; import * as https from "https"; 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"; import { TellerEnrollmentPayload } from "./teller.controller"; @@ -15,6 +16,7 @@ export class TellerService { private readonly prisma: PrismaService, private readonly encryption: EncryptionService, private readonly planLimits: PlanLimitsService, + private readonly rawPayloads: RawPayloadEncryptionService, ) {} getConnectConfig() { @@ -117,7 +119,7 @@ export class TellerService { date: new Date(tx.date), amount: new Prisma.Decimal(tx.amount), description: tx.description ?? "Teller transaction", - rawPayload: tx as unknown as Prisma.InputJsonValue, + rawPayload: this.rawPayloads.encrypt(tx), source: "teller", ingestedAt: new Date(), }, @@ -127,7 +129,7 @@ export class TellerService { date: new Date(tx.date), amount: new Prisma.Decimal(tx.amount), description: tx.description ?? "Teller transaction", - rawPayload: tx as unknown as Prisma.InputJsonValue, + rawPayload: this.rawPayloads.encrypt(tx), source: "teller", }, }); diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index c1d5fe5..28a2d0d 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -5,6 +5,7 @@ import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { PlaidService } from "../plaid/plaid.service"; import { OpaqueIdService } from "../common/opaque-id.service"; +import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service"; import { ViewRefService } from "../common/view-ref.service"; import { ExportsService } from "../exports/exports.service"; import { UpdateDerivedDto } from "./dto/update-derived.dto"; @@ -155,6 +156,7 @@ export class TransactionsService { private readonly plaidService: PlaidService, private readonly opaqueIds: OpaqueIdService, private readonly viewRefs: ViewRefService, + private readonly rawPayloads: RawPayloadEncryptionService, private readonly exportsService?: ExportsService, ) {} @@ -351,7 +353,7 @@ export class TransactionsService { date: dateObj, amount: row.amount, description: row.description, - rawPayload: row as unknown as Prisma.InputJsonValue, + rawPayload: this.rawPayloads.encrypt(row), ingestedAt: new Date(), source: "csv", }, @@ -456,7 +458,7 @@ export class TransactionsService { date: new Date(payload.date), amount: payload.amount, description: payload.description, - rawPayload: payload as unknown as Prisma.InputJsonValue, + rawPayload: this.rawPayloads.encrypt(payload), ingestedAt: new Date(), source: "manual", }, diff --git a/test/plaid.webhook.spec.ts b/test/plaid.webhook.spec.ts index 3210040..70f3db1 100644 --- a/test/plaid.webhook.spec.ts +++ b/test/plaid.webhook.spec.ts @@ -25,6 +25,9 @@ const createService = () => { encryption: { decrypt: jest.fn((value: string) => value.replace("enc_", "raw_")), }, + rawPayloads: { + encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })), + }, logger: { error: jest.fn(), }, diff --git a/test/raw-payload-encryption.service.spec.ts b/test/raw-payload-encryption.service.spec.ts new file mode 100644 index 0000000..6948066 --- /dev/null +++ b/test/raw-payload-encryption.service.spec.ts @@ -0,0 +1,26 @@ +import { RawPayloadEncryptionService } from "../src/common/raw-payload-encryption.service"; + +describe("RawPayloadEncryptionService", () => { + it("stores raw transaction payloads as encrypted envelopes", () => { + const encryption = { + encrypt: jest.fn((value: string) => `cipher:${Buffer.from(value).toString("base64")}`), + decrypt: jest.fn((value: string) => Buffer.from(value.replace("cipher:", ""), "base64").toString("utf8")), + }; + const service = new RawPayloadEncryptionService(encryption as never); + + const encrypted = service.encrypt({ transaction_id: "tx_1", merchant: "Coffee" }) as { + encrypted: boolean; + version: number; + ciphertext: string; + transaction_id?: string; + }; + + expect(encrypted).toEqual({ + encrypted: true, + version: 1, + ciphertext: expect.stringMatching(/^cipher:/), + }); + expect(encrypted).not.toHaveProperty("transaction_id"); + expect(service.decrypt(encrypted)).toEqual({ transaction_id: "tx_1", merchant: "Coffee" }); + }); +}); diff --git a/test/teller.service.spec.ts b/test/teller.service.spec.ts index 3f03fae..70bec36 100644 --- a/test/teller.service.spec.ts +++ b/test/teller.service.spec.ts @@ -18,6 +18,9 @@ const createService = () => { encrypt: jest.fn((value: string) => `enc_${value}`), decrypt: jest.fn((value: string) => value.replace("enc_", "")), }, + rawPayloads: { + encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })), + }, logger: { warn: jest.fn(), }, diff --git a/test/transactions.service.spec.ts b/test/transactions.service.spec.ts index f777859..d40eefd 100644 --- a/test/transactions.service.spec.ts +++ b/test/transactions.service.spec.ts @@ -10,9 +10,10 @@ const createService = () => { decode: jest.fn((_kind: string, _userId: string, token: string) => token.replace(/^opaque_[^_]+_/, "")), }; const viewRefs = { matches: jest.fn() }; + const rawPayloads = { encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })) }; const exportsService = { syncGoogleSheets: jest.fn().mockResolvedValue({ status: "synced" }) }; - const service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, viewRefs as any, exportsService as any); - return { service, prisma, plaid, opaqueIds, viewRefs, exportsService }; + const service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, viewRefs as any, rawPayloads as any, exportsService as any); + return { service, prisma, plaid, opaqueIds, viewRefs, rawPayloads, exportsService }; }; describe("TransactionsService", () => {