Skip to content
Merged
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
45 changes: 45 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/common/constants/queue.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ export const JOB_NAMES = {
// Payments queues
PROCESS_SUBSCRIPTION: 'process_subscription',
PROCESS_WEBHOOK: 'process-webhook',
RESUME_SUBSCRIPTION: 'resume_subscription',
} as const;
19 changes: 19 additions & 0 deletions src/migrations/1790000000000-add-paused-subscription-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddPausedSubscriptionStatus1790000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
// 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
}
}
1 change: 1 addition & 0 deletions src/payments/entities/subscription.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum SubscriptionStatus {
UNPAID = 'unpaid',
TRIALING = 'trialing',
INCOMPLETE = 'incomplete',
PAUSED = 'paused',
}
export enum SubscriptionInterval {
MONTHLY = 'monthly',
Expand Down
24 changes: 22 additions & 2 deletions src/payments/payments.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,6 +13,7 @@ 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';

/**
* PaymentsModule
Expand All @@ -24,6 +26,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: [
Expand All @@ -32,9 +37,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 {}
4 changes: 4 additions & 0 deletions src/payments/providers/payment-provider.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export interface IPaymentProvider {

cancelSubscription(subscriptionId: string): Promise<boolean>;

pauseSubscription(subscriptionId: string, resumeAt?: Date): Promise<boolean>;

resumeSubscription(subscriptionId: string): Promise<boolean>;

refundPayment(
paymentId: string,
amount?: number,
Expand Down
175 changes: 175 additions & 0 deletions src/payments/providers/stripe.provider.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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<string, any>,
): 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<string, any>,
): 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<boolean> {
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<boolean> {
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<boolean> {
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<string>('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<boolean> {
const webhookSecret = this.configService.get<string>('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;
}
}
}
Loading
Loading