Encrypt raw transaction payload storage

This commit is contained in:
MOHAN 2026-07-16 22:27:00 +05:30
parent 6395b63098
commit 1b0f1c08d2
9 changed files with 86 additions and 9 deletions

View File

@ -1,11 +1,12 @@
import { Global, Module } from "@nestjs/common"; import { Global, Module } from "@nestjs/common";
import { EncryptionService } from "./encryption.service"; import { EncryptionService } from "./encryption.service";
import { OpaqueIdService } from "./opaque-id.service"; import { OpaqueIdService } from "./opaque-id.service";
import { RawPayloadEncryptionService } from "./raw-payload-encryption.service";
import { ViewRefService } from "./view-ref.service"; import { ViewRefService } from "./view-ref.service";
@Global() @Global()
@Module({ @Module({
providers: [EncryptionService, OpaqueIdService, ViewRefService], providers: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
exports: [EncryptionService, OpaqueIdService, ViewRefService], exports: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
}) })
export class CommonModule {} export class CommonModule {}

View File

@ -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<T = unknown>(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",
);
}
}

View File

@ -10,6 +10,7 @@ import * as crypto from "crypto";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { EncryptionService } from "../common/encryption.service"; import { EncryptionService } from "../common/encryption.service";
import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service";
import { PlanLimitsService } from "../stripe/plan-limits.service"; import { PlanLimitsService } from "../stripe/plan-limits.service";
@Injectable() @Injectable()
@ -22,6 +23,7 @@ export class PlaidService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly encryption: EncryptionService, private readonly encryption: EncryptionService,
private readonly planLimits: PlanLimitsService, private readonly planLimits: PlanLimitsService,
private readonly rawPayloads: RawPayloadEncryptionService,
) { ) {
const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments; const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
const clientId = this.requireEnv("PLAID_CLIENT_ID"); const clientId = this.requireEnv("PLAID_CLIENT_ID");
@ -204,7 +206,7 @@ export class PlaidService {
for (const tx of response.data.transactions) { for (const tx of response.data.transactions) {
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id); const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
if (!account) continue; if (!account) continue;
const rawPayload = tx as unknown as Prisma.InputJsonValue; const rawPayload = this.rawPayloads.encrypt(tx);
await this.prisma.transactionRaw.upsert({ await this.prisma.transactionRaw.upsert({
where: { bankTransactionId: tx.transaction_id }, where: { bankTransactionId: tx.transaction_id },
update: { update: {

View File

@ -4,6 +4,7 @@ import * as fs from "fs";
import * as https from "https"; import * as https from "https";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { EncryptionService } from "../common/encryption.service"; import { EncryptionService } from "../common/encryption.service";
import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service";
import { PlanLimitsService } from "../stripe/plan-limits.service"; import { PlanLimitsService } from "../stripe/plan-limits.service";
import { TellerEnrollmentPayload } from "./teller.controller"; import { TellerEnrollmentPayload } from "./teller.controller";
@ -15,6 +16,7 @@ export class TellerService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly encryption: EncryptionService, private readonly encryption: EncryptionService,
private readonly planLimits: PlanLimitsService, private readonly planLimits: PlanLimitsService,
private readonly rawPayloads: RawPayloadEncryptionService,
) {} ) {}
getConnectConfig() { getConnectConfig() {
@ -117,7 +119,7 @@ export class TellerService {
date: new Date(tx.date), date: new Date(tx.date),
amount: new Prisma.Decimal(tx.amount), amount: new Prisma.Decimal(tx.amount),
description: tx.description ?? "Teller transaction", description: tx.description ?? "Teller transaction",
rawPayload: tx as unknown as Prisma.InputJsonValue, rawPayload: this.rawPayloads.encrypt(tx),
source: "teller", source: "teller",
ingestedAt: new Date(), ingestedAt: new Date(),
}, },
@ -127,7 +129,7 @@ export class TellerService {
date: new Date(tx.date), date: new Date(tx.date),
amount: new Prisma.Decimal(tx.amount), amount: new Prisma.Decimal(tx.amount),
description: tx.description ?? "Teller transaction", description: tx.description ?? "Teller transaction",
rawPayload: tx as unknown as Prisma.InputJsonValue, rawPayload: this.rawPayloads.encrypt(tx),
source: "teller", source: "teller",
}, },
}); });

View File

@ -5,6 +5,7 @@ import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { PlaidService } from "../plaid/plaid.service"; import { PlaidService } from "../plaid/plaid.service";
import { OpaqueIdService } from "../common/opaque-id.service"; import { OpaqueIdService } from "../common/opaque-id.service";
import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service";
import { ViewRefService } from "../common/view-ref.service"; import { ViewRefService } from "../common/view-ref.service";
import { ExportsService } from "../exports/exports.service"; import { ExportsService } from "../exports/exports.service";
import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { UpdateDerivedDto } from "./dto/update-derived.dto";
@ -155,6 +156,7 @@ export class TransactionsService {
private readonly plaidService: PlaidService, private readonly plaidService: PlaidService,
private readonly opaqueIds: OpaqueIdService, private readonly opaqueIds: OpaqueIdService,
private readonly viewRefs: ViewRefService, private readonly viewRefs: ViewRefService,
private readonly rawPayloads: RawPayloadEncryptionService,
private readonly exportsService?: ExportsService, private readonly exportsService?: ExportsService,
) {} ) {}
@ -351,7 +353,7 @@ export class TransactionsService {
date: dateObj, date: dateObj,
amount: row.amount, amount: row.amount,
description: row.description, description: row.description,
rawPayload: row as unknown as Prisma.InputJsonValue, rawPayload: this.rawPayloads.encrypt(row),
ingestedAt: new Date(), ingestedAt: new Date(),
source: "csv", source: "csv",
}, },
@ -456,7 +458,7 @@ export class TransactionsService {
date: new Date(payload.date), date: new Date(payload.date),
amount: payload.amount, amount: payload.amount,
description: payload.description, description: payload.description,
rawPayload: payload as unknown as Prisma.InputJsonValue, rawPayload: this.rawPayloads.encrypt(payload),
ingestedAt: new Date(), ingestedAt: new Date(),
source: "manual", source: "manual",
}, },

View File

@ -25,6 +25,9 @@ const createService = () => {
encryption: { encryption: {
decrypt: jest.fn((value: string) => value.replace("enc_", "raw_")), decrypt: jest.fn((value: string) => value.replace("enc_", "raw_")),
}, },
rawPayloads: {
encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })),
},
logger: { logger: {
error: jest.fn(), error: jest.fn(),
}, },

View File

@ -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" });
});
});

View File

@ -18,6 +18,9 @@ const createService = () => {
encrypt: jest.fn((value: string) => `enc_${value}`), encrypt: jest.fn((value: string) => `enc_${value}`),
decrypt: jest.fn((value: string) => value.replace("enc_", "")), decrypt: jest.fn((value: string) => value.replace("enc_", "")),
}, },
rawPayloads: {
encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })),
},
logger: { logger: {
warn: jest.fn(), warn: jest.fn(),
}, },

View File

@ -10,9 +10,10 @@ const createService = () => {
decode: jest.fn((_kind: string, _userId: string, token: string) => token.replace(/^opaque_[^_]+_/, "")), decode: jest.fn((_kind: string, _userId: string, token: string) => token.replace(/^opaque_[^_]+_/, "")),
}; };
const viewRefs = { matches: jest.fn() }; 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 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); 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, exportsService }; return { service, prisma, plaid, opaqueIds, viewRefs, rawPayloads, exportsService };
}; };
describe("TransactionsService", () => { describe("TransactionsService", () => {