import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of, Subject, Subscription } from 'rxjs'; import { Price, InvoicePackage, Address, Invoice, SubscriptionPackage, StripeSubscription, PaymentMethod, UnpaidPackage, SubscriptionPaymentMethod, Charge, PaidAmount, AGNavSubscriptionShort, CustChargePkg, Usage, BillPeriod, UsagePackage, CheckoutPayment, Coupon, PMPkgEdit, PriceUsd, Acre, AGNavSubscription, Plan, Status, BillingInfoPackage, Package, Addon, TrialItem } from '@app/domain/models/subscription.model'; import { loadStripe, Stripe, StripeCardElement } from '@stripe/stripe-js'; import { DateUtils, UnitUtils, Utils } from '@app/shared/utils'; import { Mode, SUB, SubKeys, SubStripe, SubTexts, SubType, subPlans } from '@app/profile/common'; import { map, switchMap } from 'rxjs/operators'; import { IMembership } from '@app/auth/models/user.model'; import { Store } from '@ngrx/store'; import { getSubIntentMode } from '@app/reducers'; import { UserService } from './user.service'; export interface CCFormValues { ccName: string, card: StripeCardElement } const BASE_URL = '/subscription'; const MAX_PERCENT = 100; const KEY = 'subIntent'; interface Option { subscriptions?: { id: string, status: string }[]; coupon?: Coupon; } @Injectable({ providedIn: 'root' }) export class SubscriptionService { private _stripe: Stripe; private _stripeLoadStatus$ = new Subject(); get stripe(): Stripe { return this._stripe; } set stripe(value: Stripe) { if (!this._stripe) this._stripe = value; } private sub$: Subscription; private subMode$ = this.store.select(getSubIntentMode); private _subMode: Mode; get subMode(): Mode { return this._subMode; } constructor( private readonly http: HttpClient, private readonly store: Store<{}>, private userSvc: UserService ) { this.sub$ = this.subMode$.subscribe((mode) => this._subMode = mode); } // Rest endpoints getPrices(): Observable { return this.http.get(`${BASE_URL}/prices`); } getPaymentMethodList(custId: string): Observable { return this.http.get(`${BASE_URL}/paymentMethods/${custId}`); } getDefPaymentMethods(custId: string): Observable { return this.http.get(`${BASE_URL}/paymentMethods/${custId}/getDefault`); } getConfig(): Observable<{ config: string }> { return this.http.get<{ config: string }>(`${BASE_URL}/config`); } getBillingAddress(applicatorId: string): Observable
{ return this.http.get
(`${BASE_URL}/billAddress/${applicatorId}`); } updateBillAddress(applicatorId: string, addrPkg: Address): Observable
{ return this.http.put
(`${BASE_URL}/billAddress/${applicatorId}`, addrPkg); } retrieveUpcomingInvoices(invoicePkg: InvoicePackage) { return this.http.post(`${BASE_URL}/retrieveNextInvoices`, invoicePkg); } updateSubscription(subPkg: SubscriptionPackage): Observable { return this.http.post(`${BASE_URL}/update`, subPkg); } fetchSubscriptions(custId: string): Observable { return this.http.get(`${BASE_URL}?custId=${custId}&billInfo=true`); } fetchPayments(pmtPkg: { custId: string, byTime: string }): Observable<{ invoices: Invoice[], charges: Charge[] }> { return this.http.post<{ invoices: Invoice[], charges: Charge[] }>(`${BASE_URL}/custInvoices`, { custId: pmtPkg.custId, byTime: pmtPkg.byTime }); } resumeUnpaidSub(unpaidSubs: string[]): Observable { return this.http.post(`${BASE_URL}/resumeUnpaidSub`, { unpaidSubs }); } payUnpaidSub(unpaidPkg: UnpaidPackage): Observable { return this.http.post(`${BASE_URL}/payInvoice`, unpaidPkg); } updateSubsPaymentMethod(subPm: SubscriptionPaymentMethod): Observable { return this.http.post(`${BASE_URL}/setSubsPaymentMethod`, subPm); } updateCustPaymentMethod(custId: string, pmId: string, setDefault?: boolean): Observable { return this.http.put(`${BASE_URL}/paymentMethods/${custId}`, { pmId, setDefault }); } getCustCharges(chargePkg: CustChargePkg): Observable { return this.http.post(`${BASE_URL}/custCharges`, chargePkg); } retrieveUsage(usgPkg: UsagePackage): Observable { return this.http.post(`${BASE_URL}/custUsages`, usgPkg); } retrieveCurrUsage(custId: string, byPuid: string): Observable { return this.retrieveBilPeriod(custId).pipe( switchMap((_billPeriods) => { const curPeriod = _billPeriods.sort((p1, p2) => p1.periodEnd - p2.periodEnd).reverse()[0]; return this.retrieveUsage({ byPuid: byPuid, fromTS: DateUtils.startUtcTS(curPeriod?.periodStart), toTS: DateUtils.endUtcTS(curPeriod?.periodEnd) }); }), map((usage) => usage) ) } retrieveBilPeriod(custId: string, subTypes?: string[]): Observable { return this.http.post(`${BASE_URL}/subBillPeriods`, { custId, subTypes }); } editSub(subsSettings: { subId: string, cancelAtPeriodEnd: boolean }[]): Observable { return this.http.post(`${BASE_URL}/setSubsSettings`, { subsSettings }); } getCoupon(coupon: string): Observable { return this.http.get(`${BASE_URL}/getCoupon/${coupon}`); } editPM(custId: string, pkg: PMPkgEdit): Observable { return this.http.put(`${BASE_URL}/paymentMethods/${custId}`, pkg); } addPM(custId: string, pmId: string, setDefault?: boolean): Observable { return this.http.post(`${BASE_URL}/paymentMethods/${custId}`, { pmId, setDefault }); } deletePM(custId: string, pmId: string): Observable { return this.http.request('delete', `${BASE_URL}/paymentMethods/${custId}`, { body: { pmId } }); } // Utils hasSubsWithStatus(subs: StripeSubscription[], status: string): boolean { return subs?.some((sub) => sub?.status === `${status}`); } isRequirePaymentMethod(subs: StripeSubscription[]): boolean { return subs?.some((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_PAYMENT_METHOD); } isRequireAction(subs: StripeSubscription[]): boolean { return subs?.some((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION); } getReqPmSubscription(subs: StripeSubscription[]): StripeSubscription { return subs?.find((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_PAYMENT_METHOD); } getReqActionSubscription(subs: StripeSubscription[]): StripeSubscription { SubStripe.REQUIRE_ACTION return subs?.find((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION); } atCheckoutReviewStage(): boolean { const subIntent = JSON.parse(sessionStorage.getItem(KEY)); return subIntent?.stage === SUB.CHKOUT_REV; } atStage(stage: string): boolean { const subIntent = JSON.parse(sessionStorage.getItem(KEY)); return subIntent?.stage === `${stage}`; } checkSubStatus(subs: AGNavSubscriptionShort[], status: string, op: string) { const binaryOp = (op: string) => new Function('x', 'y', `return x ${op} y;`); return subs?.some((sub) => binaryOp(op)(sub?.status, status)); } getPeriod(periodEnd: number): number { return periodEnd - DateUtils.startUtcTS(DateUtils.currUTC()); } dayUsedPerct(periodStart: number, periodEnd: number) { const period = periodEnd - periodStart; const dayUsed = DateUtils.currUTC() - periodStart; const curPeriod = this.getPeriod(periodEnd); if (period === 0) return 0; if (dayUsed <= 0) return 0; if (curPeriod <= 0) return 100; return Math.floor((dayUsed / period) * MAX_PERCENT); } curDayRemain(periodEnd: number) { const SECS_PER_DAY = 86400; const curPeriod = this.getPeriod(periodEnd); if (curPeriod <= 0) return 0; return Math.floor(curPeriod / SECS_PER_DAY); } acrUsedPerct(acrUsed: number, maxAcr: number) { if (!+maxAcr || !+acrUsed || maxAcr === 0) return 0; if (acrUsed >= maxAcr) return 100; return Math.floor((acrUsed / maxAcr) * MAX_PERCENT); } private calcTotalAmount(lines): number { if (Utils.isEmptyArray(lines)) return 0; return lines?.map((line) => line?.amount).reduce((t1, t2) => t1 + t2, 0); } private extractLineTax(lines): [] { if (Utils.isEmptyArray(lines)) return []; return lines?.map((line) => line?.tax_amounts).flat(); } private calcInvoice(invoices: Invoice[], coupon?: Coupon): CheckoutPayment { let lines = []; invoices?.map((inv) => lines = lines.concat(inv?.lines?.data)); const pmtTotalTax = this.calcTotalAmount(this.extractLineTax(lines)); const pmt = { payment: { lineItems: lines, totalAmount: this.calcTotalAmount(lines) + pmtTotalTax, totalTax: pmtTotalTax } }; if (coupon) { return this.applyCoupon(pmt, coupon); } return pmt; } private calcInvoiceWithProrate(invoices: Invoice[], coupon?: Coupon): CheckoutPayment { let lines = []; invoices.map((inv) => lines = lines.concat(inv?.lines?.data?.filter((line) => line?.period?.start === inv?.subscription_proration_date))); const pmtLines = lines.filter((line) => line.amount >= 0); const refLines = lines.filter((line) => line.amount < 0); let pmt: CheckoutPayment; const pmtTotalTax = this.calcTotalAmount(this.extractLineTax(pmtLines)); if (refLines.length > 0) { const refTotalTax = this.calcTotalAmount(this.extractLineTax(refLines)); pmt = { payment: { lineItems: pmtLines, totalAmount: this.calcTotalAmount(pmtLines) + pmtTotalTax, totalTax: pmtTotalTax }, refund: { lineItems: refLines, totalAmount: this.calcTotalAmount(refLines) + refTotalTax, totalTax: refTotalTax } }; } else { pmt = { payment: { lineItems: pmtLines, totalAmount: this.calcTotalAmount(pmtLines) + pmtTotalTax, totalTax: pmtTotalTax } }; } if (coupon) { return this.applyCoupon(pmt, coupon); } return pmt; } calcChkoutPayment(invoices: Invoice[], opt?: Option): CheckoutPayment { if (Utils.isEmptyArray(invoices)) return { payment: { totalAmount: 0, totalTax: 0, lineItems: [] } }; const prorateInvs = invoices.filter((inv) => inv?.lines?.data?.some((line) => line?.period?.start === inv?.subscription_proration_date)); const hasNoProrate = prorateInvs.length === 0; const hasUnResolvedInvoice = opt?.subscriptions?.some((sub) => sub.status === SubStripe.UNPAID || sub.status === SubStripe.INCOMPLETE || sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE ) || hasNoProrate; if (hasUnResolvedInvoice) { return this.calcInvoice(invoices, opt?.coupon); } return this.calcInvoiceWithProrate(invoices, opt?.coupon); } calcAmount(invoices: Invoice[], opt?: Option): PaidAmount { if (Utils.isEmptyArray(invoices)) return { totalExcludingTax: 0, totalTax: 0, total: 0 }; const pmt = this.calcChkoutPayment(invoices, opt); return { totalExcludingTax: pmt.payment.totalAmount - pmt.payment.totalTax, totalTax: pmt.payment.totalTax, total: pmt.payment.totalAmount, discount: pmt.payment.discount }; } hasInValTaxLoc(subs: StripeSubscription[]): boolean { if (Utils.isEmptyArray(subs)) return false; return subs?.some((sub) => sub?.latest_invoice?.automatic_tax?.enabled && sub?.latest_invoice?.automatic_tax?.status === SubStripe.REQ_LOC_INPUT); } private applyCoupon(pmt: CheckoutPayment, coupon: Coupon): CheckoutPayment { let clonedPmt: CheckoutPayment = { ...pmt }; if (coupon) { if (coupon.percent_off) { const amountOff = (clonedPmt.payment.totalAmount - clonedPmt.payment.totalTax) * (coupon.percent_off / 100); clonedPmt.payment = { ...clonedPmt.payment, totalAmount: clonedPmt.payment.totalAmount - amountOff, totalTax: clonedPmt.payment.totalTax, discount: { amountOff, percentOff: coupon.percent_off } }; return clonedPmt; } let totalAmount = clonedPmt.payment.totalAmount - coupon.amount_off; clonedPmt.payment = { ...clonedPmt.payment, totalAmount: totalAmount >= 0 ? totalAmount : 0, totalTax: clonedPmt.payment.totalTax, discount: { amountOff: coupon.amount_off } }; return clonedPmt; } return clonedPmt; } getInvCoupon(invoices: Invoice[]): Coupon { if (Utils.isEmptyArray(invoices)) return; return invoices?.[0]?.discount?.coupon; } crtCardDesc(brand: string, last4: string): string { if (!brand && !last4) return ''; return `${brand.charAt(0).toUpperCase()}${brand.slice(1)} ${SubTexts.ending} **** ${last4}`; } crtExp(expMonth, expYear): string { if (!expMonth && !expYear) return ''; return expMonth.toString().length === 1 ? `0${expMonth}/${expYear}` : `${expMonth}/${expYear}`; } formatCurrency(currency: PriceUsd): string { const DEFAULT_CURRENCY = '$0'; if (currency) { const priceToUS = (price: PriceUsd): string => { if (price) { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(+price / 100); } return DEFAULT_CURRENCY; } const formatNegative = (currency: number): string => { const usPrice = priceToUS(Math.abs(currency)); return `$(${usPrice.substring(1, usPrice.length)})`; } return +currency >= 0 ? priceToUS(currency) : formatNegative(+currency); } return DEFAULT_CURRENCY; } convMaxAcre(maxAcres: number | string): string { if (!maxAcres) return ''; const THOUSAND = 1000; const maxAcrToK = +maxAcres / THOUSAND; return maxAcrToK > 0 ? `${maxAcrToK}K` : maxAcres.toString(); } hasOpenSub(subs: AGNavSubscription[] | StripeSubscription[]): boolean { if (Utils.isEmptyArray(subs)) return false; return subs?.some((sub) => sub.status === SubStripe.INCOMPLETE || sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE || sub.status === SubStripe.UNPAID ); } updateMembShip(subscriptions: StripeSubscription[], membership: IMembership): IMembership { if (Utils.isEmptyArray(subscriptions)) return membership; return { ...membership, endOfPeriod: subscriptions?.find((sub) => sub.metadata.type === SubType.PACKAGE)?.latest_invoice.period_end || subscriptions?.find((sub) => sub.metadata.type === SubType.ADDON)?.latest_invoice.period_end, subscriptions: subscriptions?.map((sub) => ({ id: sub.id, periodEnd: sub.current_period_end, periodStart: sub.current_period_start, status: sub.status, items: sub.items.data?.map((item) => ({ price: item.price.lookup_key, quantity: item.quantity })), type: sub.metadata.type, cancelAtPeriodEnd: sub.cancel_at_period_end })) }; } createSubPlan(subscriptions: StripeSubscription[], membership: IMembership, usage: Usage): Plan { if (Utils.isEmptyArray(membership?.subscriptions) || Utils.isEmptyArray(subscriptions) || this.hasOpenSub(subscriptions)) { return { subscriptions, membership, package: {}, addon: {} }; } const getSubscriptionItem = (type: SubType) => membership.subscriptions.find(sub => sub.type === type && (sub.status === SubStripe.ACTIVE || sub.status === SubStripe.TRIALING))?.items[0]; const createAcrePlan = (currUsage: number, limit: number): Acre => ({ currUsage, limit, overLimit: currUsage > (limit ? limit : Infinity) }); const pkg = getSubscriptionItem(SubType.PACKAGE); const addon = getSubscriptionItem(SubType.ADDON); const pkgPrice = pkg?.price; const maxAcres = isNaN(+pkg?.metadata?.maxAcres) ? 0 : +pkg?.metadata?.maxAcres; const acre = createAcrePlan(UnitUtils.haToArea(usage.ttArea, true), maxAcres || subPlans[pkgPrice]?.maxAcres); const maxVehicles = isNaN(+pkg?.metadata?.maxVehicles) ? 0 : +pkg?.metadata?.maxVehicles; const pkgNumVeh = maxVehicles || subPlans[pkgPrice]?.maxVehicles || 0; const trackNumVeh = addon?.quantity || 0; const packagePlan = pkg ? { [pkgPrice]: { acre, airCraft: { numOfVehicle: pkgNumVeh } } } : {}; const addonPlan = addon ? { [SubKeys.TRACKING]: { airCraft: { numOfVehicle: trackNumVeh }, acre } } : {}; return { subscriptions, membership, package: packagePlan, addon: addonPlan }; } isStatusMatchingCode(status: Status, code: string) { return status?.code === code; } isUnderReview(status: Status) { return this.isStatusMatchingCode(status, SUB.AC_REVIEW); } fmtSubMsg(text: string, key: PriceUsd, vehicle: { trkQuantity?: number, pkgQuantity?: number }): string { return text?.replace('#pkg#', subPlans[key].name) .replace('#quantity#', `${vehicle.trkQuantity}` || '') .replace('#maxAC#', `${vehicle.pkgQuantity}` || '') || ''; } toVehRange(precedingMax: number, maxVehicles: number): string { const MIN = 1; const MAX = 10; const lowerRange = precedingMax + MIN; return maxVehicles > MIN && maxVehicles <= MAX ? lowerRange ? `${lowerRange}-${maxVehicles}` : `${maxVehicles}` : `${maxVehicles}`; } convertAddr(address) { address.postal_code = address?.postalCode; delete address?.postalCode; delete address?.name; delete address?.valid; return address; } createBillingInfoPackage(applicatorId): Observable { let billingInfoPackage: BillingInfoPackage; return this.getBillingAddress(applicatorId).pipe( switchMap((address: Address) => { const hasExistingAdr = address && Object.keys(address)?.some((key) => key === 'name' || key === 'postalCode' || key === 'line1' ); if (hasExistingAdr) { return of(billingInfoPackage = { billingInfo: { applicatorId, name: address.name, address: this.convertAddr(address) } }); } else { return this.userSvc.getUser(applicatorId).pipe( map((user) => { return billingInfoPackage = { isNewAccount: true, billingInfo: { applicatorId, name: user.name, address: { line1: user.address, country: user.country, city: '', state: '', postal_code: '' } } } }) ); } }), ); } createTrialItems(selPkg: Package, selAddons: Addon[]): TrialItem[] { const trialItems = selAddons?.map((addon) => ({ description: addon.desc, amount: +addon.price * addon.quantity, quantity: addon.quantity, price: { lookup_key: addon.lookupKey, unit_amount: +addon.price } })) || []; if (selPkg?.lookupKey) { trialItems.unshift({ description: selPkg.desc, amount: +selPkg.price, quantity: 1, price: { lookup_key: selPkg.lookupKey, unit_amount: +selPkg.price } }); } return trialItems; } getDateOptions(): { label: string, value: string }[] { const options = [ { label: $localize`:@@1m:past 1 month`, value: '1m' }, { label: $localize`:@@3m:past 3 months`, value: '3m' }, { label: $localize`:@@6m:past 6 months`, value: '6m' }, ]; const currYear = new Date().getUTCFullYear(); for (let i = currYear; i > currYear - 3; i--) { options.push({ label: i.toString(), value: i.toString() }); } return options; } get stripeLoadStatus$() { return this._stripeLoadStatus$; } async loadStripeApi(pk: string) { try { this.stripe = await loadStripe(pk); } catch (err) { throw err; } } loadStripePromise(): Promise { if (this.stripe) { return Promise.resolve(); // Stripe is already loaded, no need to load again } return this.getConfig().pipe( switchMap((res) => this.loadStripeApi(res.config)) ).toPromise().then(() => { this.stripeLoadStatus$.next(true); }).catch((err) => { console.error('Failed to load Stripe API:', err); this.stripeLoadStatus$.error(false); }); } /** * Updates the billing address sequence for a user and returns both the updated address and user. * @param userId The user's id * @param address The address to update * @returns Observable<{ address: Address, user: User }> */ public updateBillingAddressSequence(userId: string, address: Address) { const { isBilling, ...addressWithoutBilling } = address; // Remove isBilling property if it exists return this.updateBillAddress(userId, addressWithoutBilling).pipe( switchMap((updatedAddress: Address) => this.userSvc.getUser(userId, { withAddresses: true }).pipe( switchMap((user) => { return of({ address: updatedAddress, user }) }) ) ) ); } ngOnDestroy(): void { if (this.sub$) this.sub$.unsubscribe(); } }