Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 0 additions & 29 deletions .eslintrc.js

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- Migration: 20260715000000_balance_ledger_decimal_and_snapshot
--
-- 1. Migrate BalanceLedger.amount from REAL (Float) to DECIMAL(38,18).
-- 2. Add the BalanceLedgerSnapshot denormalisation table.

-- Step 1: convert BalanceLedger.amount to DECIMAL(38,18)
ALTER TABLE "BalanceLedger" ALTER COLUMN "amount" SET DATA TYPE DECIMAL(38,18);

-- Step 2: create the BalanceLedgerSnapshot table
CREATE TABLE "BalanceLedgerSnapshot" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"totalLocked" DECIMAL(38,18) NOT NULL,
"snapshotAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "BalanceLedgerSnapshot_pkey" PRIMARY KEY ("id"),
CONSTRAINT "BalanceLedgerSnapshot_campaignId_fkey"
FOREIGN KEY ("campaignId") REFERENCES "Campaign" ("id")
ON DELETE RESTRICT ON UPDATE CASCADE
);

-- Indexes on BalanceLedgerSnapshot
CREATE INDEX "BalanceLedgerSnapshot_campaignId_idx"
ON "BalanceLedgerSnapshot"("campaignId");

CREATE INDEX "BalanceLedgerSnapshot_snapshotAt_idx"
ON "BalanceLedgerSnapshot"("snapshotAt");

CREATE INDEX "BalanceLedgerSnapshot_campaignId_snapshotAt_idx"
ON "BalanceLedgerSnapshot"("campaignId", "snapshotAt");

This file was deleted.

2 changes: 1 addition & 1 deletion app/backend/prisma/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
provider = "postgresql"
22 changes: 22 additions & 0 deletions app/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,27 @@ model BalanceLedger {
@@index([createdAt])
}

/// Denormalised 24-hour running-total snapshot, computed by a scheduled job.
/// Each row captures the sum of all BalanceLedger.amount entries for a
/// campaign over the 24 h window ending at `snapshotAt`.
model BalanceLedgerSnapshot {
id String @id @default(cuid())
campaignId String
campaign Campaign @relation(fields: [campaignId], references: [id])

/// Sum of all BalanceLedger entries in the 24 h window ending at snapshotAt.
totalLocked Decimal @db.Decimal(38, 18)

/// Upper bound of the 24 h window this snapshot covers.
snapshotAt DateTime @default(now())

createdAt DateTime @default(now())

@@index([campaignId])
@@index([snapshotAt])
@@index([campaignId, snapshotAt])
}

enum VerificationChannel {
email
phone
Expand Down Expand Up @@ -364,6 +385,7 @@ model Campaign {

claims Claim[]
balanceLedger BalanceLedger[]
balanceLedgerSnapshots BalanceLedgerSnapshot[]
packages AidPackage[]

@@index([status])
Expand Down
10 changes: 7 additions & 3 deletions app/backend/src/campaigns/campaigns.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ export class CampaignsService {
]);

// Use type assertion to handle Prisma client type limitations
// Prisma schema has these fields but generated types may be stale
// Prisma schema has these fields but generated types may be stale.
// BalanceLedger.amount is Decimal; call toNumber() for arithmetic.
const campaigns = campaignsResult as unknown as Array<{
id: string;
name: string;
Expand All @@ -180,7 +181,7 @@ export class CampaignsService {
archivedAt: Date | null;
deletedAt: Date | null;
_count: { claims: number };
balanceLedger: Array<{ amount: number }>;
balanceLedger: Array<{ amount: { toNumber(): number } | number }>;
}>;

const data: CampaignExportRow[] = campaigns.map(c => ({
Expand All @@ -194,7 +195,10 @@ export class CampaignsService {
updatedAt: c.updatedAt,
archivedAt: c.archivedAt ?? null,
totalClaims: c._count.claims,
totalDisbursed: c.balanceLedger.reduce((sum, bl) => sum + bl.amount, 0),
totalDisbursed: c.balanceLedger.reduce((sum, bl) => {
const v = bl.amount;
return sum + (typeof v === 'object' ? v.toNumber() : (v as number));
}, 0),
}));

return { data, total, page, limit };
Expand Down
15 changes: 12 additions & 3 deletions app/backend/src/common/budget/budget.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export class BudgetService {

/**
* Returns the total locked and disbursed amount for a campaign.
* Optionally filter by token if your model supports it.
* BalanceLedger.amount is Decimal; values are returned as plain numbers
* for downstream arithmetic convenience.
*/
async getCampaignBudgetUsage(
campaignId: string,
Expand All @@ -28,9 +29,17 @@ export class BudgetService {
eventType: 'disburse',
},
});

const toNum = (
d: { toNumber(): number } | number | null | undefined,
): number => {
if (!d && d !== 0) return 0;
return typeof d === 'object' ? d.toNumber() : (d as number);
};

return {
locked: locked._sum.amount?.toNumber() ?? 0,
disbursed: disbursed._sum.amount?.toNumber() ?? 0,
locked: toNum(locked._sum.amount),
disbursed: toNum(disbursed._sum.amount),
};
}

Expand Down
71 changes: 71 additions & 0 deletions app/backend/src/jobs/balance-ledger-snapshot.job.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { BalanceLedgerSnapshotJob } from './balance-ledger-snapshot.job';
import { PrismaService } from '../prisma/prisma.service';
import { Decimal } from '@prisma/client/runtime/library';

describe('BalanceLedgerSnapshotJob', () => {
let job: BalanceLedgerSnapshotJob;
let prisma: jest.Mocked<
Pick<PrismaService, 'balanceLedger' | 'balanceLedgerSnapshot'>
>;

const makeDecimal = (s: string) => new Decimal(s);

beforeEach(() => {
prisma = {
balanceLedger: {
groupBy: jest.fn(),
} as unknown as jest.Mocked<PrismaService['balanceLedger']>,
balanceLedgerSnapshot: {
createMany: jest.fn(),
} as unknown as jest.Mocked<PrismaService['balanceLedgerSnapshot']>,
};

job = new BalanceLedgerSnapshotJob(prisma as unknown as PrismaService);
});

it('returns 0 and skips createMany when no ledger activity', async () => {
(prisma.balanceLedger.groupBy as jest.Mock).mockResolvedValue([]);

const result = await job.run();

expect(result).toBe(0);
expect(prisma.balanceLedgerSnapshot.createMany).not.toHaveBeenCalled();
});

it('creates one snapshot per campaign with the correct totalLocked', async () => {
(prisma.balanceLedger.groupBy as jest.Mock).mockResolvedValue([
{ campaignId: 'c1', _sum: { amount: makeDecimal('350.75') } },
{ campaignId: 'c2', _sum: { amount: makeDecimal('1000.00') } },
]);
(prisma.balanceLedgerSnapshot.createMany as jest.Mock).mockResolvedValue({
count: 2,
});

const result = await job.run();

expect(result).toBe(2);

const call = (prisma.balanceLedgerSnapshot.createMany as jest.Mock).mock
.calls[0][0];
expect(call.data).toHaveLength(2);
expect(call.data[0].campaignId).toBe('c1');
expect(call.data[0].totalLocked.toFixed(2)).toBe('350.75');
expect(call.data[1].campaignId).toBe('c2');
expect(call.data[1].totalLocked.toFixed(2)).toBe('1000.00');
});

it('uses Decimal(0) when _sum.amount is null', async () => {
(prisma.balanceLedger.groupBy as jest.Mock).mockResolvedValue([
{ campaignId: 'c3', _sum: { amount: null } },
]);
(prisma.balanceLedgerSnapshot.createMany as jest.Mock).mockResolvedValue({
count: 1,
});

await job.run();

const call = (prisma.balanceLedgerSnapshot.createMany as jest.Mock).mock
.calls[0][0];
expect(call.data[0].totalLocked.toFixed(2)).toBe('0.00');
});
});
79 changes: 79 additions & 0 deletions app/backend/src/jobs/balance-ledger-snapshot.job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../prisma/prisma.service';

/**
* BalanceLedgerSnapshotJob
*
* Runs every hour (configurable via BALANCE_SNAPSHOT_CRON).
* For every campaign that has at least one BalanceLedger entry in the last
* 24 hours, it computes Σ amount and writes a BalanceLedgerSnapshot row.
*
* The snapshot captures the running total of *all* ledger entries inside the
* 24-hour window ending at the moment the job fires.
*/
@Injectable()
export class BalanceLedgerSnapshotJob {
private readonly logger = new Logger(BalanceLedgerSnapshotJob.name);

constructor(private readonly prisma: PrismaService) {}

/**
* Exposed for direct invocation from tests and manual triggers.
* Returns the number of snapshots written.
*/
async run(): Promise<number> {
const windowEnd = new Date();
const windowStart = new Date(windowEnd.getTime() - 24 * 60 * 60 * 1000);

this.logger.log(
`Running balance-ledger snapshot ` +
`[${windowStart.toISOString()} → ${windowEnd.toISOString()}]`,
);

// Aggregate per campaign for the 24 h window
const rows = await this.prisma.balanceLedger.groupBy({
by: ['campaignId'],
where: {
createdAt: { gte: windowStart, lte: windowEnd },
},
_sum: { amount: true },
});

if (rows.length === 0) {
this.logger.log('No ledger activity in the last 24 h — nothing to snapshot.');
return 0;
}

// Persist one snapshot row per active campaign
const snapshots = rows.map(row => ({
campaignId: row.campaignId,
totalLocked: (row._sum.amount ?? new Decimal(0)) as Decimal,
snapshotAt: windowEnd,
}));

await this.prisma.balanceLedgerSnapshot.createMany({
data: snapshots,
});

this.logger.log(
`Snapshot complete — wrote ${snapshots.length} row(s).`,
);

return snapshots.length;
}

/** Scheduled entry-point — fires every hour by default. */
@Cron(CronExpression.EVERY_HOUR)
async handleCron(): Promise<void> {
try {
await this.run();
} catch (err) {
this.logger.error(
`Balance-ledger snapshot job failed: ${(err as Error).message}`,
(err as Error).stack,
);
}
}
}
7 changes: 5 additions & 2 deletions app/backend/src/jobs/jobs.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { DlqService } from './dlq.service';
import { SecurityEventJob } from './security-event.job';
import { UsageTrackerModule } from '../observability/usage-tracker/usage-tracker.module';
import { AuditModule } from '../audit/audit.module';
import { BalanceLedgerSnapshotJob } from './balance-ledger-snapshot.job';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
imports: [
Expand All @@ -16,9 +18,10 @@ import { AuditModule } from '../audit/audit.module';
BullModule.registerQueue({ name: 'dead-letter' }),
UsageTrackerModule,
AuditModule,
PrismaModule,
],
controllers: [JobsController],
providers: [DlqService, SecurityEventJob],
exports: [DlqService, SecurityEventJob],
providers: [DlqService, SecurityEventJob, BalanceLedgerSnapshotJob],
exports: [DlqService, SecurityEventJob, BalanceLedgerSnapshotJob],
})
export class JobsModule {}
Loading
Loading