agmission/Development/client/src/app/domain/services/subscription.service.ts

652 lines
22 KiB
TypeScript

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<boolean>();
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<Price[]> {
return this.http.get<Price[]>(`${BASE_URL}/prices`);
}
getPaymentMethodList(custId: string): Observable<PaymentMethod[]> {
return this.http.get<PaymentMethod[]>(`${BASE_URL}/paymentMethods/${custId}`);
}
getDefPaymentMethods(custId: string): Observable<PaymentMethod> {
return this.http.get<PaymentMethod>(`${BASE_URL}/paymentMethods/${custId}/getDefault`);
}
getConfig(): Observable<{ config: string }> {
return this.http.get<{ config: string }>(`${BASE_URL}/config`);
}
getBillingAddress(applicatorId: string): Observable<Address> {
return this.http.get<Address>(`${BASE_URL}/billAddress/${applicatorId}`);
}
updateBillAddress(applicatorId: string, addrPkg: Address): Observable<Address> {
return this.http.put<Address>(`${BASE_URL}/billAddress/${applicatorId}`, addrPkg);
}
retrieveUpcomingInvoices(invoicePkg: InvoicePackage) {
return this.http.post<Invoice[]>(`${BASE_URL}/retrieveNextInvoices`, invoicePkg);
}
updateSubscription(subPkg: SubscriptionPackage): Observable<StripeSubscription[]> {
return this.http.post<StripeSubscription[]>(`${BASE_URL}/update`, subPkg);
}
fetchSubscriptions(custId: string): Observable<StripeSubscription[]> {
return this.http.get<StripeSubscription[]>(`${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<Invoice[]> {
return this.http.post<Invoice[]>(`${BASE_URL}/resumeUnpaidSub`, {
unpaidSubs
});
}
payUnpaidSub(unpaidPkg: UnpaidPackage): Observable<Invoice[]> {
return this.http.post<Invoice[]>(`${BASE_URL}/payInvoice`, unpaidPkg);
}
updateSubsPaymentMethod(subPm: SubscriptionPaymentMethod): Observable<StripeSubscription[]> {
return this.http.post<StripeSubscription[]>(`${BASE_URL}/setSubsPaymentMethod`, subPm);
}
updateCustPaymentMethod(custId: string, pmId: string, setDefault?: boolean): Observable<PaymentMethod> {
return this.http.put<PaymentMethod>(`${BASE_URL}/paymentMethods/${custId}`, {
pmId,
setDefault
});
}
getCustCharges(chargePkg: CustChargePkg): Observable<any[]> {
return this.http.post<any[]>(`${BASE_URL}/custCharges`, chargePkg);
}
retrieveUsage(usgPkg: UsagePackage): Observable<Usage> {
return this.http.post<Usage>(`${BASE_URL}/custUsages`, usgPkg);
}
retrieveCurrUsage(custId: string, byPuid: string): Observable<Usage> {
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<BillPeriod[]> {
return this.http.post<BillPeriod[]>(`${BASE_URL}/subBillPeriods`, {
custId,
subTypes
});
}
editSub(subsSettings: { subId: string, cancelAtPeriodEnd: boolean }[]): Observable<StripeSubscription[]> {
return this.http.post<StripeSubscription[]>(`${BASE_URL}/setSubsSettings`, {
subsSettings
});
}
getCoupon(coupon: string): Observable<Coupon> {
return this.http.get<Coupon>(`${BASE_URL}/getCoupon/${coupon}`);
}
editPM(custId: string, pkg: PMPkgEdit): Observable<PaymentMethod> {
return this.http.put<PaymentMethod>(`${BASE_URL}/paymentMethods/${custId}`, pkg);
}
addPM(custId: string, pmId: string, setDefault?: boolean): Observable<PaymentMethod> {
return this.http.post<PaymentMethod>(`${BASE_URL}/paymentMethods/${custId}`, {
pmId,
setDefault
});
}
deletePM(custId: string, pmId: string): Observable<PaymentMethod> {
return this.http.request<PaymentMethod>('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<BillingInfoPackage> {
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<void> {
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();
}
}