Skip to content

Commit aa39a5c

Browse files
authored
Merge pull request #277 from soter-fashion/feat/226-wallet-limits
feat: replace LegacyUser limits with Wallet limits (#226)
2 parents d6e0a74 + b0b6301 commit aa39a5c

9 files changed

Lines changed: 188 additions & 116 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- CreateTable
2+
CREATE TABLE "WalletLimit" (
3+
"id" TEXT NOT NULL,
4+
"walletId" TEXT NOT NULL,
5+
"dailyLimit" DOUBLE PRECISION NOT NULL,
6+
"perTransactionLimit" DOUBLE PRECISION NOT NULL,
7+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
"updatedAt" TIMESTAMP(3) NOT NULL,
9+
10+
CONSTRAINT "WalletLimit_pkey" PRIMARY KEY ("id")
11+
);
12+
13+
-- CreateIndex
14+
CREATE UNIQUE INDEX "WalletLimit_walletId_key" ON "WalletLimit"("walletId");
15+
16+
-- CreateIndex
17+
CREATE INDEX "WalletLimit_walletId_idx" ON "WalletLimit"("walletId");
18+
19+
-- AddForeignKey
20+
ALTER TABLE "WalletLimit" ADD CONSTRAINT "WalletLimit_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ model UserLimit {
5656
user LegacyUser @relation(fields: [userId], references: [id])
5757
}
5858

59+
/// Spending limits scoped to a Wallet (replaces legacy UserLimit)
60+
model WalletLimit {
61+
id String @id @default(uuid())
62+
walletId String @unique
63+
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
64+
dailyLimit Float
65+
perTransactionLimit Float
66+
createdAt DateTime @default(now())
67+
updatedAt DateTime @updatedAt
68+
69+
@@index([walletId])
70+
}
71+
5972
/// Mainnet/testnet separation for all wallets.
6073
enum WalletNetwork {
6174
MAINNET
@@ -159,6 +172,9 @@ model Wallet {
159172
recoveryRequests RecoveryRequest[]
160173
balances WalletBalance[]
161174
175+
/// Spending limits for this wallet
176+
walletLimit WalletLimit?
177+
162178
/// Transactions sent from this wallet
163179
sentTransactions Transaction[] @relation("SentTransactions")
164180

src/limits/limits.controller.spec.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,20 @@ import { LimitsService } from './limits.service';
44

55
describe('LimitsController', () => {
66
let controller: LimitsController;
7+
let limitsService: any;
8+
9+
const walletId = 'wallet-uuid-1';
710

811
beforeEach(async () => {
12+
limitsService = {
13+
setLimits: jest.fn(),
14+
getLimits: jest.fn(),
15+
removeLimits: jest.fn(),
16+
};
17+
918
const module: TestingModule = await Test.createTestingModule({
1019
controllers: [LimitsController],
11-
providers: [
12-
{
13-
provide: LimitsService,
14-
useValue: {
15-
create: jest.fn(),
16-
findAll: jest.fn(),
17-
findOne: jest.fn(),
18-
update: jest.fn(),
19-
remove: jest.fn(),
20-
},
21-
},
22-
],
20+
providers: [{ provide: LimitsService, useValue: limitsService }],
2321
}).compile();
2422

2523
controller = module.get<LimitsController>(LimitsController);
@@ -28,4 +26,20 @@ describe('LimitsController', () => {
2826
it('should be defined', () => {
2927
expect(controller).toBeDefined();
3028
});
29+
30+
it('setLimits should delegate to service', async () => {
31+
const dto = { dailyLimit: 500, perTransactionLimit: 100 };
32+
await controller.setLimits(walletId, dto as any);
33+
expect(limitsService.setLimits).toHaveBeenCalledWith(walletId, 500, 100);
34+
});
35+
36+
it('getLimits should delegate to service', async () => {
37+
await controller.getLimits(walletId);
38+
expect(limitsService.getLimits).toHaveBeenCalledWith(walletId);
39+
});
40+
41+
it('removeLimits should delegate to service', async () => {
42+
await controller.removeLimits(walletId);
43+
expect(limitsService.removeLimits).toHaveBeenCalledWith(walletId);
44+
});
3145
});

src/limits/limits.controller.ts

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,44 @@ import {
33
Get,
44
Post,
55
Body,
6-
Patch,
76
Param,
87
Delete,
8+
HttpCode,
9+
HttpStatus,
910
} from '@nestjs/common';
1011
import { LimitsService } from './limits.service';
11-
import { CreateLimitDto } from './dto/create-limit.dto';
12-
import { UpdateLimitDto } from './dto/update-limit.dto';
12+
import { IsNumber, IsPositive } from 'class-validator';
1313

14-
@Controller('limits')
14+
class SetLimitsDto {
15+
@IsNumber()
16+
@IsPositive()
17+
dailyLimit: number;
18+
19+
@IsNumber()
20+
@IsPositive()
21+
perTransactionLimit: number;
22+
}
23+
24+
@Controller('wallets/:walletId/limits')
1525
export class LimitsController {
1626
constructor(private readonly limitsService: LimitsService) {}
1727

1828
@Post()
19-
create(@Body() createLimitDto: CreateLimitDto) {
20-
return this.limitsService.create(createLimitDto);
29+
setLimits(
30+
@Param('walletId') walletId: string,
31+
@Body() dto: SetLimitsDto,
32+
) {
33+
return this.limitsService.setLimits(walletId, dto.dailyLimit, dto.perTransactionLimit);
2134
}
2235

2336
@Get()
24-
findAll() {
25-
return this.limitsService.findAll();
26-
}
27-
28-
@Get(':id')
29-
findOne(@Param('id') id: string) {
30-
return this.limitsService.findOne(+id);
31-
}
32-
33-
@Patch(':id')
34-
update(@Param('id') id: string, @Body() updateLimitDto: UpdateLimitDto) {
35-
return this.limitsService.update(+id, updateLimitDto);
37+
getLimits(@Param('walletId') walletId: string) {
38+
return this.limitsService.getLimits(walletId);
3639
}
3740

38-
@Delete(':id')
39-
remove(@Param('id') id: string) {
40-
return this.limitsService.remove(+id);
41+
@Delete()
42+
@HttpCode(HttpStatus.NO_CONTENT)
43+
removeLimits(@Param('walletId') walletId: string) {
44+
return this.limitsService.removeLimits(walletId);
4145
}
4246
}

src/limits/limits.service.spec.ts

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import { Test, TestingModule } from '@nestjs/testing';
2+
import { NotFoundException } from '@nestjs/common';
23
import { LimitsService } from './limits.service';
34
import { PrismaService } from '../prisma/prisma.service';
45

56
describe('LimitsService', () => {
67
let service: LimitsService;
78
let prisma: any;
89

10+
const walletId = 'wallet-uuid-1';
11+
912
beforeEach(async () => {
1013
prisma = {
11-
userLimit: {
14+
walletLimit: {
1215
upsert: jest.fn(),
1316
findUnique: jest.fn(),
17+
delete: jest.fn(),
1418
},
15-
payment: {
16-
aggregate: jest.fn(),
19+
transaction: {
20+
findMany: jest.fn(),
1721
},
1822
};
1923

@@ -29,50 +33,88 @@ describe('LimitsService', () => {
2933
});
3034

3135
describe('setLimits', () => {
32-
it('should upsert limits', async () => {
33-
await service.setLimits(1, 100, 10);
34-
expect(prisma.userLimit.upsert).toHaveBeenCalledWith({
35-
where: { userId: 1 },
36+
it('should upsert wallet limits', async () => {
37+
await service.setLimits(walletId, 100, 10);
38+
expect(prisma.walletLimit.upsert).toHaveBeenCalledWith({
39+
where: { walletId },
3640
update: { dailyLimit: 100, perTransactionLimit: 10 },
37-
create: { userId: 1, dailyLimit: 100, perTransactionLimit: 10 },
41+
create: { walletId, dailyLimit: 100, perTransactionLimit: 10 },
3842
});
3943
});
4044
});
4145

46+
describe('getLimits', () => {
47+
it('should return limits for a wallet', async () => {
48+
const limit = { walletId, dailyLimit: 100, perTransactionLimit: 10 };
49+
prisma.walletLimit.findUnique.mockResolvedValue(limit);
50+
const result = await service.getLimits(walletId);
51+
expect(result).toEqual(limit);
52+
expect(prisma.walletLimit.findUnique).toHaveBeenCalledWith({ where: { walletId } });
53+
});
54+
});
55+
4256
describe('checkLimits', () => {
4357
it('should pass if no limits set', async () => {
44-
prisma.userLimit.findUnique.mockResolvedValue(null);
45-
await expect(service.checkLimits(1, 100)).resolves.not.toThrow();
58+
prisma.walletLimit.findUnique.mockResolvedValue(null);
59+
await expect(service.checkLimits(walletId, 100)).resolves.not.toThrow();
4660
});
4761

48-
it('should throw if per transaction limit exceeded', async () => {
49-
prisma.userLimit.findUnique.mockResolvedValue({
62+
it('should throw if per-transaction limit exceeded', async () => {
63+
prisma.walletLimit.findUnique.mockResolvedValue({
5064
perTransactionLimit: 50,
5165
dailyLimit: 1000,
5266
});
53-
await expect(service.checkLimits(1, 100)).rejects.toThrow(
67+
await expect(service.checkLimits(walletId, 100)).rejects.toThrow(
5468
'Transaction limit exceeded',
5569
);
5670
});
5771

5872
it('should throw if daily limit exceeded', async () => {
59-
prisma.userLimit.findUnique.mockResolvedValue({
73+
prisma.walletLimit.findUnique.mockResolvedValue({
6074
perTransactionLimit: 200,
6175
dailyLimit: 100,
6276
});
63-
prisma.payment.aggregate.mockResolvedValue({ _sum: { amount: 50 } });
64-
await expect(service.checkLimits(1, 60)).rejects.toThrow(
77+
prisma.transaction.findMany.mockResolvedValue([{ amount: '50' }]);
78+
await expect(service.checkLimits(walletId, 60)).rejects.toThrow(
6579
'Daily limit exceeded',
6680
);
6781
});
6882

6983
it('should pass if within limits', async () => {
70-
prisma.userLimit.findUnique.mockResolvedValue({
84+
prisma.walletLimit.findUnique.mockResolvedValue({
7185
perTransactionLimit: 200,
7286
dailyLimit: 100,
7387
});
74-
prisma.payment.aggregate.mockResolvedValue({ _sum: { amount: 40 } });
75-
await expect(service.checkLimits(1, 50)).resolves.not.toThrow();
88+
prisma.transaction.findMany.mockResolvedValue([{ amount: '40' }]);
89+
await expect(service.checkLimits(walletId, 50)).resolves.not.toThrow();
90+
});
91+
92+
it('should aggregate transactions by senderWalletId since start of day', async () => {
93+
prisma.walletLimit.findUnique.mockResolvedValue({
94+
perTransactionLimit: 200,
95+
dailyLimit: 1000,
96+
});
97+
prisma.transaction.findMany.mockResolvedValue([]);
98+
await service.checkLimits(walletId, 50);
99+
100+
const call = prisma.transaction.findMany.mock.calls[0][0];
101+
expect(call.where.senderWalletId).toBe(walletId);
102+
expect(call.where.createdAt.gte).toBeInstanceOf(Date);
103+
});
104+
});
105+
106+
describe('removeLimits', () => {
107+
it('should delete limits for a wallet', async () => {
108+
const limit = { walletId, dailyLimit: 100, perTransactionLimit: 10 };
109+
prisma.walletLimit.findUnique.mockResolvedValue(limit);
110+
prisma.walletLimit.delete.mockResolvedValue(limit);
111+
await service.removeLimits(walletId);
112+
expect(prisma.walletLimit.delete).toHaveBeenCalledWith({ where: { walletId } });
113+
});
114+
115+
it('should throw NotFoundException if no limits exist', async () => {
116+
prisma.walletLimit.findUnique.mockResolvedValue(null);
117+
await expect(service.removeLimits(walletId)).rejects.toThrow(NotFoundException);
76118
});
77119
});
78120
});

src/limits/limits.service.ts

Lines changed: 18 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,25 @@
1-
import { Injectable } from '@nestjs/common';
2-
import { CreateLimitDto } from './dto/create-limit.dto';
3-
import { UpdateLimitDto } from './dto/update-limit.dto';
1+
import { Injectable, NotFoundException } from '@nestjs/common';
42
import { PrismaService } from '../prisma/prisma.service';
53

64
@Injectable()
75
export class LimitsService {
86
constructor(private readonly prisma: PrismaService) {}
97

10-
async setLimits(userId: number, daily: number, perTx: number) {
11-
return this.prisma.userLimit.upsert({
12-
where: { userId },
8+
async setLimits(walletId: string, daily: number, perTx: number) {
9+
return this.prisma.walletLimit.upsert({
10+
where: { walletId },
1311
update: { dailyLimit: daily, perTransactionLimit: perTx },
14-
create: { userId, dailyLimit: daily, perTransactionLimit: perTx },
12+
create: { walletId, dailyLimit: daily, perTransactionLimit: perTx },
1513
});
1614
}
1715

18-
async getLimits(userId: number) {
19-
return this.prisma.userLimit.findUnique({
20-
where: { userId },
21-
});
16+
async getLimits(walletId: string) {
17+
return this.prisma.walletLimit.findUnique({ where: { walletId } });
2218
}
2319

24-
async checkLimits(userId: number, amount: number): Promise<void> {
25-
const limits = await this.getLimits(userId);
26-
if (!limits) return; // No limits set
20+
async checkLimits(walletId: string, amount: number): Promise<void> {
21+
const limits = await this.getLimits(walletId);
22+
if (!limits) return;
2723

2824
if (amount > limits.perTransactionLimit) {
2925
throw new Error(
@@ -34,43 +30,22 @@ export class LimitsService {
3430
const startOfDay = new Date();
3531
startOfDay.setHours(0, 0, 0, 0);
3632

37-
const usage = await this.prisma.payment.aggregate({
38-
where: {
39-
fromId: userId,
40-
createdAt: {
41-
gte: startOfDay,
42-
},
43-
},
44-
_sum: {
45-
amount: true,
46-
},
33+
const txns = await this.prisma.transaction.findMany({
34+
where: { senderWalletId: walletId, createdAt: { gte: startOfDay } },
35+
select: { amount: true },
4736
});
4837

49-
const currentDailyTotal = usage._sum.amount || 0;
38+
const currentDailyTotal = txns.reduce((sum, t) => sum + Number(t.amount), 0);
5039
if (currentDailyTotal + amount > limits.dailyLimit) {
5140
throw new Error(
5241
`Daily limit exceeded. Limit: ${limits.dailyLimit}, Used: ${currentDailyTotal}`,
5342
);
5443
}
5544
}
5645

57-
create(createLimitDto: CreateLimitDto) {
58-
return 'This action adds a new limit';
59-
}
60-
61-
findAll() {
62-
return `This action returns all limits`;
63-
}
64-
65-
findOne(id: number) {
66-
return `This action returns a #${id} limit`;
67-
}
68-
69-
update(id: number, updateLimitDto: UpdateLimitDto) {
70-
return `This action updates a #${id} limit`;
71-
}
72-
73-
remove(id: number) {
74-
return `This action removes a #${id} limit`;
46+
async removeLimits(walletId: string) {
47+
const existing = await this.getLimits(walletId);
48+
if (!existing) throw new NotFoundException(`No limits found for wallet ${walletId}`);
49+
return this.prisma.walletLimit.delete({ where: { walletId } });
7550
}
7651
}

0 commit comments

Comments
 (0)