From 30c54cca9bad9967521ab7aa9bd0cc866aa1da03 Mon Sep 17 00:00:00 2001 From: Fawaz Date: Tue, 28 Jul 2026 15:37:19 +0100 Subject: [PATCH 1/2] feat(payments): implement provider-backed subscription pause --- pnpm-lock.yaml | 45 +++ src/common/constants/queue.constants.ts | 1 + ...00000000-add-paused-subscription-status.ts | 19 + src/payments/entities/subscription.entity.ts | 1 + src/payments/payments.module.ts | 25 +- .../providers/payment-provider.interface.ts | 4 + src/payments/providers/stripe.provider.ts | 175 +++++++++ .../subscription-job.processor.ts | 96 ++++- .../subscriptions.service.spec.ts | 362 ++++++++++++++++++ .../subscriptions/subscriptions.service.ts | 147 +++++-- 10 files changed, 833 insertions(+), 42 deletions(-) create mode 100644 src/migrations/1790000000000-add-paused-subscription-status.ts create mode 100644 src/payments/providers/stripe.provider.ts create mode 100644 src/payments/subscriptions/subscriptions.service.spec.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fae264a..12ec55a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: '@nestjs/jwt': specifier: ^11.0.0 version: 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/mapped-types': + specifier: ^2.0.4 + version: 2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/passport': specifier: ^11.0.5 version: 11.0.5(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) @@ -134,6 +137,9 @@ importers: '@segment/analytics-node': specifier: ^2.1.2 version: 2.3.0 + '@socket.io/redis-adapter': + specifier: ^8.3.0 + version: 8.3.0(socket.io-adapter@2.5.7) '@types/csurf': specifier: ^1.11.5 version: 1.11.5 @@ -2617,6 +2623,12 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@socket.io/redis-adapter@8.3.0': + resolution: {integrity: sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==} + engines: {node: '>=10.0.0'} + peerDependencies: + socket.io-adapter: ^2.5.4 + '@sqltools/formatter@1.2.5': resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} @@ -3789,6 +3801,15 @@ packages: supports-color: optional: true + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -5446,6 +5467,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + notepack.io@3.0.1: + resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -6673,6 +6697,10 @@ packages: uid2@0.0.4: resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} + uid2@1.0.0: + resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} + engines: {node: '>= 4.0.0'} + uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -9785,6 +9813,15 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.7)': + dependencies: + debug: 4.3.7 + notepack.io: 3.0.1 + socket.io-adapter: 2.5.7 + uid2: 1.0.0 + transitivePeerDependencies: + - supports-color + '@sqltools/formatter@1.2.5': {} '@standard-schema/spec@1.1.0': {} @@ -11109,6 +11146,10 @@ snapshots: dependencies: ms: 2.0.0 + debug@4.3.7: + dependencies: + ms: 2.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -12980,6 +13021,8 @@ snapshots: normalize-path@3.0.0: {} + notepack.io@3.0.1: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -14244,6 +14287,8 @@ snapshots: uid2@0.0.4: {} + uid2@1.0.0: {} + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 diff --git a/src/common/constants/queue.constants.ts b/src/common/constants/queue.constants.ts index 3217a2cb..ad3539f6 100644 --- a/src/common/constants/queue.constants.ts +++ b/src/common/constants/queue.constants.ts @@ -34,4 +34,5 @@ export const JOB_NAMES = { // Payments queues PROCESS_SUBSCRIPTION: 'process_subscription', PROCESS_WEBHOOK: 'process-webhook', + RESUME_SUBSCRIPTION: 'resume_subscription', } as const; diff --git a/src/migrations/1790000000000-add-paused-subscription-status.ts b/src/migrations/1790000000000-add-paused-subscription-status.ts new file mode 100644 index 00000000..d631edbd --- /dev/null +++ b/src/migrations/1790000000000-add-paused-subscription-status.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPausedSubscriptionStatus1790000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add 'paused' to the subscription_status enum type + await queryRunner.query(` + ALTER TYPE "subscription_status" + ADD VALUE IF NOT EXISTS 'paused' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Note: PostgreSQL doesn't support removing enum values directly + // To rollback, you would need to recreate the enum without the value + // This is a limitation of PostgreSQL's enum type + // For production, consider using a different approach for status management + // such as a separate status table or string type with check constraints + } +} diff --git a/src/payments/entities/subscription.entity.ts b/src/payments/entities/subscription.entity.ts index ab04c478..9b0eac27 100644 --- a/src/payments/entities/subscription.entity.ts +++ b/src/payments/entities/subscription.entity.ts @@ -18,6 +18,7 @@ export enum SubscriptionStatus { UNPAID = 'unpaid', TRIALING = 'trialing', INCOMPLETE = 'incomplete', + PAUSED = 'paused', } export enum SubscriptionInterval { MONTHLY = 'monthly', diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index 838e5b94..5680da4c 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { CurrencyModule } from '../currency/currency.module'; import { AuditLogModule } from '../audit-log/audit-log.module'; import { IdempotencyModule } from '../common/modules/idempotency.module'; +import { QueueModule } from '../queues/queue.module'; import { Payment } from './entities/payment.entity'; import { Subscription } from './entities/subscription.entity'; import { Invoice } from './entities/invoice.entity'; @@ -12,6 +13,8 @@ import { PricingService } from './services/pricing.service'; import { PricingController } from './controllers/pricing.controller'; import { PaymentReconciliationJob } from './reconciliation/reconciliation.service'; import { PaymentReconciliationController } from './reconciliation/reconciliation.controller'; +import { StripeProvider } from './providers/stripe.provider'; +import { IPaymentProvider } from './providers/payment-provider.interface'; /** * PaymentsModule @@ -24,6 +27,9 @@ import { PaymentReconciliationController } from './reconciliation/reconciliation * * Issue #856 — imports AuditLogModule so PaymentReconciliationJob can log * PAYMENT_RECONCILIATION_MISMATCH audit events. + * + * Issue #1005 — adds StripeProvider and QueueModule for subscription pause/resume + * functionality with provider billing suspension. */ @Module({ imports: [ @@ -32,9 +38,24 @@ import { PaymentReconciliationController } from './reconciliation/reconciliation AuditLogModule, IdempotencyModule, HttpModule, + QueueModule, + ], + providers: [ + PricingService, + PaymentReconciliationJob, + StripeProvider, + { + provide: 'IPaymentProvider', + useClass: StripeProvider, + }, ], - providers: [PricingService, PaymentReconciliationJob], controllers: [PricingController, PaymentReconciliationController], - exports: [PricingService, CurrencyModule, IdempotencyModule, PaymentReconciliationJob], + exports: [ + PricingService, + CurrencyModule, + IdempotencyModule, + PaymentReconciliationJob, + 'IPaymentProvider', + ], }) export class PaymentsModule {} diff --git a/src/payments/providers/payment-provider.interface.ts b/src/payments/providers/payment-provider.interface.ts index a887dff5..97997e93 100644 --- a/src/payments/providers/payment-provider.interface.ts +++ b/src/payments/providers/payment-provider.interface.ts @@ -23,6 +23,10 @@ export interface IPaymentProvider { cancelSubscription(subscriptionId: string): Promise; + pauseSubscription(subscriptionId: string, resumeAt?: Date): Promise; + + resumeSubscription(subscriptionId: string): Promise; + refundPayment( paymentId: string, amount?: number, diff --git a/src/payments/providers/stripe.provider.ts b/src/payments/providers/stripe.provider.ts new file mode 100644 index 00000000..db1b086b --- /dev/null +++ b/src/payments/providers/stripe.provider.ts @@ -0,0 +1,175 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Stripe from 'stripe'; +import { IPaymentProvider } from './payment-provider.interface'; + +@Injectable() +export class StripeProvider implements IPaymentProvider { + private readonly logger = new Logger(StripeProvider.name); + private readonly stripe: Stripe; + + constructor(private readonly configService: ConfigService) { + const secretKey = this.configService.get('STRIPE_SECRET_KEY'); + if (!secretKey) { + throw new Error('STRIPE_SECRET_KEY is not configured'); + } + this.stripe = new Stripe(secretKey, { + apiVersion: '2025-08-27.basil', + }); + } + + get name(): string { + return 'stripe'; + } + + async createPaymentIntent( + amount: number, + currency: string, + metadata?: Record, + ): Promise<{ + clientSecret: string; + paymentIntentId: string; + requiresAction?: boolean; + }> { + const paymentIntent = await this.stripe.paymentIntents.create({ + amount: Math.round(amount * 100), // Convert to cents + currency: currency.toLowerCase(), + metadata, + }); + + return { + clientSecret: paymentIntent.client_secret!, + paymentIntentId: paymentIntent.id, + requiresAction: paymentIntent.status === 'requires_action', + }; + } + + async createSubscription( + customerId: string, + priceId: string, + metadata?: Record, + ): Promise<{ + subscriptionId: string; + status: string; + currentPeriodEnd: Date; + }> { + const subscription = await this.stripe.subscriptions.create({ + customer: customerId, + items: [{ price: priceId }], + metadata, + }); + + return { + subscriptionId: subscription.id, + status: subscription.status, + currentPeriodEnd: new Date(subscription.items.data[0].current_period_end * 1000), + }; + } + + async cancelSubscription(subscriptionId: string): Promise { + try { + await this.stripe.subscriptions.cancel(subscriptionId); + return true; + } catch (error) { + this.logger.error(`Failed to cancel Stripe subscription ${subscriptionId}`, error); + return false; + } + } + + async pauseSubscription(subscriptionId: string, resumeAt?: Date): Promise { + try { + const pauseCollection: Stripe.SubscriptionUpdateParams.PauseCollection = { + behavior: 'keep_as_draft', + }; + + if (resumeAt) { + pauseCollection.resumes_at = Math.floor(resumeAt.getTime() / 1000); + } + + const pauseParams: Stripe.SubscriptionUpdateParams = { + pause_collection: pauseCollection, + }; + + await this.stripe.subscriptions.update(subscriptionId, pauseParams); + this.logger.log(`Successfully paused Stripe subscription ${subscriptionId}`); + return true; + } catch (error) { + this.logger.error(`Failed to pause Stripe subscription ${subscriptionId}`, error); + throw error; + } + } + + async resumeSubscription(subscriptionId: string): Promise { + try { + await this.stripe.subscriptions.update(subscriptionId, { + pause_collection: null, + }); + this.logger.log(`Successfully resumed Stripe subscription ${subscriptionId}`); + return true; + } catch (error) { + this.logger.error(`Failed to resume Stripe subscription ${subscriptionId}`, error); + throw error; + } + } + + async refundPayment( + paymentId: string, + amount?: number, + ): Promise<{ + refundId: string; + status: string; + }> { + const refundParams: Stripe.RefundCreateParams = { + payment_intent: paymentId, + }; + + if (amount) { + refundParams.amount = Math.round(amount * 100); // Convert to cents + } + + const refund = await this.stripe.refunds.create(refundParams); + + return { + refundId: refund.id, + status: refund.status, + }; + } + + async handleWebhook( + payload: any, + signature: string, + ): Promise<{ + type: string; + data: any; + }> { + const webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET'); + + if (!webhookSecret) { + throw new Error('STRIPE_WEBHOOK_SECRET is not configured'); + } + + const event = this.stripe.webhooks.constructEvent(payload, signature, webhookSecret); + + return { + type: event.type, + data: event.data, + }; + } + + async verifyWebhookSignature(payload: any, signature: string): Promise { + const webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET'); + + if (!webhookSecret) { + this.logger.error('STRIPE_WEBHOOK_SECRET is not configured'); + return false; + } + + try { + this.stripe.webhooks.constructEvent(payload, signature, webhookSecret); + return true; + } catch (error) { + this.logger.error('Webhook signature verification failed', error); + return false; + } + } +} diff --git a/src/payments/subscriptions/subscription-job.processor.ts b/src/payments/subscriptions/subscription-job.processor.ts index cfd09f6e..d86f69b6 100644 --- a/src/payments/subscriptions/subscription-job.processor.ts +++ b/src/payments/subscriptions/subscription-job.processor.ts @@ -1,14 +1,108 @@ -import { Processor, Process } from '@nestjs/bull'; +import { + Processor, + Process, + OnQueueActive, + OnQueueCompleted, + OnQueueFailed, +} from '@nestjs/bull'; +import { Inject } from '@nestjs/common'; import { Job } from 'bull'; import { Logger } from '@nestjs/common'; import { QUEUE_NAMES, JOB_NAMES } from '../../common/constants/queue.constants'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Subscription, SubscriptionStatus } from '../entities/subscription.entity'; +import { IPaymentProvider } from '../providers/payment-provider.interface'; + @Processor(QUEUE_NAMES.SUBSCRIPTIONS) export class SubscriptionJobProcessor { private readonly logger = new Logger(SubscriptionJobProcessor.name); + + constructor( + @InjectRepository(Subscription) + private subscriptionRepository: Repository, + @Inject('IPaymentProvider') + private paymentProvider: IPaymentProvider, + ) {} + @Process(JOB_NAMES.PROCESS_SUBSCRIPTION) async handleSubscription(job: Job): Promise { // Process subscription job this.logger.log('Processing subscription job:', job.data); return { success: true }; } + + @Process(JOB_NAMES.RESUME_SUBSCRIPTION) + async handleResumeSubscription( + job: Job<{ subscriptionId: string }>, + ): Promise<{ success: boolean; message: string }> { + const { subscriptionId } = job.data; + + try { + this.logger.log(`Processing resume subscription job for ${subscriptionId}`); + + const subscription = await this.subscriptionRepository.findOne({ + where: { id: subscriptionId }, + }); + + if (!subscription) { + this.logger.error(`Subscription ${subscriptionId} not found`); + return { success: false, message: 'Subscription not found' }; + } + + if (subscription.status !== SubscriptionStatus.PAUSED) { + this.logger.warn( + `Subscription ${subscriptionId} is not paused (status: ${subscription.status})`, + ); + return { success: false, message: 'Subscription is not paused' }; + } + + if (!subscription.providerSubscriptionId) { + this.logger.error(`Subscription ${subscriptionId} has no provider subscription ID`); + return { success: false, message: 'No provider subscription ID' }; + } + + // Resume at provider (Stripe) first + try { + await this.paymentProvider.resumeSubscription(subscription.providerSubscriptionId); + } catch (error) { + this.logger.error(`Failed to resume subscription ${subscriptionId} at provider`, error); + return { success: false, message: 'Provider resume failed' }; + } + + // Resume the subscription locally only after provider succeeds + subscription.status = SubscriptionStatus.ACTIVE; + subscription.cancelAtPeriodEnd = false; + subscription.properties = { + ...subscription.properties, + isPaused: false, + resumedAt: new Date(), + resumeReason: 'Scheduled automatic resume', + }; + + await this.subscriptionRepository.save(subscription); + + this.logger.log(`Successfully resumed subscription ${subscriptionId} via scheduled job`); + + return { success: true, message: 'Subscription resumed successfully' }; + } catch (error) { + this.logger.error(`Failed to resume subscription ${subscriptionId}`, error); + throw error; + } + } + + @OnQueueActive() + onActive(job: Job) { + this.logger.debug(`Processing job ${job.id} of type ${job.name}`); + } + + @OnQueueCompleted() + onCompleted(job: Job, result: any) { + this.logger.debug(`Completed job ${job.id} of type ${job.name}. Result:`, result); + } + + @OnQueueFailed() + onFailed(job: Job, err: Error) { + this.logger.error(`Failed job ${job.id} of type ${job.name}. Error:`, err.message); + } } diff --git a/src/payments/subscriptions/subscriptions.service.spec.ts b/src/payments/subscriptions/subscriptions.service.spec.ts new file mode 100644 index 00000000..3bcb4811 --- /dev/null +++ b/src/payments/subscriptions/subscriptions.service.spec.ts @@ -0,0 +1,362 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { SubscriptionsService } from './subscriptions.service'; +import { + Subscription, + SubscriptionStatus, + SubscriptionInterval, +} from '../entities/subscription.entity'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { IPaymentProvider } from '../providers/payment-provider.interface'; +import { QueueService } from '../../queues/queue.service'; +import { PauseSubscriptionDto, ResumeSubscriptionDto } from './dto/subscription-action.dto'; + +describe('SubscriptionsService - Pause/Resume Functionality', () => { + let service: SubscriptionsService; + let subscriptionRepository: jest.Mocked>; + let paymentProvider: jest.Mocked; + let queueService: jest.Mocked; + let eventEmitter: jest.Mocked; + + const mockSubscription: Subscription = { + id: 'sub-1', + providerSubscriptionId: 'stripe-sub-1', + status: SubscriptionStatus.ACTIVE, + interval: SubscriptionInterval.MONTHLY, + amount: 29.99, + currency: 'USD', + currency: 'usd', + cancelledAt: null, + trialStart: null, + trialEnd: null, + currentPeriodStart: new Date(), + currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + cancelAtPeriodEnd: false, + userId: 'user-1', + user: {} as any, + properties: {}, + createdAt: new Date(), + updatedAt: new Date(), + version: 1, + }; + + beforeEach(async () => { + subscriptionRepository = { + findOne: jest.fn(), + save: jest.fn(), + } as any; + + paymentProvider = { + pauseSubscription: jest.fn(), + resumeSubscription: jest.fn(), + } as any; + + queueService = { + addJob: jest.fn(), + } as any; + + eventEmitter = { + emit: jest.fn(), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SubscriptionsService, + { + provide: getRepositoryToken(Subscription), + useValue: subscriptionRepository, + }, + { + provide: 'IPaymentProvider', + useValue: paymentProvider, + }, + { + provide: QueueService, + useValue: queueService, + }, + { + provide: EventEmitter2, + useValue: eventEmitter, + }, + ], + }).compile(); + + service = module.get(SubscriptionsService); + }); + + describe('pauseSubscription', () => { + it('should pause subscription successfully with provider call', async () => { + const pauseDto: PauseSubscriptionDto = { + reason: 'User requested pause', + }; + + subscriptionRepository.findOne.mockResolvedValue(mockSubscription); + paymentProvider.pauseSubscription.mockResolvedValue(true); + subscriptionRepository.save.mockResolvedValue({ + ...mockSubscription, + status: SubscriptionStatus.PAUSED, + properties: { + ...mockSubscription.properties, + pausedAt: new Date(), + pauseReason: 'User requested pause', + isPaused: true, + }, + }); + + const result = await service.pauseSubscription('sub-1', pauseDto); + + expect(result.status).toBe(SubscriptionStatus.PAUSED); + expect(paymentProvider.pauseSubscription).toHaveBeenCalledWith('stripe-sub-1', undefined); + expect(subscriptionRepository.save).toHaveBeenCalled(); + expect(eventEmitter.emit).toHaveBeenCalledWith('subscription.paused', { + subscriptionId: 'sub-1', + userId: 'user-1', + resumeAt: undefined, + reason: 'User requested pause', + }); + }); + + it('should schedule resume job when resumeAt is provided', async () => { + const resumeAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days from now + const pauseDto: PauseSubscriptionDto = { + reason: 'Temporary pause', + resumeAt: resumeAt.toISOString(), + }; + + subscriptionRepository.findOne.mockResolvedValue(mockSubscription); + paymentProvider.pauseSubscription.mockResolvedValue(true); + subscriptionRepository.save.mockResolvedValue({ + ...mockSubscription, + status: SubscriptionStatus.PAUSED, + properties: { + ...mockSubscription.properties, + pausedAt: new Date(), + pauseReason: 'Temporary pause', + resumeAt: resumeAt.toISOString(), + isPaused: true, + }, + }); + queueService.addJob.mockResolvedValue({ + jobId: 'job-1', + queue: 'subscriptions', + name: 'resume_subscription', + }); + + await service.pauseSubscription('sub-1', pauseDto); + + expect(queueService.addJob).toHaveBeenCalledWith( + 'subscriptions', + 'resume_subscription', + { subscriptionId: 'sub-1' }, + expect.objectContaining({ + delay: expect.any(Number), + attempts: 3, + }), + ); + }); + + it('should throw error if subscription is not ACTIVE', async () => { + const inactiveSubscription = { ...mockSubscription, status: SubscriptionStatus.CANCELLED }; + subscriptionRepository.findOne.mockResolvedValue(inactiveSubscription); + + await expect(service.pauseSubscription('sub-1', {})).rejects.toThrow(BadRequestException); + await expect(service.pauseSubscription('sub-1', {})).rejects.toThrow( + 'Cannot pause subscription with status: cancelled. Must be active.', + ); + }); + + it('should throw error if subscription has no provider ID', async () => { + const subscriptionWithoutProvider = { ...mockSubscription, providerSubscriptionId: null }; + subscriptionRepository.findOne.mockResolvedValue(subscriptionWithoutProvider); + + await expect(service.pauseSubscription('sub-1', {})).rejects.toThrow(BadRequestException); + await expect(service.pauseSubscription('sub-1', {})).rejects.toThrow( + 'Subscription does not have a provider subscription ID', + ); + }); + + it('should throw error if provider pause call fails', async () => { + subscriptionRepository.findOne.mockResolvedValue(mockSubscription); + paymentProvider.pauseSubscription.mockRejectedValue(new Error('Stripe API error')); + + await expect(service.pauseSubscription('sub-1', {})).rejects.toThrow(BadRequestException); + await expect(service.pauseSubscription('sub-1', {})).rejects.toThrow( + 'Failed to pause subscription at provider: Stripe API error', + ); + }); + + it('should not update local state if provider call fails (rollback)', async () => { + subscriptionRepository.findOne.mockResolvedValue(mockSubscription); + paymentProvider.pauseSubscription.mockRejectedValue(new Error('Provider error')); + + await expect(service.pauseSubscription('sub-1', {})).rejects.toThrow(); + + expect(subscriptionRepository.save).not.toHaveBeenCalled(); + expect(eventEmitter.emit).not.toHaveBeenCalled(); + }); + }); + + describe('resumeSubscription', () => { + it('should resume subscription successfully with provider call', async () => { + const pausedSubscription = { + ...mockSubscription, + status: SubscriptionStatus.PAUSED, + properties: { isPaused: true, pausedAt: new Date() }, + }; + const resumeDto: ResumeSubscriptionDto = { + reason: 'User requested resume', + }; + + subscriptionRepository.findOne.mockResolvedValue(pausedSubscription); + paymentProvider.resumeSubscription.mockResolvedValue(true); + subscriptionRepository.save.mockResolvedValue({ + ...pausedSubscription, + status: SubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: false, + properties: { + ...pausedSubscription.properties, + isPaused: false, + resumedAt: new Date(), + resumeReason: 'User requested resume', + }, + }); + + const result = await service.resumeSubscription('sub-1', resumeDto); + + expect(result.status).toBe(SubscriptionStatus.ACTIVE); + expect(paymentProvider.resumeSubscription).toHaveBeenCalledWith('stripe-sub-1'); + expect(subscriptionRepository.save).toHaveBeenCalled(); + expect(eventEmitter.emit).toHaveBeenCalledWith('subscription.resumed', { + subscriptionId: 'sub-1', + userId: 'user-1', + reason: 'User requested resume', + }); + }); + + it('should throw error if subscription is not PAUSED', async () => { + subscriptionRepository.findOne.mockResolvedValue(mockSubscription); + + await expect(service.resumeSubscription('sub-1', {})).rejects.toThrow(BadRequestException); + await expect(service.resumeSubscription('sub-1', {})).rejects.toThrow( + 'Subscription is not paused', + ); + }); + + it('should throw error if subscription has no provider ID', async () => { + const pausedSubscription = { + ...mockSubscription, + status: SubscriptionStatus.PAUSED, + providerSubscriptionId: null, + }; + subscriptionRepository.findOne.mockResolvedValue(pausedSubscription); + + await expect(service.resumeSubscription('sub-1', {})).rejects.toThrow(BadRequestException); + await expect(service.resumeSubscription('sub-1', {})).rejects.toThrow( + 'Subscription does not have a provider subscription ID', + ); + }); + + it('should throw error if provider resume call fails', async () => { + const pausedSubscription = { + ...mockSubscription, + status: SubscriptionStatus.PAUSED, + properties: { isPaused: true }, + }; + subscriptionRepository.findOne.mockResolvedValue(pausedSubscription); + paymentProvider.resumeSubscription.mockRejectedValue(new Error('Stripe API error')); + + await expect(service.resumeSubscription('sub-1', {})).rejects.toThrow(BadRequestException); + await expect(service.resumeSubscription('sub-1', {})).rejects.toThrow( + 'Failed to resume subscription at provider: Stripe API error', + ); + }); + + it('should not update local state if provider call fails (rollback)', async () => { + const pausedSubscription = { + ...mockSubscription, + status: SubscriptionStatus.PAUSED, + properties: { isPaused: true }, + }; + subscriptionRepository.findOne.mockResolvedValue(pausedSubscription); + paymentProvider.resumeSubscription.mockRejectedValue(new Error('Provider error')); + + await expect(service.resumeSubscription('sub-1', {})).rejects.toThrow(); + + expect(subscriptionRepository.save).not.toHaveBeenCalled(); + expect(eventEmitter.emit).not.toHaveBeenCalled(); + }); + }); + + describe('processRenewal', () => { + it('should skip renewal for paused subscriptions', async () => { + const pausedSubscription = { + ...mockSubscription, + status: SubscriptionStatus.PAUSED, + }; + + subscriptionRepository.findOne.mockResolvedValue(pausedSubscription); + + const result = await service.processRenewal('sub-1'); + + expect(result).toBe(false); + }); + + it('should proceed with renewal for ACTIVE subscriptions', async () => { + subscriptionRepository.findOne.mockResolvedValue(mockSubscription); + subscriptionRepository.save.mockResolvedValue(mockSubscription); + + const result = await service.processRenewal('sub-1'); + + expect(result).toBe(true); + expect(subscriptionRepository.save).toHaveBeenCalled(); + }); + + it('should proceed with renewal for PAST_DUE subscriptions', async () => { + const pastDueSubscription = { + ...mockSubscription, + status: SubscriptionStatus.PAST_DUE, + }; + + subscriptionRepository.findOne.mockResolvedValue(pastDueSubscription); + subscriptionRepository.save.mockResolvedValue(pastDueSubscription); + + const result = await service.processRenewal('sub-1'); + + expect(result).toBe(true); + expect(subscriptionRepository.save).toHaveBeenCalled(); + }); + }); + + describe('getUserSubscription', () => { + it('should return null for paused subscriptions', async () => { + const pausedSubscription = { + ...mockSubscription, + status: SubscriptionStatus.PAUSED, + }; + + subscriptionRepository.findOne.mockResolvedValue(pausedSubscription); + + const result = await service.getUserSubscription('user-1'); + + // The method filters by status = ACTIVE, so it should return null for paused + expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ + where: { userId: 'user-1', status: SubscriptionStatus.ACTIVE }, + relations: ['user'], + }); + }); + + it('should return active subscription when status is ACTIVE', async () => { + subscriptionRepository.findOne.mockResolvedValue(mockSubscription); + + const result = await service.getUserSubscription('user-1'); + + expect(result).toBe(mockSubscription); + expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ + where: { userId: 'user-1', status: SubscriptionStatus.ACTIVE }, + relations: ['user'], + }); + }); + }); +}); diff --git a/src/payments/subscriptions/subscriptions.service.ts b/src/payments/subscriptions/subscriptions.service.ts index a49a803a..d5438637 100644 --- a/src/payments/subscriptions/subscriptions.service.ts +++ b/src/payments/subscriptions/subscriptions.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, BadRequestException, NotFoundException, Inject } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { @@ -13,6 +13,9 @@ import { UpgradeSubscriptionDto, DowngradeSubscriptionDto, } from './dto/subscription-action.dto'; +import { IPaymentProvider } from '../providers/payment-provider.interface'; +import { QueueService } from '../../queues/queue.service'; +import { QUEUE_NAMES, JOB_NAMES } from '../../common/constants/queue.constants'; /** * Handles subscription lifecycle management including pause, resume, upgrade, downgrade @@ -25,6 +28,9 @@ export class SubscriptionsService { @InjectRepository(Subscription) private subscriptionRepository: Repository, private eventEmitter: EventEmitter2, + @Inject('IPaymentProvider') + private paymentProvider: IPaymentProvider, + private queueService: QueueService, ) {} /** @@ -68,28 +74,71 @@ export class SubscriptionsService { ); } - // Update subscription with pause metadata without canceling the subscription. - subscription.properties = { - ...subscription.properties, - pausedAt: new Date(), - pauseReason: dto.reason, - resumeAt: dto.resumeAt, - isPaused: true, - }; + if (!subscription.providerSubscriptionId) { + throw new BadRequestException('Subscription does not have a provider subscription ID'); + } - const updated = await this.subscriptionRepository.save(subscription); + const resumeAtDate = dto.resumeAt ? new Date(dto.resumeAt) : undefined; - // Emit event for downstream processing (notify user, analytics, etc.) - this.eventEmitter.emit('subscription.paused', { - subscriptionId: updated.id, - userId: updated.userId, - resumeAt: dto.resumeAt, - reason: dto.reason, - }); + try { + // Call provider to pause billing + await this.paymentProvider.pauseSubscription( + subscription.providerSubscriptionId, + resumeAtDate, + ); - this.logger.log(`Subscription ${subscriptionId} paused by user ${subscription.userId}`); + // Update subscription status to PAUSED + subscription.status = SubscriptionStatus.PAUSED; + subscription.properties = { + ...subscription.properties, + pausedAt: new Date(), + pauseReason: dto.reason, + resumeAt: dto.resumeAt, + isPaused: true, + }; + + const updated = await this.subscriptionRepository.save(subscription); + + // Schedule automatic resume if resumeAt is provided + if (resumeAtDate) { + const delayMs = resumeAtDate.getTime() - Date.now(); + if (delayMs > 0) { + await this.queueService.addJob( + QUEUE_NAMES.SUBSCRIPTIONS, + JOB_NAMES.RESUME_SUBSCRIPTION, + { subscriptionId: updated.id }, + { + delay: delayMs, + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }, + ); + this.logger.log( + `Scheduled automatic resume for subscription ${subscriptionId} at ${resumeAtDate.toISOString()}`, + ); + } + } - return updated; + // Emit event for downstream processing (notify user, analytics, etc.) + this.eventEmitter.emit('subscription.paused', { + subscriptionId: updated.id, + userId: updated.userId, + resumeAt: dto.resumeAt, + reason: dto.reason, + }); + + this.logger.log(`Subscription ${subscriptionId} paused by user ${subscription.userId}`); + + return updated; + } catch (error) { + this.logger.error(`Failed to pause subscription ${subscriptionId} at provider`, error); + throw new BadRequestException( + `Failed to pause subscription at provider: ${(error as Error).message}`, + ); + } } /** @@ -101,32 +150,46 @@ export class SubscriptionsService { ): Promise { const subscription = await this.getSubscription(subscriptionId); - if (!subscription.properties?.isPaused) { + if (subscription.status !== SubscriptionStatus.PAUSED) { throw new BadRequestException('Subscription is not paused'); } - // Update subscription to active - subscription.status = SubscriptionStatus.ACTIVE; - subscription.cancelAtPeriodEnd = false; - subscription.properties = { - ...subscription.properties, - isPaused: false, - resumedAt: new Date(), - resumeReason: dto.reason, - }; - - const updated = await this.subscriptionRepository.save(subscription); + if (!subscription.providerSubscriptionId) { + throw new BadRequestException('Subscription does not have a provider subscription ID'); + } - // Emit event for downstream processing - this.eventEmitter.emit('subscription.resumed', { - subscriptionId: updated.id, - userId: updated.userId, - reason: dto.reason, - }); + try { + // Call provider to resume billing + await this.paymentProvider.resumeSubscription(subscription.providerSubscriptionId); + + // Update subscription status back to ACTIVE + subscription.status = SubscriptionStatus.ACTIVE; + subscription.cancelAtPeriodEnd = false; + subscription.properties = { + ...subscription.properties, + isPaused: false, + resumedAt: new Date(), + resumeReason: dto.reason, + }; + + const updated = await this.subscriptionRepository.save(subscription); + + // Emit event for downstream processing + this.eventEmitter.emit('subscription.resumed', { + subscriptionId: updated.id, + userId: updated.userId, + reason: dto.reason, + }); - this.logger.log(`Subscription ${subscriptionId} resumed by user ${subscription.userId}`); + this.logger.log(`Subscription ${subscriptionId} resumed by user ${subscription.userId}`); - return updated; + return updated; + } catch (error) { + this.logger.error(`Failed to resume subscription ${subscriptionId} at provider`, error); + throw new BadRequestException( + `Failed to resume subscription at provider: ${(error as Error).message}`, + ); + } } /** @@ -292,6 +355,12 @@ export class SubscriptionsService { async processRenewal(subscriptionId: string, maxRetries = 3): Promise { const subscription = await this.getSubscription(subscriptionId); + // Skip paused subscriptions - they should not be renewed + if (subscription.status === SubscriptionStatus.PAUSED) { + this.logger.log(`Skipping renewal for paused subscription ${subscriptionId}`); + return false; + } + if ( subscription.status !== SubscriptionStatus.ACTIVE && subscription.status !== SubscriptionStatus.PAST_DUE From 8284d9df8f24649c6dc687438f6b895ba4b9f8ef Mon Sep 17 00:00:00 2001 From: Fawaz Date: Tue, 28 Jul 2026 16:12:16 +0100 Subject: [PATCH 2/2] style: fix lint issues --- .../1790000000000-add-paused-subscription-status.ts | 2 +- src/payments/payments.module.ts | 1 - .../subscriptions/subscription-job.processor.ts | 12 +++--------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/migrations/1790000000000-add-paused-subscription-status.ts b/src/migrations/1790000000000-add-paused-subscription-status.ts index d631edbd..89346b3a 100644 --- a/src/migrations/1790000000000-add-paused-subscription-status.ts +++ b/src/migrations/1790000000000-add-paused-subscription-status.ts @@ -9,7 +9,7 @@ export class AddPausedSubscriptionStatus1790000000000 implements MigrationInterf `); } - public async down(queryRunner: QueryRunner): Promise { + public async down(_queryRunner: QueryRunner): Promise { // Note: PostgreSQL doesn't support removing enum values directly // To rollback, you would need to recreate the enum without the value // This is a limitation of PostgreSQL's enum type diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index 5680da4c..b8281c17 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -14,7 +14,6 @@ import { PricingController } from './controllers/pricing.controller'; import { PaymentReconciliationJob } from './reconciliation/reconciliation.service'; import { PaymentReconciliationController } from './reconciliation/reconciliation.controller'; import { StripeProvider } from './providers/stripe.provider'; -import { IPaymentProvider } from './providers/payment-provider.interface'; /** * PaymentsModule diff --git a/src/payments/subscriptions/subscription-job.processor.ts b/src/payments/subscriptions/subscription-job.processor.ts index d86f69b6..5206fb11 100644 --- a/src/payments/subscriptions/subscription-job.processor.ts +++ b/src/payments/subscriptions/subscription-job.processor.ts @@ -1,13 +1,7 @@ -import { - Processor, - Process, - OnQueueActive, - OnQueueCompleted, - OnQueueFailed, -} from '@nestjs/bull'; -import { Inject } from '@nestjs/common'; +import { Processor, Process, OnQueueActive, OnQueueCompleted, OnQueueFailed } from '@nestjs/bull'; + +import { Inject, Logger } from '@nestjs/common'; import { Job } from 'bull'; -import { Logger } from '@nestjs/common'; import { QUEUE_NAMES, JOB_NAMES } from '../../common/constants/queue.constants'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm';