Skip to content

Commit db44636

Browse files
committed
fix(subscriptions): resolve CI lint errors and merge conflicts for auto-resume
1 parent ac1df71 commit db44636

6 files changed

Lines changed: 93 additions & 133 deletions

File tree

src/common/constants/queue.constants.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,5 @@ export const JOB_NAMES = {
3434
// Payments queues
3535
PROCESS_SUBSCRIPTION: 'process_subscription',
3636
PROCESS_WEBHOOK: 'process-webhook',
37-
RESUME_SUBSCRIPTION: 'resume-subscription',
3837
RESUME_SUBSCRIPTION: 'resume_subscription',
3938
} as const;

src/payments/payments.module.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ import { SubscriptionJobProcessor } from './subscriptions/subscription-job.proce
1919
import { QUEUE_NAMES } from '../common/constants/queue.constants';
2020
import { PaymentReconciliationJob } from './reconciliation/reconciliation.service';
2121
import { PaymentReconciliationController } from './reconciliation/reconciliation.controller';
22-
import { SubscriptionsService } from './subscriptions/subscriptions.service';
23-
import { SubscriptionsController } from './subscriptions/subscriptions.controller';
2422
import { PaymentProviderService } from './providers/payment-provider.service';
2523
import { StripeProvider } from './providers/stripe.provider';
2624

@@ -46,24 +44,21 @@ import { StripeProvider } from './providers/stripe.provider';
4644
imports: [
4745
TypeOrmModule.forFeature([Payment, Subscription, Invoice, Refund]),
4846
CurrencyModule,
49-
BullModule.registerQueue({
50-
name: QUEUE_NAMES.SUBSCRIPTIONS,
51-
}),
52-
],
53-
providers: [PricingService, SubscriptionsService, SubscriptionJobProcessor],
54-
controllers: [PricingController, SubscriptionsController],
55-
exports: [PricingService, SubscriptionsService, CurrencyModule],
5647
AuditLogModule,
5748
IdempotencyModule,
5849
OutboxModule,
5950
HttpModule,
6051
QueueModule,
52+
BullModule.registerQueue({
53+
name: QUEUE_NAMES.SUBSCRIPTIONS,
54+
}),
6155
],
6256
providers: [
6357
PricingService,
6458
PaymentReconciliationJob,
6559
StripeProvider,
6660
SubscriptionsService,
61+
SubscriptionJobProcessor,
6762
PaymentProviderService,
6863
{
6964
provide: 'IPaymentProvider',
@@ -82,4 +77,3 @@ import { StripeProvider } from './providers/stripe.provider';
8277
],
8378
})
8479
export class PaymentsModule {}
85-

src/payments/subscriptions/subscription-job.processor.ts

Lines changed: 21 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,20 @@
11
import { Processor, Process, OnQueueActive, OnQueueCompleted, OnQueueFailed } from '@nestjs/bull';
2-
3-
import { Inject, Logger } from '@nestjs/common';
2+
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
43
import { Job } from 'bull';
5-
import { Logger } from '@nestjs/common';
64
import { InjectRepository } from '@nestjs/typeorm';
75
import { Repository } from 'typeorm';
86
import { QUEUE_NAMES, JOB_NAMES } from '../../common/constants/queue.constants';
97
import { SubscriptionsService } from './subscriptions.service';
108
import { Subscription, SubscriptionStatus } from '../entities/subscription.entity';
9+
import { IPaymentProvider } from '../providers/payment-provider.interface';
1110

1211
export interface ResumeSubscriptionJobData {
1312
subscriptionId: string;
1413
userId?: string;
1514
reason?: string;
1615
}
17-
import { QUEUE_NAMES, JOB_NAMES } from '../../common/constants/queue.constants';
18-
import { InjectRepository } from '@nestjs/typeorm';
19-
import { Repository } from 'typeorm';
20-
import { Subscription, SubscriptionStatus } from '../entities/subscription.entity';
21-
import { IPaymentProvider } from '../providers/payment-provider.interface';
2216

17+
@Injectable()
2318
@Processor(QUEUE_NAMES.SUBSCRIPTIONS)
2419
export class SubscriptionJobProcessor {
2520
private readonly logger = new Logger(SubscriptionJobProcessor.name);
@@ -28,21 +23,21 @@ export class SubscriptionJobProcessor {
2823
private readonly subscriptionsService: SubscriptionsService,
2924
@InjectRepository(Subscription)
3025
private readonly subscriptionRepository: Repository<Subscription>,
31-
@InjectRepository(Subscription)
32-
private subscriptionRepository: Repository<Subscription>,
26+
@Optional()
3327
@Inject('IPaymentProvider')
34-
private paymentProvider: IPaymentProvider,
28+
private readonly paymentProvider?: IPaymentProvider,
3529
) {}
3630

3731
@Process(JOB_NAMES.PROCESS_SUBSCRIPTION)
3832
async handleSubscription(job: Job<unknown>): Promise<unknown> {
39-
// Process subscription job
4033
this.logger.log('Processing subscription job:', job.data);
4134
return { success: true };
4235
}
4336

4437
@Process(JOB_NAMES.RESUME_SUBSCRIPTION)
45-
async handleResumeSubscription(job: Job<ResumeSubscriptionJobData>): Promise<unknown> {
38+
async handleResumeSubscription(
39+
job: Job<ResumeSubscriptionJobData>,
40+
): Promise<{ success: boolean; reason?: string; message?: string }> {
4641
const { subscriptionId } = job.data;
4742
this.logger.log(`Processing automatic resume for subscription: ${subscriptionId}`);
4843

@@ -67,13 +62,25 @@ export class SubscriptionJobProcessor {
6762
}
6863

6964
// Idempotency check: Guard against double-resume or already resumed subscriptions
70-
if (!subscription.properties?.isPaused) {
65+
if (!subscription.properties?.isPaused && subscription.status !== SubscriptionStatus.PAUSED) {
7166
this.logger.log(
7267
`Subscription ${subscriptionId} is not paused (already resumed). Skipping auto-resume.`,
7368
);
7469
return { success: true, reason: 'Subscription not paused' };
7570
}
7671

72+
// Resume at provider (Stripe) if provider subscription ID is present
73+
if (subscription.providerSubscriptionId && this.paymentProvider?.resumeSubscription) {
74+
try {
75+
await this.paymentProvider.resumeSubscription(subscription.providerSubscriptionId);
76+
} catch (error) {
77+
this.logger.error(
78+
`Failed to resume subscription ${subscriptionId} at payment provider: ${(error as Error).message}`,
79+
);
80+
throw error;
81+
}
82+
}
83+
7784
try {
7885
await this.subscriptionsService.resumeSubscription(subscriptionId, {
7986
reason: job.data.reason || 'Automatic resume from scheduled pause',
@@ -87,63 +94,6 @@ export class SubscriptionJobProcessor {
8794
throw error;
8895
}
8996
}
90-
async handleResumeSubscription(
91-
job: Job<{ subscriptionId: string }>,
92-
): Promise<{ success: boolean; message: string }> {
93-
const { subscriptionId } = job.data;
94-
95-
try {
96-
this.logger.log(`Processing resume subscription job for ${subscriptionId}`);
97-
98-
const subscription = await this.subscriptionRepository.findOne({
99-
where: { id: subscriptionId },
100-
});
101-
102-
if (!subscription) {
103-
this.logger.error(`Subscription ${subscriptionId} not found`);
104-
return { success: false, message: 'Subscription not found' };
105-
}
106-
107-
if (subscription.status !== SubscriptionStatus.PAUSED) {
108-
this.logger.warn(
109-
`Subscription ${subscriptionId} is not paused (status: ${subscription.status})`,
110-
);
111-
return { success: false, message: 'Subscription is not paused' };
112-
}
113-
114-
if (!subscription.providerSubscriptionId) {
115-
this.logger.error(`Subscription ${subscriptionId} has no provider subscription ID`);
116-
return { success: false, message: 'No provider subscription ID' };
117-
}
118-
119-
// Resume at provider (Stripe) first
120-
try {
121-
await this.paymentProvider.resumeSubscription(subscription.providerSubscriptionId);
122-
} catch (error) {
123-
this.logger.error(`Failed to resume subscription ${subscriptionId} at provider`, error);
124-
return { success: false, message: 'Provider resume failed' };
125-
}
126-
127-
// Resume the subscription locally only after provider succeeds
128-
subscription.status = SubscriptionStatus.ACTIVE;
129-
subscription.cancelAtPeriodEnd = false;
130-
subscription.properties = {
131-
...subscription.properties,
132-
isPaused: false,
133-
resumedAt: new Date(),
134-
resumeReason: 'Scheduled automatic resume',
135-
};
136-
137-
await this.subscriptionRepository.save(subscription);
138-
139-
this.logger.log(`Successfully resumed subscription ${subscriptionId} via scheduled job`);
140-
141-
return { success: true, message: 'Subscription resumed successfully' };
142-
} catch (error) {
143-
this.logger.error(`Failed to resume subscription ${subscriptionId}`, error);
144-
throw error;
145-
}
146-
}
14797

14898
@OnQueueActive()
14999
onActive(job: Job) {

src/payments/subscriptions/subscriptions.service.spec.ts

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1+
import { BadRequestException, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
12
import { Test, TestingModule } from '@nestjs/testing';
23
import { getRepositoryToken } from '@nestjs/typeorm';
34
import { getQueueToken } from '@nestjs/bull';
45
import { EventEmitter2 } from '@nestjs/event-emitter';
5-
import { BadRequestException, NotFoundException } from '@nestjs/common';
6+
import { Repository } from 'typeorm';
67
import { SubscriptionsService } from './subscriptions.service';
7-
import { Subscription, SubscriptionStatus, SubscriptionInterval } from '../entities/subscription.entity';
8+
import {
9+
Subscription,
10+
SubscriptionStatus,
11+
SubscriptionInterval,
12+
} from '../entities/subscription.entity';
813
import { QUEUE_NAMES, JOB_NAMES } from '../../common/constants/queue.constants';
14+
import { OutboxService } from '../../common/events/outbox.service';
15+
import { PaymentProviderService } from '../providers/payment-provider.service';
916

10-
describe('SubscriptionsService', () => {
17+
describe('SubscriptionsService (Lifecycle & Pause/Resume)', () => {
1118
let service: SubscriptionsService;
1219

1320
const mockSubscriptionRepository = {
@@ -23,6 +30,16 @@ describe('SubscriptionsService', () => {
2330
add: jest.fn().mockResolvedValue({ id: 'job-1' }),
2431
};
2532

33+
const mockOutbox = {
34+
enqueueStandalone: jest.fn().mockResolvedValue(undefined),
35+
enqueue: jest.fn().mockResolvedValue(undefined),
36+
};
37+
38+
const mockPaymentProvider = {
39+
chargeCustomer: jest.fn(),
40+
issueCredit: jest.fn(),
41+
};
42+
2643
beforeEach(async () => {
2744
jest.clearAllMocks();
2845

@@ -41,6 +58,14 @@ describe('SubscriptionsService', () => {
4158
provide: getQueueToken(QUEUE_NAMES.SUBSCRIPTIONS),
4259
useValue: mockQueue,
4360
},
61+
{
62+
provide: OutboxService,
63+
useValue: mockOutbox,
64+
},
65+
{
66+
provide: PaymentProviderService,
67+
useValue: mockPaymentProvider,
68+
},
4469
],
4570
}).compile();
4671

@@ -68,9 +93,9 @@ describe('SubscriptionsService', () => {
6893
status: SubscriptionStatus.ACTIVE,
6994
});
7095

71-
await expect(
72-
service.pauseSubscription('sub-1', { resumeAt: pastDate }),
73-
).rejects.toThrow(BadRequestException);
96+
await expect(service.pauseSubscription('sub-1', { resumeAt: pastDate })).rejects.toThrow(
97+
BadRequestException,
98+
);
7499
expect(mockQueue.add).not.toHaveBeenCalled();
75100
});
76101

@@ -168,16 +193,16 @@ describe('SubscriptionsService', () => {
168193
properties: { isPaused: false },
169194
});
170195

171-
await expect(
172-
service.resumeSubscription('sub-1', { reason: 'Back now' }),
173-
).rejects.toThrow(BadRequestException);
196+
await expect(service.resumeSubscription('sub-1', { reason: 'Back now' })).rejects.toThrow(
197+
BadRequestException,
198+
);
174199
});
175200

176201
it('should resume paused subscription successfully', async () => {
177202
const sub = {
178203
id: 'sub-1',
179204
userId: 'user-1',
180-
status: SubscriptionStatus.ACTIVE,
205+
status: SubscriptionStatus.PAUSED,
181206
properties: { isPaused: true, pausedAt: new Date() },
182207
};
183208
mockSubscriptionRepository.findOne.mockResolvedValue(sub);
@@ -196,20 +221,11 @@ describe('SubscriptionsService', () => {
196221
}),
197222
);
198223
});
199-
import { BadRequestException, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
200-
import { Test, TestingModule } from '@nestjs/testing';
201-
import { getRepositoryToken } from '@nestjs/typeorm';
202-
import { Repository } from 'typeorm';
203-
import { SubscriptionsService } from './subscriptions.service';
204-
import {
205-
Subscription,
206-
SubscriptionStatus,
207-
SubscriptionInterval,
208-
} from '../entities/subscription.entity';
209-
import { PaymentProviderService } from '../providers/payment-provider.service';
224+
});
225+
});
210226

211227
// ---------------------------------------------------------------------------
212-
// Helpers
228+
// Helpers for proration/upgrade/downgrade suites
213229
// ---------------------------------------------------------------------------
214230

215231
const PERIOD_END = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000); // 15 days from now
@@ -265,7 +281,13 @@ function buildService(
265281
outbox: ReturnType<typeof makeOutbox>,
266282
provider: jest.Mocked<PaymentProviderService>,
267283
): SubscriptionsService {
268-
return new SubscriptionsService(repo as any, outbox as any, provider);
284+
return new SubscriptionsService(
285+
repo as any,
286+
{ emit: jest.fn() } as any,
287+
{ add: jest.fn() } as any,
288+
outbox as any,
289+
provider,
290+
);
269291
}
270292

271293
// ---------------------------------------------------------------------------
@@ -298,7 +320,10 @@ describe('SubscriptionsService.upgradeSubscription', () => {
298320
expect(userId).toBe('user-1');
299321
expect(amount).toBeGreaterThan(0);
300322
expect(currency).toBe('USD');
301-
expect(meta).toMatchObject({ subscriptionId: 'sub-1', type: 'subscription_upgrade_proration' });
323+
expect(meta).toMatchObject({
324+
subscriptionId: 'sub-1',
325+
type: 'subscription_upgrade_proration',
326+
});
302327

303328
// Plan change persisted
304329
expect(repo.save).toHaveBeenCalledTimes(1);

src/payments/subscriptions/subscriptions.service.ts

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -153,32 +153,17 @@ export class SubscriptionsService {
153153
);
154154
}
155155

156-
// Emit event for downstream processing (notify user, analytics, etc.)
157-
this.eventEmitter.emit('subscription.paused', {
158-
// TODO: Schedule automatic resume if resumeAt is provided.
159-
// This requires injecting queueService and QUEUE_NAMES constants.
160-
// const resumeAtDate = dto.resumeAt ? new Date(dto.resumeAt) : undefined;
161-
// if (resumeAtDate) {
162-
// const delayMs = resumeAtDate.getTime() - Date.now();
163-
// if (delayMs > 0) {
164-
// await this.queueService.addJob(
165-
// QUEUE_NAMES.SUBSCRIPTIONS,
166-
// JOB_NAMES.RESUME_SUBSCRIPTION,
167-
// { subscriptionId: updated.id },
168-
// {
169-
// delay: delayMs,
170-
// attempts: 3,
171-
// backoff: {
172-
// type: 'exponential',
173-
// delay: 5000,
174-
// },
175-
// },
176-
// );
177-
// }
178-
// }
179-
180156
// Enqueue event for downstream processing (notify user, analytics, etc.).
181-
await this.outbox.enqueueStandalone('subscription.paused', {
157+
if (this.outbox) {
158+
await this.outbox.enqueueStandalone('subscription.paused', {
159+
subscriptionId: updated.id,
160+
userId: updated.userId,
161+
resumeAt: dto.resumeAt,
162+
reason: dto.reason,
163+
});
164+
}
165+
166+
this.eventEmitter.emit('subscription.paused', {
182167
subscriptionId: updated.id,
183168
userId: updated.userId,
184169
resumeAt: dto.resumeAt,
@@ -214,7 +199,15 @@ export class SubscriptionsService {
214199

215200
const updated = await this.subscriptionRepository.save(subscription);
216201

217-
await this.outbox.enqueueStandalone('subscription.resumed', {
202+
if (this.outbox) {
203+
await this.outbox.enqueueStandalone('subscription.resumed', {
204+
subscriptionId: updated.id,
205+
userId: updated.userId,
206+
reason: dto.reason,
207+
});
208+
}
209+
210+
this.eventEmitter.emit('subscription.resumed', {
218211
subscriptionId: updated.id,
219212
userId: updated.userId,
220213
reason: dto.reason,

0 commit comments

Comments
 (0)