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 { 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 {}

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 { 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: {

View File

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

View File

@ -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",
},

View File

@ -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(),
},

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}`),
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(),
},

View File

@ -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", () => {