Skip to content

Commit 8ad809b

Browse files
authored
feat: plan limits (#14)
* feat: add string billing support * fix: failing builds * fix: lint, type erorrs and database typing * feat: enable plan limits
1 parent d48bee7 commit 8ad809b

File tree

15 files changed

+350
-11
lines changed

15 files changed

+350
-11
lines changed

backend/src/auth/auth.service.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ describe('AuthService', () => {
3030
findOne: jest.Mock;
3131
find: jest.Mock;
3232
delete: jest.Mock;
33+
count: jest.Mock;
3334
};
3435
let mockUserRepo: { findOne: jest.Mock; create: jest.Mock; save: jest.Mock };
3536

@@ -40,6 +41,7 @@ describe('AuthService', () => {
4041
findOne: jest.fn(),
4142
find: jest.fn(),
4243
delete: jest.fn(),
44+
count: jest.fn().mockResolvedValue(0),
4345
};
4446
mockUserRepo = {
4547
findOne: jest.fn(),

backend/src/auth/auth.service.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
1+
import {
2+
Injectable,
3+
Logger,
4+
NotFoundException,
5+
HttpException,
6+
} from '@nestjs/common';
27
import { ConfigService } from '@nestjs/config';
38
import { Repository } from 'typeorm';
49
import { UserApiKey } from './entities/user-api-key.entity';
@@ -9,10 +14,12 @@ import { Domain } from '../domains/entities/domain.entity';
914
import { TlsCrt } from '../certs/tls/entities/tls-crt.entity';
1015
import { UpdateProfileDto } from './dto/update-profile.dto';
1116
import { BillingService } from '../billing/billing.service';
17+
import { PLAN_LIMITS } from '../billing/constants/plan-limits';
1218
import type {
1319
ApiKey,
1420
AuthCallbackResponse,
1521
CreateApiKeyResponse,
22+
SubscriptionPlan,
1623
UserProfile,
1724
} from '@krakenkey/shared';
1825

@@ -163,6 +170,28 @@ export class AuthService {
163170
* The raw key is returned only once - it cannot be retrieved later.
164171
*/
165172
async createApiKey(userId: string, name: string, expiresAt?: string) {
173+
// Plan-based API key limit check
174+
const plan = (await this.billingService.resolveUserTier(
175+
userId,
176+
)) as SubscriptionPlan;
177+
const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS.free;
178+
if (limits.apiKeys !== Infinity) {
179+
const count = await this.userApiKeyRepo.count({
180+
where: { userId },
181+
});
182+
if (count >= limits.apiKeys) {
183+
throw new HttpException(
184+
{
185+
message: 'API key limit reached',
186+
limit: limits.apiKeys,
187+
current: count,
188+
plan,
189+
},
190+
402,
191+
);
192+
}
193+
}
194+
166195
const rawKey = `kk_${randomBytes(24).toString('hex')}`;
167196
const hash = createHash('sha256').update(rawKey).digest('hex');
168197

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { SubscriptionPlan } from '@krakenkey/shared';
2+
3+
export interface PlanLimits {
4+
domains: number;
5+
apiKeys: number;
6+
certsPerMonth: number;
7+
totalActiveCerts: number;
8+
concurrentPending: number;
9+
renewalWindowDays: number;
10+
}
11+
12+
export const PLAN_LIMITS: Record<SubscriptionPlan, PlanLimits> = {
13+
free: {
14+
domains: 3,
15+
apiKeys: 2,
16+
certsPerMonth: 5,
17+
totalActiveCerts: 10,
18+
concurrentPending: 2,
19+
renewalWindowDays: 5,
20+
},
21+
starter: {
22+
domains: 10,
23+
apiKeys: 5,
24+
certsPerMonth: 50,
25+
totalActiveCerts: 75,
26+
concurrentPending: 5,
27+
renewalWindowDays: 30,
28+
},
29+
team: {
30+
domains: 25,
31+
apiKeys: 10,
32+
certsPerMonth: 250,
33+
totalActiveCerts: 375,
34+
concurrentPending: 25,
35+
renewalWindowDays: 30,
36+
},
37+
business: {
38+
domains: 75,
39+
apiKeys: 25,
40+
certsPerMonth: 1000,
41+
totalActiveCerts: 1500,
42+
concurrentPending: 100,
43+
renewalWindowDays: 30,
44+
},
45+
enterprise: {
46+
domains: Infinity,
47+
apiKeys: Infinity,
48+
certsPerMonth: Infinity,
49+
totalActiveCerts: Infinity,
50+
concurrentPending: Infinity,
51+
renewalWindowDays: 30,
52+
},
53+
} as const;

backend/src/certs/tls/services/cert-monitor.service.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { TlsCrt } from '../entities/tls-crt.entity';
66
import { CertStatus } from '@krakenkey/shared';
77
import { MetricsService } from '../../../metrics/metrics.service';
88
import { EmailService } from '../../../notifications/email.service';
9+
import { BillingService } from '../../../billing/billing.service';
910

1011
describe('CertMonitorService', () => {
1112
let service: CertMonitorService;
@@ -14,9 +15,10 @@ describe('CertMonitorService', () => {
1415

1516
const expiringCert = {
1617
id: 1,
18+
userId: 'user-123',
1719
status: 'issued',
1820
autoRenew: true,
19-
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000), // 15 days from now
21+
expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), // 3 days from now (within free tier 5-day window)
2022
} as TlsCrt;
2123

2224
beforeEach(async () => {
@@ -53,6 +55,10 @@ describe('CertMonitorService', () => {
5355
sendCertExpiryWarning: jest.fn(),
5456
},
5557
},
58+
{
59+
provide: BillingService,
60+
useValue: { resolveUserTier: jest.fn().mockResolvedValue('free') },
61+
},
5662
],
5763
}).compile();
5864

backend/src/certs/tls/services/cert-monitor.service.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import { LessThan, Repository } from 'typeorm';
55
import { TlsCrt } from '../entities/tls-crt.entity';
66
import { TlsService } from '../tls.service';
77
import { CertStatus } from '@krakenkey/shared';
8+
import type { SubscriptionPlan } from '@krakenkey/shared';
89
import { MetricsService } from '../../../metrics/metrics.service';
910
import { EmailService } from '../../../notifications/email.service';
11+
import { BillingService } from '../../../billing/billing.service';
12+
import { PLAN_LIMITS } from '../../../billing/constants/plan-limits';
1013

1114
@Injectable()
1215
export class CertMonitorService {
@@ -18,14 +21,17 @@ export class CertMonitorService {
1821
private readonly tlsService: TlsService,
1922
private readonly metricsService: MetricsService,
2023
private readonly emailService: EmailService,
24+
private readonly billingService: BillingService,
2125
) {}
2226

2327
/**
24-
* Runs daily at 6 AM. Finds all issued certificates expiring within 30 days
25-
* and queues a renewal job for each via BullMQ.
28+
* Runs daily at 6 AM. Finds all issued certificates expiring within their
29+
* tier-specific renewal window and queues a renewal job for each via BullMQ.
30+
* Free tier: 5-day window, paid tiers: 30-day window.
2631
*/
2732
@Cron(CronExpression.EVERY_DAY_AT_6AM)
2833
async checkExpiringCertificates(): Promise<void> {
34+
// Use max window (30 days) for the DB query, then filter per-user by tier
2935
const threshold = new Date();
3036
threshold.setDate(threshold.getDate() + 30);
3137

@@ -60,11 +66,31 @@ export class CertMonitorService {
6066
`Certificate expiry check: ${expiring.length} certificate(s) expiring within 30 days`,
6167
);
6268

69+
// Cache plan lookups to avoid redundant calls for certs owned by the same user
70+
const planCache = new Map<string, SubscriptionPlan>();
71+
6372
for (const cert of expiring) {
64-
if (cert.user && cert.expiresAt) {
65-
const daysUntilExpiry = Math.floor(
66-
(cert.expiresAt.getTime() - Date.now()) / 86_400_000,
67-
);
73+
if (!cert.expiresAt) continue;
74+
75+
const daysUntilExpiry = Math.floor(
76+
(cert.expiresAt.getTime() - Date.now()) / 86_400_000,
77+
);
78+
79+
// Determine tier-specific renewal window (cached per user)
80+
let userPlan = planCache.get(cert.userId);
81+
if (!userPlan) {
82+
userPlan = (await this.billingService.resolveUserTier(
83+
cert.userId,
84+
)) as SubscriptionPlan;
85+
planCache.set(cert.userId, userPlan);
86+
}
87+
const windowDays =
88+
(PLAN_LIMITS[userPlan] ?? PLAN_LIMITS.free).renewalWindowDays;
89+
90+
// Skip certs outside this user's renewal window
91+
if (daysUntilExpiry > windowDays) continue;
92+
93+
if (cert.user) {
6894
const commonName =
6995
(cert.parsedCsr?.subject?.find((a) => a.shortName === 'CN')
7096
?.value as string) ??

backend/src/certs/tls/tls.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Route53DnsStrategy } from './strategies/route53-dns.strategy';
1313
import { ConfigService } from '@nestjs/config';
1414
import { DnsProvider } from './interfaces/dns-provider.interface';
1515
import { DomainsModule } from '../../domains/domains.module';
16+
import { BillingModule } from '../../billing/billing.module';
1617
import { CertMonitorService } from './services/cert-monitor.service';
1718

1819
@Module({
@@ -22,6 +23,7 @@ import { CertMonitorService } from './services/cert-monitor.service';
2223
name: 'tlsCertIssuance',
2324
}),
2425
DomainsModule,
26+
BillingModule,
2527
],
2628
controllers: [TlsController],
2729
providers: [

backend/src/certs/tls/tls.service.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { TlsCrt } from './entities/tls-crt.entity';
1414
import { getQueueToken } from '@nestjs/bullmq';
1515
import type { ParsedCsr } from '@krakenkey/shared';
1616
import { EmailService } from '../../notifications/email.service';
17+
import { BillingService } from '../../billing/billing.service';
1718

1819
describe('TlsService', () => {
1920
let service: TlsService;
@@ -35,6 +36,7 @@ describe('TlsService', () => {
3536
findOneBy: jest.fn().mockResolvedValue(null),
3637
update: jest.fn().mockResolvedValue({ affected: 1 }),
3738
delete: jest.fn().mockResolvedValue({ affected: 1 }),
39+
count: jest.fn().mockResolvedValue(0),
3840
};
3941

4042
mockQueue = {
@@ -105,8 +107,13 @@ describe('TlsService', () => {
105107
sendCertExpiryWarning: jest.fn(),
106108
sendCertFailed: jest.fn(),
107109
sendCertRevoked: jest.fn(),
110+
sendPlanLimitReached: jest.fn(),
108111
},
109112
},
113+
{
114+
provide: BillingService,
115+
useValue: { resolveUserTier: jest.fn().mockResolvedValue('free') },
116+
},
110117
],
111118
}).compile();
112119

0 commit comments

Comments
 (0)