diff --git a/backend/__tests__/services/stripeService.test.ts b/backend/__tests__/services/stripeService.test.ts new file mode 100644 index 0000000..55e48df --- /dev/null +++ b/backend/__tests__/services/stripeService.test.ts @@ -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(); + }); + }); +}); diff --git a/backend/controllers/stripeController.ts b/backend/controllers/stripeController.ts index a9fa0b2..b78177f 100644 --- a/backend/controllers/stripeController.ts +++ b/backend/controllers/stripeController.ts @@ -1,5 +1,6 @@ -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'; @@ -7,13 +8,22 @@ 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 }); @@ -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 }); @@ -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 @@ -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 @@ -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' }); diff --git a/backend/services/stripeService.ts b/backend/services/stripeService.ts index 86cf642..c7eef95 100644 --- a/backend/services/stripeService.ts +++ b/backend/services/stripeService.ts @@ -1,51 +1,356 @@ import { stripe } from '../config/stripe.js'; import { prisma } from '../config/prisma.js'; import Stripe from 'stripe'; +import { SubscriptionStatus, PaymentStatus } from '@prisma/client'; export class StripeService { + private static mapStripeSubscriptionStatus(s: string): SubscriptionStatus { + switch (s) { + case 'active': + return SubscriptionStatus.ACTIVE; + case 'trialing': + return SubscriptionStatus.TRIALING; + case 'incomplete': + return SubscriptionStatus.INCOMPLETE; + case 'past_due': + return SubscriptionStatus.PAST_DUE; + case 'unpaid': + return SubscriptionStatus.PAST_DUE; + case 'canceled': + return SubscriptionStatus.CANCELED; + case 'incomplete_expired': + return SubscriptionStatus.CANCELED; + default: + return SubscriptionStatus.INCOMPLETE; + } + } + private static mapStripePaymentIntentStatus(piStatus: string): PaymentStatus { + switch (piStatus) { + case 'succeeded': + return PaymentStatus.SUCCEEDED; + case 'processing': + case 'requires_confirmation': + case 'requires_action': + return PaymentStatus.PENDING; + case 'canceled': + case 'requires_payment_method': + return PaymentStatus.FAILED; + default: + return PaymentStatus.PENDING; + } + } /** * Create a Stripe customer for an organization */ static async createCustomer(organizationId: string) { - // Implement customer creation - throw new Error('Not implemented'); + try { + const organization = await prisma.organization.findUnique({ + where: { + id: organizationId, + }, + }); + + if (!organization) { + throw new Error('Organization not found'); + } + const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); + const customer = await stripe.customer.create({ + email: organization?.email, + name: organization?.name, + phone: organization?.primaryContactPhone, + }); + + return customer; + } catch (error: any) { + console.error('Create customer error: ', error.message); + throw error; + } } /** * Create or update subscription + * If an organization doesn't have a stripeId than guide them to createCustomer */ static async createSubscription(organizationId: string, priceId: string) { // Implement subscription creation // Handle payment intent and client secret - throw new Error('Not implemented'); + // ToDo: Handle issues with paymentIntent and the cases that come with that + try { + const organization = await prisma.organization.findUnique({ + where: { + id: organizationId, + }, + include: { + subscription: true, + }, + }); + + if (!organization) { + throw new Error('Organization id not found'); + } + + if (organization.subscription) { + throw new Error('Organization already has a subscription'); + } + + const customerInfo = await this.createCustomer(organizationId); + + const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); + + const newSubscription = await stripe.subscriptions.create({ + customer: customerInfo.id, + items: [ + { + price: priceId, + }, + ], + payment_behavior: 'default_incomplete', + expand: ['latest_invoice.payment_intent'], + }); + + const subscriptionStatus = StripeService.mapStripeSubscriptionStatus(newSubscription.status); + + const stripeInvoice = newSubscription.latest_invoice; + if (!stripeInvoice || typeof stripeInvoice === 'string') { + throw new Error('Stripe latest_invoice was not expanded'); + } + + const paymentIntent = stripeInvoice.payment_intent; + + const paymentStatus = + paymentIntent && typeof paymentIntent !== 'string' + ? StripeService.mapStripePaymentIntentStatus(paymentIntent.status) + : PaymentStatus.PENDING; + + const result = await prisma.subscription.create({ + data: { + organizationId: organizationId, + stripeCustomerId: customerInfo.id, + stripeSubscriptionId: newSubscription.id, + stripePriceId: priceId, + status: subscriptionStatus, + currentPeriodStart: new Date(newSubscription.current_period_start * 1000), + currentPeriodEnd: new Date(newSubscription.current_period_end * 1000), + cancelAtPeriodEnd: newSubscription.cancel_at_period_end, + payments: { + create: { + stripePaymentIntentId: paymentIntent.id, + stripeInvoiceId: stripeInvoice.id, + amount: paymentIntent.amount, + currency: paymentIntent.currency, + status: paymentStatus, + }, + }, + invoices: { + create: { + stripeInvoiceId: stripeInvoice.id, + amount: + stripeInvoice.amount_due ?? + stripeInvoice.amount_paid ?? + stripeInvoice.amount_remaining ?? + 0, + currency: stripeInvoice.currency ?? 'usd', + status: stripeInvoice.status ?? 'draft', + invoicePdf: stripeInvoice.invoice_pdf ?? null, + hostedInvoiceUrl: stripeInvoice.hosted_invoice_url ?? null, + + periodStart: new Date( + ((stripeInvoice.period_start ?? stripeInvoice.created) as number) * 1000 + ), + periodEnd: new Date( + ((stripeInvoice.period_end ?? stripeInvoice.created) as number) * 1000 + ), + }, + }, + createdAt: new Date(newSubscription.created * 1000), + updatedAt: new Date(newSubscription.created * 1000), + }, + }); + return { + subscription: result, + clientSecret: paymentIntent.client_secret, + stripeSubscriptionId: newSubscription.id, + stripeInvoiceId: stripeInvoice.id, + stripePaymentIntentId: paymentIntent.id, + }; + } catch (error: any) { + console.error('Create subscription failed: ', error.message); + throw error; + } } /** * Cancel subscription */ static async cancelSubscription(organizationId: string, immediate = false) { - // Implement subscription cancellation - throw new Error('Not implemented'); + try { + const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); + + const subscriptionID = await prisma.subscription.findUnique({ + where: { + organizationId: organizationId, + }, + select: { + id: true, + stripeSubscriptionId: true, + }, + }); + + if (!subscriptionID) { + throw new Error(`No subscription found for : ${organizationId}`); + } + + if (immediate) { + await stripe.subscriptions.cancel(subscriptionID.stripeSubscriptionId); + } else { + await stripe.subscriptions.update(subscriptionID.stripeSubscriptionId, { + cancel_at_period_end: true, + }); + } + + await prisma.subscription.update({ + where: { id: subscriptionID.id }, + data: { + cancelAtPeriodEnd: !immediate, + status: immediate ? SubscriptionStatus.CANCELED : SubscriptionStatus.ACTIVE, + }, + }); + } catch (error: any) { + console.error('Cancel subscription failed: ', error); + throw error; + } + } + + /** + * Update subscription and payment status after Stripe confirms payment. + * Call this from webhook handlers for invoice.payment_succeeded or payment_intent.succeeded. + */ + static async updateSubscriptionAfterPaymentConfirmed(stripePaymentIntentId: string) { + try { + const payment = await prisma.payment.findUnique({ + where: { stripePaymentIntentId }, + include: { subscription: true }, + }); + + if (!payment) { + // Not a subscription payment – ignore (avoids webhook retries for other payment types) + return; + } + + const stripeInstance = require('stripe')(process.env.STRIPE_SECRET_KEY); + const stripeSubscription = await stripeInstance.subscriptions.retrieve( + payment.subscription.stripeSubscriptionId + ); + + const subscriptionStatus = StripeService.mapStripeSubscriptionStatus( + stripeSubscription.status + ); + + await prisma.$transaction([ + prisma.payment.update({ + where: { stripePaymentIntentId }, + data: { status: PaymentStatus.SUCCEEDED }, + }), + prisma.subscription.update({ + where: { id: payment.subscriptionId }, + data: { + status: subscriptionStatus, + currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), + currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), + cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? false, + }, + }), + ]); + } catch (error: any) { + console.error('Update subscription after payment confirmed failed:', error.message); + throw error; + } + } + + /** + * Update subscription and payment status when Stripe reports payment failure. + * Call from webhook handlers for invoice.payment_failed or payment_intent.payment_failed. + */ + static async updateSubscriptionAfterPaymentFailed( + stripeSubscriptionId: string, + stripePaymentIntentId?: string + ) { + try { + const stripeInstance = require('stripe')(process.env.STRIPE_SECRET_KEY); + const stripeSubscription = await stripeInstance.subscriptions.retrieve(stripeSubscriptionId); + + const subscriptionStatus = StripeService.mapStripeSubscriptionStatus( + stripeSubscription.status + ); + + const subscription = await prisma.subscription.findUnique({ + where: { stripeSubscriptionId }, + }); + + if (!subscription) { + return; + } + + let paymentStatus: PaymentStatus | null = null; + if (stripePaymentIntentId) { + const payment = await prisma.payment.findUnique({ + where: { stripePaymentIntentId }, + }); + if (payment) { + const paymentIntent = await stripeInstance.paymentIntents.retrieve(stripePaymentIntentId); + paymentStatus = StripeService.mapStripePaymentIntentStatus(paymentIntent.status); + } + } + + await prisma.$transaction(async tx => { + await tx.subscription.update({ + where: { id: subscription.id }, + data: { + status: subscriptionStatus, + currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), + currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), + cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? false, + }, + }); + if (stripePaymentIntentId && paymentStatus) { + await tx.payment.update({ + where: { stripePaymentIntentId }, + data: { status: paymentStatus }, + }); + } + }); + } catch (error: any) { + console.error('Update subscription after payment failed:', error.message); + throw error; + } } /** * Get subscription status */ static async getSubscription(organizationId: string) { - // Implement get subscription with payments and invoices - throw new Error('Not implemented'); + try { + const subscription = await prisma.subscription.findUnique({ + where: { + organizationId: organizationId, + }, + select: { + status: true, + }, + }); + + if (!subscription) { + throw new Error("Organization doesn't have subscription"); + } + + return subscription.status; + } catch (error: any) { + console.error(`Error getting subscription status for ${organizationId}: `, error); + } } /** * Handle webhook events */ - static async handleWebhook(event: Stripe.Event) { - // Handle different webhook event types: - // - customer.subscription.updated - // - customer.subscription.deleted - // - invoice.payment_succeeded - // - invoice.payment_failed - // - payment_intent.succeeded - throw new Error('Not implemented'); - } + static async handleWebhook(event: Stripe.Event) {} }