import { ConflictException } from '@nestjs/common' import { Test, TestingModule } from '@nestjs/testing' import { datatype, date } from 'faker' import { cloneDeep, sample } from 'lodash' import { factory } from '../../../../test/factories' import { MockedModule } from '../../../../test/types' import { BusinessExceptionError, NonSubscriberError, NotFoundError, SubscribeError, UnprocessableEntityError, } from '../../../errors/' import { PatientRepository, PlanRepository, SubscriptionInstallmentRepository } from '../../../repositories' import { CancellationUser, CardParams, N2BPaymentsService, PaymentProviderData, SubscriptionService, TokenType, } from '../../../services' import { Addon, Person, SmartfitApi } from '../../../services/smartfit' import { CancellationReason, CancellationsReasons, CardType, Patient, PaymentProviders, Plan, Subscription as SubscriptionModel, SubscriptionInstallment, } from './../../../models' import { AppleCheckSubscription, GoogleCheckSubscription, SmartfitCheckSubscription } from './check' import { Subscription } from './subscription' describe('Subscription', () => { let subject: Subscription let patientRepository: MockedModule let subscriptionService: MockedModule let planRepository: MockedModule let smartfitApi: MockedModule let smartfitCheckSubscription: MockedModule let appleCheckSubscription: MockedModule let googleCheckSubscription: MockedModule let subscriptionInstallmentRepository: MockedModule let n2bPaymentsService: MockedModule let error: any beforeAll(async () => { patientRepository = { save: jest.fn(), get: jest.fn(), } subscriptionService = { subscribe: jest.fn(), getPerson: jest.fn(), getActiveAddon: jest.fn(), addSubscriptionWithAddon: jest.fn(), addSubscription: jest.fn(), getAllowAddons: jest.fn(), cancelSubscription: jest.fn(), getSubscriptionAmountToRefund: jest.fn(), updateSuspendedDate: jest.fn(), addTransaction: jest.fn(), } planRepository = { findByAddonId: jest.fn(), getWithFeatures: jest.fn(), } smartfitApi = { removeAddon: jest.fn(), getPersonByLogin: jest.fn(), } smartfitCheckSubscription = { check: jest.fn(), } appleCheckSubscription = { check: jest.fn(), } googleCheckSubscription = { check: jest.fn(), } subscriptionInstallmentRepository = { findOverdueBySubscription: jest.fn(), save: jest.fn(), } n2bPaymentsService = { chargeInstallment: jest.fn(), } const app: TestingModule = await Test.createTestingModule({ controllers: [], providers: [ { provide: PatientRepository, useValue: patientRepository, }, { provide: SubscriptionService, useValue: subscriptionService, }, { provide: PlanRepository, useValue: planRepository, }, { provide: SmartfitApi, useValue: smartfitApi, }, { provide: SmartfitCheckSubscription, useValue: smartfitCheckSubscription, }, { provide: AppleCheckSubscription, useValue: appleCheckSubscription, }, { provide: GoogleCheckSubscription, useValue: googleCheckSubscription, }, { provide: SubscriptionInstallmentRepository, useValue: subscriptionInstallmentRepository, }, { provide: N2BPaymentsService, useValue: n2bPaymentsService, }, Subscription, ], }).compile() subject = app.get(Subscription) }) beforeEach(() => { error = undefined }) afterEach(() => { jest.resetAllMocks() }) afterAll(() => { jest.restoreAllMocks() }) describe('#subscribe', () => { const patientId = datatype.uuid() const planId = datatype.uuid() let patient: Patient let plan: Plan let paymentProvider: PaymentProviderData let originalTransaction: string beforeAll(async () => { patient = await factory.build('patient', { id: patientId }) plan = await factory.build('plan', { id: planId }) paymentProvider = { provider: plan.payment_providers[0].provider, data: {} } originalTransaction = datatype.uuid() global.secret = { n2b_payments: { enable: false, endpoint: '', token: '', }, } as any }) beforeEach(() => { patientRepository.get.mockResolvedValue(patient) planRepository.getWithFeatures.mockResolvedValue(plan) }) it('calls to subscribe the user with the plan', async () => { await subject.subscribe({ planId, patientId, paymentProvider: paymentProvider.provider, originalTransaction, data: {}, }) expect(subscriptionService.subscribe).toHaveBeenCalledWith( expect.objectContaining({ patient, plan, paymentProvider, options: { originalTransaction }, }), ) }) describe('when has card', () => { let card: CardParams beforeAll(() => { card = { token: datatype.uuid(), kind: sample(['credit', 'debit']), } }) it('calls to subscribe with card as payment provider data param', async () => { await subject.subscribe({ planId, patientId, paymentProvider: paymentProvider.provider, originalTransaction, card, data: {}, }) expect(subscriptionService.subscribe).toHaveBeenCalledWith( expect.objectContaining({ patient, plan, paymentProvider: { ...paymentProvider, data: { card: { ...card, tokenType: TokenType.PAYMENT_METHOD_TOKEN } }, }, options: { originalTransaction }, }), ) }) }) describe('when payment provider is SMARTFIT', () => { beforeAll(() => { paymentProvider.provider = PaymentProviders.SMARTFIT }) it('subscribes with smartfit payment provider', async () => { await subject.subscribe({ planId, patientId, paymentProvider: paymentProvider.provider, originalTransaction, data: {}, }) expect(subscriptionService.subscribe).toHaveBeenCalledWith( expect.objectContaining({ paymentProvider: { provider: PaymentProviders.SMARTFIT, data: {} }, }), ) }) }) }) describe('#cancel()', () => { let patient: Patient let person: Person let addon: Addon let amountToRefund: number let cancellationReason: CancellationReason = { id: CancellationsReasons.CONCILIATION } let cancellationBy: CancellationUser = { id: datatype.uuid() } let cancelSubscriptionWithAddonSpy: jest.SpyInstance let cancelSubscriptionAndRefundSpy: jest.SpyInstance const cancellationRequestedByPatientAt: Date = date.past() beforeAll(() => { cancelSubscriptionWithAddonSpy = jest.spyOn(subject, 'cancelSubscriptionWithAddon') cancelSubscriptionAndRefundSpy = jest.spyOn(subject, 'cancelSubscriptionAndRefund') }) beforeEach(async () => { patientRepository.get.mockResolvedValue(patient) subscriptionService.getActiveAddon.mockResolvedValue(addon) subscriptionService.getAllowAddons.mockResolvedValue([addon]) subscriptionService.getPerson.mockResolvedValue(person) try { await subject.cancel( datatype.uuid(), cancellationReason, cancellationBy, cancellationRequestedByPatientAt, amountToRefund, ) } catch (err) { error = err } }) afterAll(() => { cancelSubscriptionWithAddonSpy.mockRestore() cancelSubscriptionAndRefundSpy.mockRestore() }) describe('when patient is a collaborator', () => { beforeAll(async () => { patient = await factory.build('patient:collaborator') }) it('throws an error', () => { expect(error).toBeInstanceOf(UnprocessableEntityError) }) }) describe('when patient isn`t a subscriber', () => { beforeAll(async () => { patient = await factory.build('patient') }) it('throws an error', () => { expect(error).toBeInstanceOf(NonSubscriberError) }) }) describe('when patient is a subscriber', () => { describe('and subscription is in cancellation', () => { beforeAll(async () => { patient = await factory.build('patient:subscriber', { subscription: async () => await factory.build('subscription:in_cancellation'), }) }) it('throws an error', () => { expect(error).toBeInstanceOf(NonSubscriberError) }) }) describe('and subscription isn`t in cancellation', () => { beforeAll(async () => { patient = await factory.build('patient:subscriber') person = await factory.build('smartfit:person') person.authToken = datatype.uuid() cancellationReason = { id: CancellationsReasons.COMPLEX, } cancellationBy = { id: datatype.uuid(), } }) describe('when payment provider is SMARTFIT', () => { beforeAll(() => { patient.subscription.paymentProvider = PaymentProviders.SMARTFIT }) it('cancels subscription with addon', () => { expect(cancelSubscriptionWithAddonSpy).toHaveBeenCalled() }) }) describe('when payment provider is N2B', () => { beforeAll(() => { patient.subscription.paymentProvider = PaymentProviders.N2B }) it('cancels subscription and refund', () => { expect(cancelSubscriptionAndRefundSpy).toHaveBeenCalled() }) describe('when amountToRefund is given', () => { beforeAll(() => { amountToRefund = datatype.number({ min: 1 }) }) it('cancels subscription and refund with amount given', () => { expect(cancelSubscriptionAndRefundSpy).toHaveBeenCalledWith( patient, cancellationReason, cancellationBy, cancellationRequestedByPatientAt, amountToRefund, false, ) }) }) }) }) }) }) describe('#check()', () => { let patient: Patient let result: boolean beforeEach(async () => { patientRepository.get.mockResolvedValue(cloneDeep(patient)) result = undefined try { result = await subject.check(datatype.uuid()) } catch (err) { error = err } }) describe('when patient is a collaborator', () => { beforeAll(async () => { patient = await factory.build('patient', { collaborator: true }) }) it('returns true', () => { expect(result).toBeTruthy() }) }) describe('when patient is not a collaborator', () => { beforeAll(async () => { patient = await factory.build('patient:subscriber', { collaborator: false }) patient.subscription.paymentProvider = PaymentProviders.SMARTFIT }) describe('when patient has a subscription', () => { describe('when patient subscription payment provider is SmartFit', () => { it('calls smartfit check subscription', () => { expect(smartfitCheckSubscription.check).toBeCalledWith(patient) }) describe(`when SmartFit's check subscription throws an error`, () => { beforeAll(() => { smartfitCheckSubscription.check.mockRejectedValue(new Error()) }) it('returns false', () => { expect(result).toEqual(false) }) }) }) const inAppProviders = [PaymentProviders.APPLE, PaymentProviders.GOOGLE] inAppProviders.forEach(paymentProvider => { let service beforeEach(() => { service = paymentProvider === PaymentProviders.APPLE ? appleCheckSubscription : googleCheckSubscription }) describe(`when patient subscription payment provider is ${paymentProvider}`, () => { beforeAll(() => { patient.subscription.paymentProvider = paymentProvider }) it('calls service check subscription', () => { expect(service.check).toBeCalledWith(patient) }) describe('when response is false', () => { beforeEach(() => { service.check.mockResolvedValue(false) }) it('try SmartFit service check subscription', () => { expect(smartfitCheckSubscription.check).toBeCalledWith(patient) }) }) }) }) describe('when patient subscription payment provider is N2B', () => { beforeAll(() => { patient.subscription.paymentProvider = PaymentProviders.N2B }) describe('when patients is a subscriber', () => { it('returns true', () => { expect(result).toEqual(true) }) }) describe(`when patients isn't a subscriber`, () => { beforeAll(() => { patient.subscription.cancellationAt = new Date() }) it('try SmartFit service check subscription', () => { expect(smartfitCheckSubscription.check).toBeCalledWith(patient) }) describe('when SmartFit service check subscription retuns true', () => { beforeAll(() => { smartfitCheckSubscription.check.mockResolvedValue(true) }) it('returns true', () => { expect(result).toEqual(true) }) }) }) }) }) describe("when patient hasn't a subscription", () => { beforeAll(() => { patient.subscription = undefined }) it('calls smartfit check subscription', () => { expect(smartfitCheckSubscription.check).toBeCalledWith(patient) }) }) }) }) describe('#change()', () => { let patient: Patient let planId: string let plan: Plan let subscription: SubscriptionModel let person: Person let addon: Addon let result: SubscriptionModel beforeEach(async () => { subscriptionService.getPerson.mockResolvedValue(person) patientRepository.get.mockResolvedValue(cloneDeep(patient)) planRepository.getWithFeatures.mockResolvedValue(plan) subscriptionService.getActiveAddon.mockResolvedValue(addon) subscriptionService.getAllowAddons.mockResolvedValue([addon]) subscriptionService.addSubscriptionWithAddon.mockResolvedValue(cloneDeep(subscription)) subscriptionService.addSubscription.mockResolvedValue(cloneDeep(subscription)) try { result = await subject.change(datatype.uuid(), planId) } catch (err) { error = err } }) const doNothing = (errorType: any) => { it('throws an error', () => { expect(error).toBeInstanceOf(errorType) }) it('don`t cancel current subscription', () => { expect(smartfitApi.removeAddon).not.toBeCalled() }) it('no creates new subscription', () => { expect(subscriptionService.addSubscriptionWithAddon).not.toBeCalled() }) } describe('when patient is collaborator', () => { beforeAll(async () => { patient = await factory.build('patient:collaborator') }) doNothing(UnprocessableEntityError) }) describe('when patient isn`t a collaborator', () => { describe('and patient isn`t a subscriber', () => { beforeAll(async () => { patient = await factory.build('patient') }) doNothing(NonSubscriberError) }) describe('and patient is a subscriber', () => { beforeAll(async () => { patient = await factory.build('patient:subscriber') }) describe('and planId is same as the current plan', () => { beforeAll(() => { planId = patient.subscription.planId }) doNothing(SubscribeError) }) describe('and planId is another plan', () => { beforeAll(async () => { planId = datatype.uuid() }) describe("when subscription's paymentProvider is SMARTFIT", () => { beforeAll(async () => { subscription = await factory.build('subscription', { paymentProvider: PaymentProviders.SMARTFIT }) plan = await factory.build('plan:smartfit') addon = await factory.build(`addon:active`, { addonCode: datatype.uuid() }) patient.subscription.paymentProvider = PaymentProviders.SMARTFIT person = await factory.build('smartfit:person') person.authToken = datatype.uuid() }) it('cancels the current subscription', () => { expect(smartfitApi.removeAddon).toBeCalledWith({ token: person.authToken }, addon.addonCode) expect(subscriptionService.cancelSubscription).toBeCalled() }) it('creates a new subscription', () => { expect(subscriptionService.addSubscriptionWithAddon).toBeCalled() }) it('returns the new subscription', () => { expect(result).toEqual(subscription) }) }) describe("when subscription's paymentProvider is different from SMARTFIT", () => { const paymentProvider = sample( Object.values(PaymentProviders).filter(provider => provider !== PaymentProviders.SMARTFIT), ) beforeAll(async () => { subscription = await factory.build('subscription', { paymentProvider }) plan = await factory.build('plan') patient.subscription.paymentProvider = paymentProvider }) it('cancels the current subscription', () => { expect(smartfitApi.removeAddon).not.toBeCalled() expect(subscriptionService.cancelSubscription).toBeCalled() }) it('creates a new subscription', () => { expect(subscriptionService.addSubscription).toBeCalled() }) it('returns the new subscription', () => { expect(result).toEqual(subscription) }) }) }) }) }) }) describe('#cancelSubscriptionWithAddon', () => { let patient: Patient let person: Person let addon: Addon let addons: Addon[] let cancellationReason: CancellationReason = { id: CancellationsReasons.CONCILIATION } let cancellationBy: CancellationUser = { id: datatype.uuid() } beforeAll(async () => { patient = await factory.build('patient:subscriber') person = await factory.build('smartfit:person') person.authToken = datatype.uuid() cancellationReason = { id: CancellationsReasons.COMPLEX, } cancellationBy = { id: datatype.uuid(), } }) beforeEach(() => { subscriptionService.getPerson.mockResolvedValue(person) subscriptionService.getActiveAddon.mockResolvedValue(addon) subscriptionService.getAllowAddons.mockResolvedValue(addons) }) describe('and has no addons', () => { beforeAll(async () => { addon = undefined addons = [await factory.build(`addon:active`, { addonCode: datatype.uuid() })] }) it('doesn`t removes the addon from SmartFit API', async () => { await subject['cancelSubscriptionWithAddon'](patient, cancellationReason, cancellationBy) expect(smartfitApi.removeAddon).not.toBeCalled() }) }) describe('and has active addons', () => { beforeAll(async () => { addon = await factory.build(`addon:active`, { addonCode: datatype.uuid(), expiresAt: new Date() }) patient.subscription.paymentProvider = PaymentProviders.SMARTFIT }) it(`removes the addon by SmartFit API`, async () => { await subject['cancelSubscriptionWithAddon'](patient, cancellationReason, cancellationBy) expect(smartfitApi.removeAddon).toBeCalledWith( expect.objectContaining({ token: person.authToken }), addon.addonCode, ) }) describe('when does not have allow addons', () => { beforeAll(async () => { addons = [] }) it('cancels subscription with no expires date', async () => { await subject['cancelSubscriptionWithAddon'](patient, cancellationReason, cancellationBy) expect(subscriptionService.cancelSubscription).toBeCalledWith({ patient, cancellationReason, cancellationBy }) }) }) describe('when has allow addons', () => { beforeAll(async () => { addons = [addon] }) it('cancels subscription with expires date given', async () => { await subject['cancelSubscriptionWithAddon'](patient, cancellationReason, cancellationBy) expect(subscriptionService.cancelSubscription).toBeCalledWith({ patient, cancellationReason, cancellationBy, expiresAt: addon.expiresAt, }) }) }) }) }) describe('#cancelSubscriptionAndRefund', () => { const amountToRefundByTransaction = datatype.number({ min: 1 }) let patient: Patient let cancellationReason: CancellationReason = { id: CancellationsReasons.CONCILIATION } let cancellationBy: CancellationUser = { id: datatype.uuid() } const cancellationRequestedByPatientAt: Date = date.past() beforeAll(async () => { patient = await factory.build('patient:subscriber') patient.subscription.paymentProvider = PaymentProviders.N2B cancellationReason = { id: CancellationsReasons.COMPLEX, } cancellationBy = { id: datatype.uuid(), } patient.subscription.trialExpiresAt = date.past() }) it('cancels subscription', async () => { await subject['cancelSubscriptionAndRefund']( patient, cancellationReason, cancellationBy, cancellationRequestedByPatientAt, ) expect(subscriptionService.cancelSubscription).toBeCalled() }) describe('when subscription is trial', () => { beforeAll(() => { patient.subscription.trialExpiresAt = date.future() }) describe('when amount is given', () => { const amountToRefund = datatype.number({ min: 1 }) it('refunds with amount given', async () => { await subject['cancelSubscriptionAndRefund']( patient, cancellationReason, cancellationBy, cancellationRequestedByPatientAt, amountToRefund, ) expect(subscriptionService.cancelSubscription).toBeCalledWith({ patient, cancellationReason, cancellationBy, cancellationRequestedByPatientAt, amount: amountToRefund, }) }) }) describe('when amount is not given', () => { beforeEach(() => { subscriptionService.getSubscriptionAmountToRefund.mockResolvedValue(amountToRefundByTransaction) }) it('refunds with full plan price', async () => { await subject['cancelSubscriptionAndRefund']( patient, cancellationReason, cancellationBy, cancellationRequestedByPatientAt, amountToRefundByTransaction, null, ) expect(subscriptionService.cancelSubscription).toBeCalledWith({ patient, cancellationReason, cancellationBy, cancellationRequestedByPatientAt, amount: amountToRefundByTransaction, }) }) }) }) describe('when subscription is not trial', () => { beforeAll(() => { patient.subscription.trialExpiresAt = date.past() }) describe('when amount is given', () => { const amountToRefund = datatype.number({ min: 1 }) it('refunds with amount given', async () => { await subject['cancelSubscriptionAndRefund']( patient, cancellationReason, cancellationBy, cancellationRequestedByPatientAt, amountToRefund, ) expect(subscriptionService.cancelSubscription).toBeCalledWith({ patient, cancellationReason, cancellationBy, cancellationRequestedByPatientAt, amount: amountToRefund, }) }) }) describe('when amount is not given', () => { it('does not refund', async () => { await subject['cancelSubscriptionAndRefund']( patient, cancellationReason, cancellationBy, cancellationRequestedByPatientAt, ) expect(subscriptionService.cancelSubscription).toBeCalledWith({ patient, cancellationReason, cancellationBy, cancellationRequestedByPatientAt, }) }) }) }) }) describe('#regularizeSubscription', () => { let patientId: string let token: string let kind: CardType let patient: Patient beforeAll(() => { token = datatype.uuid() kind = 'credit' }) beforeEach(() => { patientRepository.get.mockResolvedValue(patient) }) describe(`when patient doesn't exists`, () => { beforeAll(() => { patient = undefined }) it('throws an error', async () => { await expect(subject.regularizeSubscription(patientId, token, kind)).rejects.toThrow(NotFoundError) }) }) describe(`when patient exists`, () => { describe(`when subscription doesn't exists`, () => { beforeAll(async () => { patient = await factory.build('patient') }) it('throws an error', async () => { await expect(subject.regularizeSubscription(patientId, token, kind)).rejects.toThrow(BusinessExceptionError) }) }) describe(`when subscription exists`, () => { beforeAll(async () => { patient = await factory.build('patient:subscriber') }) describe(`when subscription is cancelled`, () => { beforeAll(() => { patient.subscription.cancellationAt = new Date() }) it('throws an error', async () => { await expect(subject.regularizeSubscription(patientId, token, kind)).rejects.toThrow(BusinessExceptionError) }) }) describe(`when subscription is active`, () => { let installment: SubscriptionInstallment beforeAll(() => { patient.subscription.cancellationAt = undefined }) beforeEach(() => { subscriptionInstallmentRepository.findOverdueBySubscription.mockResolvedValue(installment) }) describe(`when has't overdue installments`, () => { beforeAll(async () => { installment = null }) it('throws an error', async () => { await expect(subject.regularizeSubscription(patientId, token, kind)).rejects.toThrow(ConflictException) }) }) describe(`when has overdue installment`, () => { beforeAll(async () => { installment = await factory.build('subscription_installment:overdue', { paid_at: undefined }) }) describe('when is paid by payments service', () => { beforeEach(() => { n2bPaymentsService.chargeInstallment.mockResolvedValue({ amount: datatype.number({ min: 1 }), paid_at: new Date(), }) }) it('unsuspend subscription', async () => { await subject.regularizeSubscription(patientId, token, kind) expect(subscriptionService.updateSuspendedDate).toHaveBeenCalledWith(patient.subscription, null) }) it('update paid_at of installment', async () => { await subject.regularizeSubscription(patientId, token, kind) expect(subscriptionInstallmentRepository.save).toHaveBeenCalledWith( expect.objectContaining({ id: installment.id, paid_at: expect.any(Date) }), [{ field: 'paid_at', type: 'timestamptz!' }], ) }) }) }) }) }) }) }) })