import { Injectable } from '@nestjs/common' import { addMonths } from 'date-fns' import { debug, Debugger } from 'debug' import { cloneDeep, isEmpty, isNil, pick } from 'lodash' import { IsAlreadySubscriberError, SubscribeError, SubscriptionTransactionError, UnprocessableEntityError, } from '../../errors' import { DiscountCouponInvalidError } from '../../errors/discount_coupon_invalid.error' import { DiscountCouponNotFoundError } from '../../errors/discount_coupon_not_found.error' import { CancellationReason, CancellationsReasons, CardType, DiscountCoupon, Patient, PaymentProviders, Plan, Subscription, SubscriptionTransaction, } from '../../models' import { SubscriptionInstallment } from '../../models/subscription_installment.model' import { DiscountCouponRepository, PatientRepository, PlanRepository, SubscriptionInstallmentRepository, SubscriptionRepository, SubscriptionTransactionRepository, } from '../../repositories' import { CountryHelper } from '../../utils/countries' import { TeleconsultationsCreditService } from '../credits/teleconsultations_credit.service' import { CardParams, Currency, GenerateSubscriptionParams, Installment, N2BPaymentsService, TransactionError, } from '../n2b-payments' import { Addon, AddonStatus, PaymentMethods, Person, SmartfitApi, SmartfitPlanHelper } from '../smartfit' import { NewAddonError, TokenKindDisallowedError } from './errors' import { IntervalsHelper } from './helpers/intervals.helper' import { CancellationUser, SubscriptionData } from './types' const IMMEDIATE_CANCELLATION_REASONS = [ CancellationsReasons.CHANGE_PLAN_BY_PROVIDER, CancellationsReasons.PLAN_REPLACEMENT, ] const ALLOW_KIND_TOKEN = ['credit', 'debit'] export type N2BPaymentProviderData = { discount: number unitId: number source: string card: Omit } export type PaymentProviderData = { provider: PaymentProviders data?: Data } type SubscribeParams = { patient: Patient plan: Plan paymentProvider: PaymentProviderData options?: Partial data: SubscriptionData smartfitUnitId?: string installments?: Installment[] discountCouponCode?: string } @Injectable() export class SubscriptionService { protected debug: Debugger = debug('SubscriptionService') constructor( private readonly patientRepository: PatientRepository, private readonly planRepository: PlanRepository, private readonly subscriptionRepository: SubscriptionRepository, private readonly smartfitApi: SmartfitApi, private readonly teleconsultationsCreditService: TeleconsultationsCreditService, private readonly n2bPaymentsService: N2BPaymentsService, private readonly subscriptionTransactionRepository: SubscriptionTransactionRepository, private readonly subscriptionInstallmentRepository: SubscriptionInstallmentRepository, private readonly discountCouponRepository: DiscountCouponRepository, ) {} async subscribe(params: SubscribeParams): Promise { if (params.patient.subscriber) throw new IsAlreadySubscriberError(`id: ${params.patient.id}`) if (params.paymentProvider.provider === PaymentProviders.SMARTFIT) return this.addSubscriptionWithAddon(params) if (params.paymentProvider.provider === PaymentProviders.N2B) return this.subscribeN2BPaymentWithFallbackToSmarfit(params as SubscribeParams) return this.addSubscription(params) } async getActiveAddon(person: Person, canBeInCancellation = true): Promise { const addons = await this.getAllowAddons(person) return (canBeInCancellation ? addons : addons.filter(addon => !addon.isInCancellation)) .filter(addon => addon.isActive || addon.isInTrial) .sort((addonB, addonA) => addonA.createdAt < addonB.createdAt ? 1 : addonA.createdAt === addonB.createdAt ? 0 : -1, ) .sort((addonB, addonA) => { if (addonA.status === AddonStatus.ACTIVE) if ([AddonStatus.IN_CANCELLATION, AddonStatus.TRIAL, AddonStatus.CANCELLED].includes(addonB.status)) return -1 if (addonA.status === AddonStatus.TRIAL) if ([AddonStatus.IN_CANCELLATION, AddonStatus.CANCELLED].includes(addonB.status)) return -1 if (addonA.status === AddonStatus.IN_CANCELLATION) if ([AddonStatus.CANCELLED].includes(addonB.status)) return -1 return 0 }) .pop() } async checkAddon(patient: Patient, plan: Plan): Promise<{ person: Person; addon: Addon }> { const person = await this.getPerson(patient.login) const activeAddon = await this.getActiveAddon(person) if ( !isEmpty(activeAddon) && plan.getPaymentProvider(PaymentProviders.SMARTFIT).smartfit_id !== activeAddon.addonId && !activeAddon.isInCancellation ) throw new SubscribeError(`Usuário já possui outro addon ativo`) if (isEmpty(activeAddon) || activeAddon.isInCancellation) { try { await this.smartfitApi.addAddon( { token: person.authToken }, plan.getPaymentProvider(PaymentProviders.SMARTFIT).code, person.locationAcronym, ) return { person, addon: await this.getActiveAddon(person) } } catch (error) { throw new NewAddonError(error.message, person) } } return { person, addon: activeAddon } } async addSubscription( { patient, plan, paymentProvider, options, data, }: Pick, discountCoupon?: DiscountCoupon, ): Promise { const createdAt = new Date() const subscription = new Subscription({ createdAt, subscribedAt: options?.subscribedAt || createdAt, patientId: patient.id, planId: plan.id, ...(!isNil(plan.trial_value) ? { trialExpiresAt: plan.trialExpiresAtFromNow } : {}), ...options, data, paymentProvider: paymentProvider.provider, discountCouponId: discountCoupon?.id, }) const teleconsultationPlanFeature = plan.getFeature('teleconsultation') if (teleconsultationPlanFeature?.quantity > 0) subscription.teleconsultationRenewsAt = addMonths( subscription.subscribedAt, teleconsultationPlanFeature.intervalInMonths, ) const newId = await this.subscriptionRepository.create(subscription) subscription.id = newId await this.patientRepository.updateSubscription(patient, subscription.id) if (teleconsultationPlanFeature?.quantity > 0) await this.teleconsultationsCreditService.createTransaction({ patientId: patient.id, value: teleconsultationPlanFeature.quantity, reason: 'plan_subscription', author: patient, objectModel: subscription, }) return subscription } async addInstallments(installments: Installment[], subscription: Subscription): Promise { const installmentsToCreate = installments.map( installment => new SubscriptionInstallment({ subscription_id: subscription.id, amount: installment.amount, installment: installment.installment_number, paid_at: installment.paid_at, due_date: installment.due_date, }), ) await this.subscriptionInstallmentRepository.create(installmentsToCreate) } async updateInstallmentPayment(subscription: Subscription, installmentNumber: number): Promise { const installments = await this.subscriptionInstallmentRepository.findBySubscription(subscription.id) const installment = installments.find(installment => installment.installment === installmentNumber) await this.subscriptionInstallmentRepository.save({ id: installment.id, paid_at: new Date() }, ['paid_at']) } async addTransaction({ due_at, paid_at, card_number, installments, amount, data, card_type, payment_id, installment_number, payment_provider, error, ...params }: { amount: number due_at: Date patient: Patient paid_at?: Date subscription?: Subscription card_number: string installments: number card_type: CardType payment_id: number installment_number?: number payment_provider: PaymentProviders error?: TransactionError data?: SubscriptionData }): Promise { await this.subscriptionTransactionRepository.create( SubscriptionTransaction.toInstance({ patient_id: params.patient.id, installments, amount, card_number, due_at, paid_at, card_type, data, installment_number, payment_id, payment_provider, discount: 0, fee: 0, status: amount !== 0 && !!paid_at ? 'success' : 'failed', ...(isNil(params.subscription?.id) ? {} : { subscription_id: params.subscription?.id }), ...(isNil(error) ? {} : { error_code: error.code, error_message: error.message }), }), ) } async cancelSubscription({ patient, cancellationReason, cancellationBy, cancellationRequestedByPatientAt, expiresAt, amount, ignoreTrial = false, }: { patient: Patient cancellationReason: CancellationReason cancellationBy: CancellationUser cancellationRequestedByPatientAt?: Date expiresAt?: Date amount?: number ignoreTrial?: boolean }): Promise { const subscription = patient.subscription if (!subscription) throw new UnprocessableEntityError(`Patient isn't subscriber`) if (subscription.isCancelled) throw new UnprocessableEntityError(`Subscriptions is already cancelled`) subscription.expiresAt = expiresAt || subscription.expiresAt subscription.cancellationRequestAt = subscription.cancellationRequestAt || new Date() subscription.cancellationRequestedByPatientAt = cancellationRequestedByPatientAt || new Date() if (IMMEDIATE_CANCELLATION_REASONS.includes(cancellationReason.id) || ignoreTrial) subscription.cancellationAt = new Date() if (subscription.paymentProvider === PaymentProviders.N2B) { const subscriptionInstallments = await this.subscriptionInstallmentRepository.findBySubscription(subscription.id) if (subscriptionInstallments.length <= 0) await this.n2bPaymentsService.askRefund({ amount, paymentId: +subscription.originalTransaction, }) else { await this.n2bPaymentsService.askCancelSubscription({ amountToRefund: amount, subscriptionId: +subscription.originalTransaction, }) } } await this.subscriptionRepository.cancel(subscription, cancellationReason, cancellationBy.id) } async uncancel(subscription: Subscription): Promise { subscription.uncancel() await this.subscriptionRepository.uncancel(subscription) } async getAllowAddons(person: Person): Promise> { const plans = await this.planRepository.findEnabled() return (await this.smartfitApi.findAddons({ token: person.authToken })).filter(addon => { const plan = plans.find(plan => plan.getPaymentProvider(PaymentProviders.SMARTFIT)?.smartfit_id === addon.addonId) if (isNil(plan)) return false addon.addonCode = plan.getPaymentProvider(PaymentProviders.SMARTFIT).code return true }) } async getPerson(login: string): Promise { const person = await this.smartfitApi.getPersonByLogin(login) if (!person) throw new Error(`Não é permitido o usuário adicionar addon`) return person } async addSubscriptionWithAddon({ patient, plan, paymentProvider, options, data, }: Pick): Promise { try { const { person, addon } = await this.checkAddon(patient, plan) const subscription = await this.addSubscription({ patient, plan, paymentProvider, options: { ...options, subscribedAt: addon.createdAt }, data, }) await this.logSmartfitTransaction({ subscription, patient, person, plan, data, paid_at: new Date() }) return subscription } catch (error) { if (error instanceof NewAddonError) await this.logSmartfitTransaction({ patient, person: error.person, data, plan }) throw error } } async getToken(subscription: Subscription): Promise { if (!isEmpty(subscription?.token)) return subscription.token return this.subscriptionRepository.getToken(subscription.id) } async updateExpiresDate(subscription: Subscription, expiresAt: Date): Promise { await this.subscriptionRepository.save({ id: subscription.id, expires_at: expiresAt }, ['expires_at']) } async updateSuspendedDate(subscription: Subscription, suspendedAt: Date): Promise { await this.subscriptionRepository.save({ id: subscription.id, suspended_at: suspendedAt }, [ { field: 'suspended_at', type: 'timestamptz!' }, ]) } async updateCancellationDate(subscription: Subscription, cancellationAt: Date): Promise { await this.subscriptionRepository.save({ id: subscription.id, cancellation_at: cancellationAt }, [ 'cancellation_at', ]) } async updateCardNumber(subscription: Subscription, cardNumber: string): Promise { await this.subscriptionRepository.save({ id: subscription.id, card_number: cardNumber }, ['card_number']) } async getSubscriptionAmountToRefund(subscription: Subscription): Promise { const transactions = await this.subscriptionTransactionRepository.findBySubscription(subscription.id) const amount = transactions.filter(transaction => transaction.status === 'success')?.pop()?.amount return amount || 0 } private async subscribeN2BPaymentWithFallbackToSmarfit( params: SubscribeParams, ): Promise { try { return await this.addSubscriptionWithN2BPayments(params) } catch (error) { if (params.data.origin === 'smartfit') throw error this.debug('The transaction was not authorized with N2B Payments. Redirecting to Smartfit.') if (!isNil(params.discountCouponCode)) throw error const newParams = cloneDeep(params) newParams.paymentProvider.provider = PaymentProviders.SMARTFIT return this.addSubscriptionWithAddon(newParams) } } private async addSubscriptionWithN2BPayments({ patient, plan, paymentProvider, options, data, discountCouponCode, }: Pick< SubscribeParams, 'patient' | 'plan' | 'paymentProvider' | 'options' | 'data' | 'discountCouponCode' >): Promise { if (data.origin === 'smartfit') { await this.n2bPaymentsService.generateSubscription( await this.buildN2BPaymentsParams({ card: paymentProvider.data.card, patient, plan, data, }), ) return null } return this.processN2bPaymentsSubscriptionNow({ patient, plan, paymentProvider, options, data, discountCouponCode, }) } private async processN2bPaymentsSubscriptionNow({ patient, plan, paymentProvider, options, data, discountCouponCode, }: Pick< SubscribeParams, 'patient' | 'plan' | 'paymentProvider' | 'options' | 'data' | 'discountCouponCode' >): Promise { let discountCoupon: DiscountCoupon let subscription: Subscription if (!isNil(discountCouponCode)) { discountCoupon = await this.discountCouponRepository.getByCode(discountCouponCode) if (isNil(discountCoupon)) throw new DiscountCouponNotFoundError(discountCouponCode) if (!discountCoupon.isValid({ planId: plan.id })) throw new DiscountCouponInvalidError( discountCouponCode, discountCoupon.isInactive, !discountCoupon.allows(plan.id), ) } const requestPaymentParams = await this.buildN2BPaymentsParams({ patient, plan, data, card: paymentProvider.data?.card, }) if (discountCoupon) requestPaymentParams.amount = discountCoupon.apply(requestPaymentParams.amount) const result = await this.n2bPaymentsService.generateSubscriptionAndChargePayment(requestPaymentParams) if (result.success) { subscription = await this.addSubscription( { patient, plan, paymentProvider, data, options: { ...options, subscribedAt: new Date(), expiresAt: result.subscription.expires_at, originalTransaction: result.subscription.id.toString(), cardNumber: result.subscription.invoice?.installments[0]?.payments[0]?.transaction?.card?.last4, }, }, discountCoupon, ) await this.addInstallments(result.subscription.invoice.installments, subscription) } const person = await this.smartfitApi.getPersonByLogin(patient.login) const installment = result.subscription.invoice.installments?.find( installment => installment.installment_number === 1, ) const payment = installment.payments?.find(payment => payment.amount > 0) || installment.payments?.[installment.payments?.length - 1] await this.addTransaction({ amount: installment?.amount, due_at: installment?.due_date, paid_at: payment?.paid_at, card_number: person?.lastDigitsCardNumber || null, installments: result?.subscription?.card_installments, data: { ...result?.subscription?.data, ...data }, patient, subscription, payment_id: payment.id, card_type: requestPaymentParams.card.kind, installment_number: installment.installment_number, payment_provider: PaymentProviders.N2B, ...(result.success ? {} : { error: result.subscription.invoice.installments[0].payments[0].transaction.error }), }) if (!result.success) { throw new SubscriptionTransactionError( result.subscription.invoice.installments[0].payments[0].transaction.error.code, result.subscription.invoice.installments[0].payments[0].transaction.error.message, ) } return subscription } private async buildN2BPaymentsParams({ patient, plan, data, card, }: { patient: Patient plan: Plan data: SubscriptionData card: CardParams }): Promise { if (isNil(card?.token)) { const personToken = await this.smartfitApi.getByLogin(patient.login) const wallet = await this.smartfitApi.getWalletToken(personToken.token) card = pick(wallet, 'token', 'kind') } if (!ALLOW_KIND_TOKEN.includes(card.kind)) throw new TokenKindDisallowedError(card.kind) return { data, amount: plan.getPaymentProvider(PaymentProviders.N2B).amount, externalReferenceId: plan.id, period: IntervalsHelper.IntervalsToN2BBillingPeriods(plan.interval), issuedAt: new Date(), installmentsQuantity: IntervalsHelper.IntervalsToCardInstallments(plan.interval), customer: { name: patient.name, email: patient.email, document: patient.document_number, phone: patient.cell_phone, country_code: CountryHelper.extractOnlyCountryCode(CountryHelper.parseCode(patient.country_code)), currency: patient.country_code === 'pt_BR' ? Currency.BRL : Currency.MXN, external_id: patient.id, }, card: { ...card, installments: 1, }, } } private async logSmartfitTransaction({ subscription, patient, person, plan, paid_at, data, }: { subscription?: Subscription patient: Patient person: Person plan: Plan paid_at?: Date data?: SubscriptionData }): Promise { const planWithInstallments = SmartfitPlanHelper.makePlanWithSmartfitPaymentProvider(person, plan) const amount = plan.getPaymentProvider(PaymentProviders.SMARTFIT)?.amount await this.addTransaction({ amount, due_at: new Date(), paid_at, card_number: person?.lastDigitsCardNumber || null, installments: planWithInstallments.getPaymentProvider(PaymentProviders.SMARTFIT).installments, patient, subscription, payment_id: Number(plan.getPaymentProvider(PaymentProviders.SMARTFIT).smartfit_id), card_type: person.paymentMethod === PaymentMethods.credit ? 'credit' : 'debit', payment_provider: PaymentProviders.SMARTFIT, data, }) } }