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
218 changes: 218 additions & 0 deletions backend/__tests__/services/stripeService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { mockReset } from 'jest-mock-extended';

// Preserve Prisma enums (SubscriptionStatus, PaymentStatus) - setup mocks entire @prisma/client
jest.mock('@prisma/client', () => ({
...jest.requireActual('@prisma/client'),
PrismaClient: jest.fn().mockImplementation(() => require('../setup/prisma-mock').prismaMock),
}));

// Mock Stripe - the service uses require('stripe')(key) and calls methods on the result
const mockStripeInstance = {
customers: {
create: jest.fn(),
},
customer: {
create: jest.fn(),
},
subscriptions: {
create: jest.fn(),
retrieve: jest.fn(),
update: jest.fn(),
cancel: jest.fn(),
},
paymentIntents: {
retrieve: jest.fn(),
},
};

jest.mock('stripe', () => {
return jest.fn(() => mockStripeInstance);
});

// Use the prisma mock from global setup - don't override config/prisma
import { prismaMock } from '../setup/prisma-mock.js';
import { StripeService } from '../../services/stripeService.js';

beforeEach(() => {
mockReset(prismaMock);
jest.clearAllMocks();
// Handle both batch [op1, op2] and interactive (tx) => {} transaction forms
(prismaMock.$transaction as jest.Mock).mockImplementation(async (arg: any) => {
if (Array.isArray(arg)) {
return Promise.all(arg);
}
return arg(prismaMock);
});
});

describe('StripeService', () => {
describe('updateSubscriptionAfterPaymentConfirmed', () => {
const mockPayment = {
id: 'pay-1',
stripePaymentIntentId: 'pi_123',
subscriptionId: 'sub-1',
status: 'PENDING' as const,
subscription: {
id: 'sub-1',
stripeSubscriptionId: 'stripe_sub_123',
},
} as any;

const mockStripeSubscription = {
status: 'active',
current_period_start: 1609459200,
current_period_end: 1612137600,
cancel_at_period_end: false,
};

it('updates payment to SUCCEEDED and syncs subscription from Stripe', async () => {
prismaMock.payment.findUnique.mockResolvedValue(mockPayment);
mockStripeInstance.subscriptions.retrieve.mockResolvedValue(mockStripeSubscription);

await StripeService.updateSubscriptionAfterPaymentConfirmed('pi_123');

expect(prismaMock.payment.findUnique).toHaveBeenCalledWith({
where: { stripePaymentIntentId: 'pi_123' },
include: { subscription: true },
});
expect(mockStripeInstance.subscriptions.retrieve).toHaveBeenCalledWith('stripe_sub_123');
expect(prismaMock.payment.update).toHaveBeenCalledWith({
where: { stripePaymentIntentId: 'pi_123' },
data: { status: 'SUCCEEDED' },
});
expect(prismaMock.subscription.update).toHaveBeenCalledWith({
where: { id: 'sub-1' },
data: expect.objectContaining({
status: 'ACTIVE',
currentPeriodStart: expect.any(Date),
currentPeriodEnd: expect.any(Date),
}),
});
});

it('returns early if payment not found (non-subscription payment)', async () => {
prismaMock.payment.findUnique.mockResolvedValue(null);

await StripeService.updateSubscriptionAfterPaymentConfirmed('pi_unknown');

expect(mockStripeInstance.subscriptions.retrieve).not.toHaveBeenCalled();
expect(prismaMock.$transaction).not.toHaveBeenCalled();
});
});

describe('updateSubscriptionAfterPaymentFailed', () => {
const mockSubscription = {
id: 'sub-1',
stripeSubscriptionId: 'stripe_sub_123',
} as any;

const mockStripeSubscription = {
status: 'past_due',
current_period_start: 1609459200,
current_period_end: 1612137600,
cancel_at_period_end: false,
};

const mockPaymentIntent = {
id: 'pi_123',
status: 'requires_payment_method',
};

it('updates subscription and payment status when payment found', async () => {
prismaMock.subscription.findUnique.mockResolvedValue(mockSubscription);
mockStripeInstance.subscriptions.retrieve.mockResolvedValue(mockStripeSubscription);
prismaMock.payment.findUnique.mockResolvedValue({
id: 'pay-1',
stripePaymentIntentId: 'pi_123',
} as any);
mockStripeInstance.paymentIntents.retrieve.mockResolvedValue(mockPaymentIntent);
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock));

await StripeService.updateSubscriptionAfterPaymentFailed('stripe_sub_123', 'pi_123');

expect(mockStripeInstance.subscriptions.retrieve).toHaveBeenCalledWith('stripe_sub_123');
expect(prismaMock.subscription.update).toHaveBeenCalledWith({
where: { id: 'sub-1' },
data: expect.objectContaining({
status: 'PAST_DUE',
}),
});
expect(prismaMock.payment.update).toHaveBeenCalledWith({
where: { stripePaymentIntentId: 'pi_123' },
data: { status: 'FAILED' },
});
});

it('updates only subscription when payment intent id not provided', async () => {
prismaMock.subscription.findUnique.mockResolvedValue(mockSubscription);
mockStripeInstance.subscriptions.retrieve.mockResolvedValue(mockStripeSubscription);
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock));

await StripeService.updateSubscriptionAfterPaymentFailed('stripe_sub_123');

expect(prismaMock.subscription.update).toHaveBeenCalled();
expect(prismaMock.payment.update).not.toHaveBeenCalled();
});

it('returns early if subscription not found in our DB', async () => {
// Service fetches from Stripe first, then checks our DB
mockStripeInstance.subscriptions.retrieve.mockResolvedValue({
status: 'past_due',
current_period_start: 1609459200,
current_period_end: 1612137600,
cancel_at_period_end: false,
});
prismaMock.subscription.findUnique.mockResolvedValue(null);

await StripeService.updateSubscriptionAfterPaymentFailed('stripe_sub_unknown', 'pi_123');

// No DB updates when subscription not in our DB
expect(prismaMock.subscription.update).not.toHaveBeenCalled();
expect(prismaMock.payment.update).not.toHaveBeenCalled();
});
});

describe('handleWebhook', () => {
it('handles events without throwing', async () => {
await expect(
StripeService.handleWebhook({
type: 'invoice.payment_succeeded',
data: { object: { payment_intent: 'pi_123' } },
} as any)
).resolves.toBeUndefined();
});

it('handles unknown event types', async () => {
await expect(
StripeService.handleWebhook({
type: 'customer.unknown',
data: { object: {} },
} as any)
).resolves.toBeUndefined();
});
});

describe('getSubscription', () => {
it('returns subscription status for organization', async () => {
prismaMock.subscription.findUnique.mockResolvedValue({
status: 'ACTIVE' as const,
} as any);

const result = await StripeService.getSubscription('org-1');

expect(result).toBe('ACTIVE');
expect(prismaMock.subscription.findUnique).toHaveBeenCalledWith({
where: { organizationId: 'org-1' },
select: { status: true },
});
});

it('returns undefined when organization has no subscription (service logs error)', async () => {
prismaMock.subscription.findUnique.mockResolvedValue(null);

const result = await StripeService.getSubscription('org-unknown');

expect(result).toBeUndefined();
});
});
});
36 changes: 27 additions & 9 deletions backend/controllers/stripeController.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import { Request, Response } from 'express';
// import { StripeService } from '../services/stripeService.js';
import e, { Response } from 'express';
import { AuthenticatedRequest } from '../types/index.js';
import { StripeService } from '../services/stripeService.js';
// import { stripe } from '../config/stripe.js';
// import { STRIPE_WEBHOOK_SECRET } from '../config/stripe.js';

export const stripeController = {
/**
* Create subscription
*/
createSubscription: async (req: Request, res: Response) => {
createSubscription: async (req: AuthenticatedRequest, res: Response) => {
try {
// Get priceId from request body
// Get organizationId from req.user
// Call StripeService.createSubscription
// Return subscription details with clientSecret
res.status(501).json({ error: 'Not implemented' });
if (!req.user?.id) {
return res
.status(401)
.json({ error: 'User is not authenticated or lacks an organization' });
}
const organizationId = req.user.id;
const { priceId } = req.body;

const subscription = StripeService.createSubscription(organizationId, priceId);
res.status(201).json(subscription);
} catch (error: any) {
console.error('Error creating subscription:', error);
res.status(500).json({ error: error.message });
Expand All @@ -23,11 +33,19 @@ export const stripeController = {
/**
* Get subscription status
*/
getSubscription: async (req: Request, res: Response) => {
getSubscription: async (req: AuthenticatedRequest, res: Response) => {
try {
// Get organizationId from req.user
// Call StripeService.getSubscription
res.status(501).json({ error: 'Not implemented' });
if (!req.user?.id) {
return res
.status(401)
.json({ error: 'User is not authenticated or lacks an organization' });
}
const organizationId = req.user?.id;

const subscription = StripeService.getSubscription(organizationId);
res.status(201).json(subscription);
} catch (error: any) {
console.error('Error getting subscription:', error);
res.status(500).json({ error: error.message });
Expand All @@ -37,7 +55,7 @@ export const stripeController = {
/**
* Cancel subscription
*/
cancelSubscription: async (req: Request, res: Response) => {
cancelSubscription: async (req: AuthenticatedRequest, res: Response) => {
try {
// Get immediate flag from request body
// Get organizationId from req.user
Expand All @@ -52,7 +70,7 @@ export const stripeController = {
/**
* Stripe webhook handler
*/
handleWebhook: async (req: Request, res: Response) => {
handleWebhook: async (req: AuthenticatedRequest, res: Response) => {
try {
// Verify webhook signature
// Call StripeService.handleWebhook
Expand All @@ -66,7 +84,7 @@ export const stripeController = {
/**
* Get available pricing plans
*/
getPrices: async (req: Request, res: Response) => {
getPrices: async (req: AuthenticatedRequest, res: Response) => {
try {
// Fetch active prices from Stripe
res.status(501).json({ error: 'Not implemented' });
Expand Down
Loading