-
- {{col.header}}
- {{ resolveFieldData(rowData, col.field) | userType }}
-
- {{ resolveFieldData(rowData, col.field) }}
+
+
+ {{ acc.name }}
+ {{ acc.username }}
+ {{ acc.kind | userType }}
+
+
-
+ {{ acc.phone }}
+ {{ acc.email }}
diff --git a/Development/client/src/app/accounts/account-list/account-list.component.ts b/Development/client/src/app/accounts/account-list/account-list.component.ts
index fb263a2..fa7a9a2 100644
--- a/Development/client/src/app/accounts/account-list/account-list.component.ts
+++ b/Development/client/src/app/accounts/account-list/account-list.component.ts
@@ -7,9 +7,8 @@ import { User } from '../models/user.model';
import * as fromUsers from '../reducers';
import * as userActions from '../actions/account.actions';
-import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global';
+import { RoleIds, globals } from '@app/shared/global';
import { BaseComp } from '@app/shared/base/base.component';
-import { Utils } from '@app/shared/utils';
@Component({
@@ -18,11 +17,8 @@ import { Utils } from '@app/shared/utils';
styleUrls: ['./account-list.component.css']
})
export class AccountListComponent extends BaseComp implements OnInit, OnDestroy {
- readonly resolveFieldData = Utils.resolveFieldData;
- readonly KIND = 'kind';
- readonly ACTIVE = OperationalStatus.ACTIVE;
+
accounts: Array;
- isLoading: boolean;
currAcc: User;
cols: any[];
userFilter: string;
@@ -39,8 +35,8 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
this.cols = [
{ field: 'name', header: globals.name, filtered: true, filterMatchMode: 'contains' },
{ field: 'username', header: globals.userName, filtered: true, filterMatchMode: 'contains' },
- { field: this.KIND, header: $localize`:@@type:Type`, width: '10%' },
- { field: this.ACTIVE, header: globals.active, width: '6%' },
+ { field: 'kind', header: $localize`:@@type:Type`, width: '10%' },
+ { field: 'active', header: globals.active, width: '6%' },
{ field: 'phone', header: globals.phone + ' ' + $localize`:@@Num:N°`, width: '10%', filtered: true, filterMatchMode: 'contains' },
{ field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains' }
];
@@ -52,10 +48,11 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
ngOnInit() {
this.sub$ = this.store.select(fromUsers.getAllUsers).subscribe(users => this.accounts = users);
- this.sub$.add(this.store.select(fromUsers.getIsLoading).subscribe(loading => this.isLoading = loading));
+
this.sub$.add(this.store.select(fromUsers.getSelectedUser).subscribe(
(acc) => this.currAcc = acc
));
+ // Always fetch the fresh list of accounts
this.store.dispatch(new userActions.Fetch());
}
@@ -71,13 +68,6 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
return (this.currAcc && this.currAcc._id !== '0');
}
- get canDelete() {
- // WI-2: Soft lock - Allow deletion of all account types including vendor accounts
- // Previously: blocked PARTNER_SYSTEM_USER accounts
- // Now: allowed with warning confirmation dialog (see deleteAccount)
- return this.canEdit;
- }
-
newAccount() {
this.router.navigate(['account', '0'], { relativeTo: this.route });
}
@@ -88,19 +78,8 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
deleteAccount() {
if (!this.currAcc) { return; }
-
- // WI-2: Soft lock - Show special warning for vendor accounts
- const isVendorAccount = this.currAcc?.kind === RoleIds.PARTNER_SYSTEM_USER;
- const message = isVendorAccount
- ? Labels.VENDOR_DELETE_CONFIRM_MESSAGE
- : globals.confirmDeleteThing.replace('#thing#', globals.account);
- const header = isVendorAccount ? Labels.VENDOR_DELETE_CONFIRM_TITLE : undefined;
-
this.confirmSvc.confirm({
- header: header,
- message: message,
- acceptLabel: globals.yes,
- rejectLabel: globals.no,
+ message: globals.confirmDeleteThing.replace('#thing#', globals.account),
accept: () => {
this.store.dispatch(new userActions.Delete(this.currAcc));
this.currAcc = null;
diff --git a/Development/client/src/app/accounts/account.module.ts b/Development/client/src/app/accounts/account.module.ts
index 02bdaa5..9bb163e 100644
--- a/Development/client/src/app/accounts/account.module.ts
+++ b/Development/client/src/app/accounts/account.module.ts
@@ -7,7 +7,6 @@ import { CheckboxModule } from 'primeng/checkbox';
import { AutoCompleteModule } from 'primeng/autocomplete';
import { ToolbarModule } from 'primeng/toolbar';
import { InputSwitchModule } from 'primeng/inputswitch';
-import { TooltipModule } from 'primeng/tooltip';
import { TableModule } from 'primeng/table';
import { CalendarModule } from 'primeng/calendar';
@@ -24,7 +23,7 @@ import { AccountEditComponent } from './account-edit/account-edit.component';
import { AccountsGuard } from './account.guard';
import { AccountEffects } from './effects/account.effects';
-import { FEATURE_KEY, reducer } from './reducers/users.reducer';
+import { FEATURE_KEY, reducer } from './reducers/users-reducer';
@NgModule({
imports: [
@@ -38,7 +37,6 @@ import { FEATURE_KEY, reducer } from './reducers/users.reducer';
ToolbarModule,
SplitButtonModule,
TableModule,
- TooltipModule,
StoreModule.forFeature(FEATURE_KEY, reducer),
EffectsModule.forFeature([AccountEffects]),
diff --git a/Development/client/src/app/accounts/actions/account.actions.ts b/Development/client/src/app/accounts/actions/account.actions.ts
index dc5d03d..0dc8d95 100644
--- a/Development/client/src/app/accounts/actions/account.actions.ts
+++ b/Development/client/src/app/accounts/actions/account.actions.ts
@@ -22,12 +22,7 @@ export const CREATE = '[USERS] Create a user';
export class Create implements Action {
type: typeof CREATE = CREATE;
- constructor(readonly payload: User & {
- partnerConfig?: {
- vendorSystemType: string;
- vendorConfiguration: any;
- };
- }) { }
+ constructor(readonly payload: User) { }
}
export const CREATE_SUCCESS = '[USERS] Create user success';
export class CreateSuccess implements Action {
@@ -44,12 +39,7 @@ export const UPDATE = '[USERS] Update user';
export class Update implements Action {
type: typeof UPDATE = UPDATE;
- constructor(readonly payload: User & {
- partnerConfig?: {
- vendorSystemType: string;
- vendorConfiguration: any;
- };
- }) { }
+ constructor(readonly payload: User) { }
}
export const UPDATE_SUCCESS = '[USERS] Update user success';
export class UpdateSuccess implements Action {
@@ -59,7 +49,7 @@ export class UpdateSuccess implements Action {
}
export const UPDATE_FAILED = '[USERS] Update user failed';
export class UpdateFailed implements Action {
- type: typeof UPDATE_FAILED = UPDATE_FAILED;
+ type: typeof UPDATE_FAILED = UPDATE_FAILED;
}
export const DELETE = '[USERS] Delete user';
diff --git a/Development/client/src/app/accounts/effects/account.effects.ts b/Development/client/src/app/accounts/effects/account.effects.ts
index 8b0d904..8de577f 100644
--- a/Development/client/src/app/accounts/effects/account.effects.ts
+++ b/Development/client/src/app/accounts/effects/account.effects.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs';
-import { map, switchMap, catchError, repeat } from 'rxjs/operators';
+import { map, switchMap, catchError } from 'rxjs/operators';
import { Action } from '@ngrx/store';
@@ -9,18 +9,15 @@ import * as userActions from '../actions/account.actions';
import { UserService } from '@app/domain/services/user.service';
import { AuthService } from '@app/domain/services/auth.service';
import { AppMessageService } from '@app/shared/app-message.service';
-import { PartnerService } from '@app/partners/services/partner.service';
-import { PartnerSystemUser } from '@app/accounts/models/user.model';
-import { RoleIds, globals, KnownPartnerCodes } from '@app/shared/global';
+import { globals } from '@app/shared/global';
@Injectable()
export class AccountEffects {
- constructor(
+ constructor(
private readonly actions$: Actions,
private readonly userSvc: UserService,
private readonly authSvc: AuthService,
- private readonly msgSvc: AppMessageService,
- private readonly partnerSvc: PartnerService
+ private readonly msgSvc: AppMessageService
) {
}
@@ -28,250 +25,55 @@ export class AccountEffects {
loadUsers$: Observable = this.actions$.pipe(
ofType(userActions.FETCH),
switchMap(() =>
- // All account types (including PARTNER_SYSTEM_USER) are returned by the backend
- // /api/users/search endpoint — no separate /api/partners/systemUsers call needed.
this.userSvc.loadUsers({ byPuid: this.authSvc.user.parent }).pipe(
- map(users => new userActions.FetchSuccess(users))
+ map(users => new userActions.FetchSuccess(users)),
+ catchError(err => {
+ this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.accounts));
+ return of(new userActions.FetchError());
+ })
)
- ),
- catchError(err => this.handleUserOperationError(err, 'load')),
- repeat()
+ )
);
@Effect()
createUser$: Observable = this.actions$.pipe(
ofType(userActions.CREATE),
- switchMap(({ payload }) => {
- // Extract user data and partner config from payload
- const { partnerConfig, ...userData } = payload;
-
- // For partner system users, create them directly through PartnerService
- if (partnerConfig && partnerConfig.vendorSystemType) {
- return this.createPartnerSystemUser(userData, partnerConfig);
- }
-
- // For regular users, use UserService directly
- return this.userSvc.saveUser(userData).pipe(
- map((savedUser) => new userActions.CreateSuccess(savedUser))
- );
- }),
- catchError(err => this.handleUserOperationError(err, 'create')),
- repeat()
+ switchMap(({ payload }) =>
+ this.userSvc.saveUser(payload).pipe(
+ map((user) => new userActions.CreateSuccess(user)),
+ catchError(err => {
+ this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.account));
+ return of(new userActions.CreateFailed())
+ })
+ )
+ )
);
@Effect()
updateUser$: Observable = this.actions$.pipe(
ofType(userActions.UPDATE),
- switchMap(({ payload }) => {
- // Extract user data and partner config from payload
- const { partnerConfig, ...userData } = payload;
-
- // Case 1: User WITHOUT partner - use UserService directly + cleanup
- if (!partnerConfig || !partnerConfig.vendorSystemType) {
- return this.userSvc.saveUser(userData).pipe(
- switchMap((savedUser) => {
- // Clean up any existing partner system users for non-partner accounts
- return this.cleanupPartnerSystemUsers(userData._id).pipe(
- map(() => new userActions.UpdateSuccess(savedUser)),
- catchError(err => {
- console.error('Partner cleanup failed:', err);
- // User update succeeded, cleanup failed is not critical
- return of(new userActions.UpdateSuccess(savedUser));
- })
- );
- })
- );
- }
-
- // Case 2: User WITH partner - use PartnerService workflow completely
- return this.updatePartnerUserWorkflow(userData, partnerConfig).pipe(
- map((savedUser) => new userActions.UpdateSuccess(savedUser))
- );
- }),
- catchError(err => this.handleUserOperationError(err, 'save')),
- repeat()
+ switchMap(({ payload }) =>
+ this.userSvc.saveUser(payload).pipe(
+ map(() => new userActions.UpdateSuccess(payload)),
+ catchError(err => {
+ this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.account));
+ return of(new userActions.UpdateFailed());
+ })
+ )
+ )
);
@Effect()
deleteUser$: Observable = this.actions$.pipe(
ofType(userActions.DELETE),
- switchMap(({ payload }) => {
- // Check if the user is a PARTNER_SYSTEM_USER
- if (payload.kind === RoleIds.PARTNER_SYSTEM_USER) {
- // Backend only disables partner system users (sets active=false), it does NOT remove them.
- // Dispatch UpdateSuccess so the store reflects the disabled state in-place rather than
- // removing the row — which would cause it to reappear on the next reload.
- return this.partnerSvc.deleteSystemUser(payload._id).pipe(
- map(() => new userActions.UpdateSuccess({ ...payload, active: false }))
- );
- } else {
- // Use UserService for regular users
- return this.userSvc.deleteUser(payload).pipe(
- map(() => new userActions.DeleteSuccess(payload))
- );
- }
- }),
- catchError(err => this.handleUserOperationError(err, 'delete')),
- repeat()
+ switchMap(({ payload }) =>
+ this.userSvc.deleteUser(payload).pipe(
+ map(() => new userActions.DeleteSuccess(payload)),
+ catchError(err => {
+ this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.account));
+ return of(new userActions.UpdateFailed())
+ })
+ )
+ )
);
-
- // Partner user workflow methods - use PartnerService exclusively
- private createPartnerSystemUser(userData: any, partnerConfig: any): Observable {
- // Get partner ID based on vendor type
- return this.getPartnerByVendorType(partnerConfig.vendorSystemType).pipe(
- switchMap(partnerId => {
- if (!partnerId) {
- throw new Error(`Failed to get partner for vendor type: ${partnerConfig.vendorSystemType}`);
- }
-
- // Create vendor-specific system user data
- const createData = this.buildPartnerSystemUserData(userData, partnerConfig, partnerId);
-
- return this.partnerSvc.createSystemUser(createData).pipe(
- map((systemUser) => {
- // ✅ FIX: Return the created system user with customerId/partnerId for post-save validation
- // Merge the saved systemUser data with original userData to preserve all fields
- return new userActions.CreateSuccess({
- ...userData,
- ...systemUser,
- // Ensure we have the IDs for post-save validation
- customer: systemUser.customer || createData.customerId,
- partner: systemUser.partner || createData.partnerId
- });
- })
- );
- })
- );
- }
-
- private updatePartnerUserWorkflow(userData: any, partnerConfig: any): Observable {
- // Use getSystemUserById to directly fetch the partner system user
- return this.partnerSvc.getSystemUserById(userData._id).pipe(
- switchMap(existingSystemUser => {
- if (existingSystemUser) {
- // Update existing partner system user with backend-compatible structure
- const updateData = this.buildPartnerSystemUserData(userData, partnerConfig, existingSystemUser.partner._id);
-
- return this.partnerSvc.updateSystemUser(existingSystemUser._id!, updateData).pipe(
- map(() => userData) // Return the user data
- );
- } else {
- // Partner system user doesn't exist, return error
- throw new Error('Partner system user not found for update');
- }
- })
- );
- }
-
- /**
- * Build partner system user data structure based on vendor type
- * This method can be extended to support additional vendors
- */
- private buildPartnerSystemUserData(userData: any, partnerConfig: any, partnerId: string): any {
- return {
- partnerId: partnerId,
- customerId: userData.parent, // AgMission customer (main applicator account)
- username: userData.username,
- password: userData.password,
- name: userData.name,
- active: userData.active,
- email: userData.email,
- address: userData.address,
- phone: userData.phone,
- companyId: partnerConfig.vendorConfiguration.companyId || null,
- apiKey: partnerConfig.vendorConfiguration.apiKey || null,
- apiSecret: partnerConfig.vendorConfiguration.apiSecret || null
- // NOTE: metadata intentionally omitted — partner identity is carried by
- // partnerId (ObjectId). metadata.vendor was a fragile frontend-derived
- // copy that could silently diverge from the partner document.
- };
- }
-
- /**
- * Get partner ID by vendor type
- * This method can be extended to support additional vendors
- */
- private getPartnerByVendorType(vendorType: string): Observable {
- return this.partnerSvc.getPartners().pipe(
- map((partners: any[]) => {
- let partner = null;
-
- switch (vendorType) {
- case KnownPartnerCodes.SATLOC:
- partner = partners.find(p =>
- p.partnerCode === KnownPartnerCodes.SATLOC.toUpperCase() ||
- p.name?.toLowerCase().includes(KnownPartnerCodes.SATLOC)
- );
- break;
-
- // Add additional vendors here as needed
- // case 'other_vendor':
- // partner = partners.find(p =>
- // p.partnerCode === 'OTHER_VENDOR' ||
- // p.name?.toLowerCase().includes('other_vendor')
- // );
- // break;
-
- default:
- // Fallback: try to find partner by name or code matching vendor type
- partner = partners.find(p =>
- p.partnerCode?.toLowerCase() === vendorType.toLowerCase() ||
- p.name?.toLowerCase().includes(vendorType.toLowerCase())
- );
- break;
- }
-
- return partner ? partner._id : null;
- }),
- catchError(() => of(null))
- );
- }
-
- private cleanupPartnerSystemUsers(userId: string): Observable {
- return this.partnerSvc.getSystemUsersForCustomer(userId).pipe(
- switchMap((systemUsers: PartnerSystemUser[]) => {
- if (systemUsers.length === 0) {
- return of(null);
- }
-
- // Delete all system users for this customer
- const deleteOperations = systemUsers.map(systemUser =>
- this.partnerSvc.deleteSystemUser(systemUser._id!).pipe(
- catchError(error => {
- console.error('Failed to delete partner system user:', error);
- return of(null);
- })
- )
- );
-
- // Wait for all delete operations to complete
- return of(...deleteOperations);
- }),
- catchError(error => {
- console.error('Failed to load partner system users for cleanup:', error);
- return of(null);
- })
- );
- }
-
- // Centralized error handler for user operations following subscription.effects pattern
- private handleUserOperationError(err: any, operation: 'create' | 'save' | 'delete' | 'load'): Observable {
- const actionVerb = operation === 'create' ? globals.create :
- operation === 'save' ? globals.save :
- operation === 'delete' ? globals.delete : globals.load;
-
- // For load operation, use 'accounts' (plural), for others use 'account' (singular)
- const thingName = operation === 'load' ? globals.accounts : globals.account;
- this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', actionVerb).replace('#thing#', thingName));
-
- if (operation === 'create') {
- return of(new userActions.CreateFailed());
- } else if (operation === 'save') {
- return of(new userActions.UpdateFailed());
- } else if (operation === 'delete') {
- return of(new userActions.UpdateFailed()); // Note: There's no DeleteFailed action, using UpdateFailed
- } else {
- return of(new userActions.FetchError());
- }
- }
}
diff --git a/Development/client/src/app/accounts/models/user.model.ts b/Development/client/src/app/accounts/models/user.model.ts
index 5eec52d..6cfc0e9 100644
--- a/Development/client/src/app/accounts/models/user.model.ts
+++ b/Development/client/src/app/accounts/models/user.model.ts
@@ -1,5 +1,4 @@
-import { Address } from '@app/domain/models/subscription.model';
-import { RoleIds, OperationalStatusType } from '@app/shared/global';
+import { RoleIds } from '@app/shared/global';
interface RoleArray {
[index: number]: string;
@@ -10,106 +9,24 @@ export interface User {
username?: string;
password?: string;
name?: string;
- address?: string | null;
+ address?: string;
country?: string;
- phone?: string | null;
- email?: string | null;
+ Country?: any;
+ phone?: string;
+ email?: string;
kind: string;
roles?: RoleArray;
active?: boolean;
createdAt?: Date;
- updatedAt?: Date;
parent?: any;
- contact?: string;
- addresses?: Address[];
- billAddress?;
- needReview?: boolean;
-
- // Optional partner system fields (present for partner system users)
- customer?: string | { _id: string; username: string; name: string; kind: string; };
- partner?: string | { _id: string; name: string; kind: string; };
-}
-
-// PartnerSystemUser extends User with partner-specific fields
-export interface PartnerSystemUser extends User {
- // Partner relationships (populated objects from backend via .populate())
- // NOTE: backend uses .lean() so the 'customer' Mongoose virtual is NOT present.
- // 'parent' is populated as { _id, username, name, kind } in API responses.
- partner: {
- _id: string;
- name: string;
- partnerCode?: string;
- kind: string;
- };
- // 'customer' virtual from Mongoose is NOT returned by .lean(). Use 'parent' instead.
- customer?: {
- _id: string;
- username: string;
- name: string;
- kind: string;
- };
-
- // Partner system credentials
- partnerUserId?: string; // User ID in partner system
- partnerUsername?: string; // Username in partner system
- companyId?: string | null; // Company ID in partner system
-
- // Access credentials (encrypted in production)
- apiKey?: string | null;
- apiSecret?: string | null;
-
- // Status and metadata
- lastLoginAt?: Date;
- lastSyncAt?: Date;
- syncStatus?: OperationalStatusType;
-
- // Partner-specific metadata (contains vendor config)
- metadata?: {
- vendor?: string;
- satlocUrl?: string;
- satlocUsername?: string;
- satlocPassword?: string;
- [key: string]: any;
- };
-
- // Additional fields from backend response
- address?: string | null;
- email?: string | null;
- phone?: string | null;
-}
-
-export interface SatlocConnectionResult {
- success: boolean;
- message?: string;
- error?: string;
- connectionTime?: number;
- serverInfo?: {
- version?: string;
- capabilities?: string[];
- };
- account_info?: SatlocAccountInfo;
-}
-
-export interface SatlocAccountInfo {
- company_name: string;
- aircraft_count: number;
- api_version: string;
-}
-
-export interface SatlocIntegration {
- enabled: boolean;
- status: OperationalStatusType;
- account_info: SatlocAccountInfo | null;
- credentials_stored: boolean;
- last_error: string | null;
}
export const createNewUser = (parentId?: string, kind: String = RoleIds.APP_ADM) => {
const user = {
_id: '0',
kind: kind,
- active: kind == RoleIds.DEVICE ? false : true,
+ active: true,
parent: parentId
};
return user;
-}
+}
\ No newline at end of file
diff --git a/Development/client/src/app/accounts/reducers/index.ts b/Development/client/src/app/accounts/reducers/index.ts
index e2726ca..853ca9d 100644
--- a/Development/client/src/app/accounts/reducers/index.ts
+++ b/Development/client/src/app/accounts/reducers/index.ts
@@ -3,7 +3,7 @@ import {
createFeatureSelector,
} from '@ngrx/store';
-import * as fromUsers from './users.reducer';
+import * as fromUsers from './users-reducer';
/**
* The createFeatureSelector function selects a piece of state from the root of the state object.
diff --git a/Development/client/src/app/accounts/reducers/users.reducer.ts b/Development/client/src/app/accounts/reducers/users-reducer.ts
similarity index 91%
rename from Development/client/src/app/accounts/reducers/users.reducer.ts
rename to Development/client/src/app/accounts/reducers/users-reducer.ts
index 6370b80..cd8d26f 100644
--- a/Development/client/src/app/accounts/reducers/users.reducer.ts
+++ b/Development/client/src/app/accounts/reducers/users-reducer.ts
@@ -28,9 +28,6 @@ export function reducer(
switch (action.type) {
case actions.FETCH:
- // Clear stale entities immediately so the list never shows old data while loading.
- return adapter.removeAll({ ...state, loading: true });
-
case actions.CREATE:
case actions.UPDATE:
case actions.DELETE:
diff --git a/Development/client/src/app/actions/sub-plans.actions.ts b/Development/client/src/app/actions/sub-plans.actions.ts
deleted file mode 100644
index df79b80..0000000
--- a/Development/client/src/app/actions/sub-plans.actions.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Plan, Status } from "@app/domain/models/subscription.model";
-import { Action } from "@ngrx/store";
-
-export const FETCH_SUB_PLANS = '[SUB_PLANS] Fetch subscription plans';
-export class FetchSubPlans implements Action {
- type: typeof FETCH_SUB_PLANS = FETCH_SUB_PLANS;
- constructor() { }
-}
-
-export const FETCH_SUB_PLANS_SUCCESS = '[SUB_PLANS] Fetch subscription plans success';
-export class FetchSubPlansSuccess implements Action {
- type: typeof FETCH_SUB_PLANS_SUCCESS = FETCH_SUB_PLANS_SUCCESS;
- constructor(readonly payload: Plan) { }
-}
-
-export const FETCH_SUB_PLANS_FAILED = '[SUB_PLANS] Fetch subscription plans failed';
-export class FetchSubPlansFailed implements Action {
- type: typeof FETCH_SUB_PLANS_FAILED = FETCH_SUB_PLANS_FAILED;
- constructor(readonly payload: Status) { }
-}
-
-export const RESET_SUB_PLANS = '[SUB_PLANS] Reset subscription plans';
-export class ResetSubPlans implements Action {
- type: typeof RESET_SUB_PLANS = RESET_SUB_PLANS;
- constructor() { }
-}
-
-export type SubPlansAction =
- | FetchSubPlans
- | FetchSubPlansSuccess
- | FetchSubPlansFailed
- | ResetSubPlans
diff --git a/Development/client/src/app/actions/subscription.actions.ts b/Development/client/src/app/actions/subscription.actions.ts
deleted file mode 100644
index ddc7a13..0000000
--- a/Development/client/src/app/actions/subscription.actions.ts
+++ /dev/null
@@ -1,561 +0,0 @@
-import { UserModel } from '@app/auth/models/user.model';
-import { BillingInfo, Card, Invoice, StripeSubscription, SubscriptionPackage, RefreshPackage, CreatePaymentMethodPackage, Status, UnpaidPackage, PastDue, Unpaid, SubscriptionIntent, Incomplete, ConfirmPackage, PaidAmount, Coupon, TrialPmtPkg, Trial, PaymentMethod, PMPkgEdit, PMPkgAdd, Plan } from '@app/domain/models/subscription.model';
-import { Mode } from '@app/profile/common';
-import { Action } from '@ngrx/store';
-
-// shared actions
-export const GOTO_MY_SERVICES = '[SUBSCRIPTION Nav] Navigate to my services page';
-export class GotoMyServices implements Action {
- type: typeof GOTO_MY_SERVICES = GOTO_MY_SERVICES;
-}
-
-export const GOTO_PAYMENT_HISTORY = '[SUBSCRIPTION Nav] Navigate to payment history page';
-export class GotoPaymentHistory implements Action {
- type: typeof GOTO_PAYMENT_HISTORY = GOTO_PAYMENT_HISTORY;
-}
-
-export const GOTO_PAYMENT_DETAIL = '[SUBSCRIPTION Nav] Navigate to payment detail page';
-export class GotoPaymentDetail implements Action {
- type: typeof GOTO_PAYMENT_DETAIL = GOTO_PAYMENT_DETAIL;
- constructor(readonly payload: { paymentId: string }) { }
-}
-
-export const GOTO_SERVICES = '[SUBSCRIPTION Nav] Navigate to services page';
-export class GotoServices implements Action {
- type: typeof GOTO_SERVICES = GOTO_SERVICES;
-}
-
-export const GOTO_BILLING_ADDRESS = '[SUBSCRIPTION Nav] Navigate to billing address page';
-export class GotoBillingAddress implements Action {
- type: typeof GOTO_BILLING_ADDRESS = GOTO_BILLING_ADDRESS;
-}
-
-export const GOTO_CHECK_OUT = '[SUBSCRIPTION Nav] Navigate to checkout page';
-export class GotoCheckout implements Action {
- type: typeof GOTO_CHECK_OUT = GOTO_CHECK_OUT;
-}
-
-export const GOTO_CHECK_OUT_REVIEW = '[SUBSCRIPTION Nav] Navigate to checkout review page';
-export class GotoCheckoutReview implements Action {
- type: typeof GOTO_CHECK_OUT_REVIEW = GOTO_CHECK_OUT_REVIEW;
-}
-
-export const GOTO_CHECK_OUT_CONFIRM = '[SUBSCRIPTION Nav] Navigate to checkout confirm page';
-export class GotoCheckoutConfirm implements Action {
- type: typeof GOTO_CHECK_OUT_CONFIRM = GOTO_CHECK_OUT_CONFIRM;
-}
-
-export const GOTO_HOME = '[SUBSCRIPTION Nav] Navigate to home page';
-export class GotoHome implements Action {
- type: typeof GOTO_HOME = GOTO_HOME;
-}
-
-export const GOTO_USAGE_DETAIL = '[SUBSCRIPTION Nav] Navigate to usage details';
-export class GotoUsageDetail implements Action {
- type: typeof GOTO_USAGE_DETAIL = GOTO_USAGE_DETAIL;
-}
-
-export const GOTO_AIRCRAFT_LIST = '[SUBSCRIPTION Nav] Navigate to aircraft list';
-export class GotoAircraftList implements Action {
- type: typeof GOTO_AIRCRAFT_LIST = GOTO_AIRCRAFT_LIST;
-}
-
-export const COMPOUND = '[SUBSCRIPTION Compound] Execute multiple actions sequentially';
-export class Compound implements Action {
- type: typeof COMPOUND = COMPOUND;
- constructor(readonly payload: Action[]) { }
-}
-
-// In session actions
-export const INIT_SUBSCRIPTION = '[SUBSCRIPTION Manage subscription] Initialize subscriptions contents';
-export class InitSubscription implements Action {
- type: typeof INIT_SUBSCRIPTION = INIT_SUBSCRIPTION;
- constructor(readonly payload: { custId: string }) { }
-}
-
-export const START_MY_SERVICES = '[SUBSCRIPTION Making payment intent session] Initialize my services stage';
-export class StartMyServices implements Action {
- type: typeof START_MY_SERVICES = START_MY_SERVICES;
- constructor(readonly payload: { custId: string }) { }
-}
-
-export const CREATE_NEW_BILLING = '[SUBSCRIPTION Making payment intent session] Initialize subscription intent with a new account';
-export class CreateNewBilling implements Action {
- type: typeof CREATE_NEW_BILLING = CREATE_NEW_BILLING;
- constructor(readonly payload: SubscriptionIntent) { }
-}
-
-export const START_BILLING_INFO = '[SUBSCRIPTION Making payment intent session] Start billing info stage';
-export class StartBillingInfo implements Action {
- type: typeof START_BILLING_INFO = START_BILLING_INFO;
- constructor(readonly payload: any) { }
-}
-
-export const START_BILLING_INFO_SUCCESS = '[SUBSCRIPTION Making payment intent session] Start billing info stage, success';
-export class StartBillingInfoSuccess implements Action {
- type: typeof START_BILLING_INFO_SUCCESS = START_BILLING_INFO_SUCCESS;
- constructor(readonly payload: SubscriptionIntent) { }
-}
-
-export const CREATE_SUBSCRIPTION_INTENT_FAILED = '[SUBSCRIPTION Making payment intent session] Create subscription intent failed';
-export class CreateSubscriptionIntentFailed implements Action {
- type: typeof CREATE_SUBSCRIPTION_INTENT_FAILED = CREATE_SUBSCRIPTION_INTENT_FAILED;
- constructor(readonly payload: Status) { }
-}
-
-export const START_CHECKOUT = '[SUBSCRIPTION Making payment intent session] Start checkout stage';
-export class StartCheckout implements Action {
- type: typeof START_CHECKOUT = START_CHECKOUT;
- constructor(readonly payload: { billingInfo: BillingInfo; subIntentPkg: SubscriptionIntent }) { }
-}
-
-export const START_CHECKOUT_SUCCESS = '[SUBSCRIPTION Making payment intent session] Start checkout stage success';
-export class StartCheckoutSuccess implements Action {
- type: typeof START_CHECKOUT_SUCCESS = START_CHECKOUT_SUCCESS;
- constructor(readonly payload: SubscriptionIntent) { }
-}
-
-export const UPDATE_BILLING_ADDRESS_SUCCESS = '[SUBSCRIPTION Making payment intent session] Update billing address success';
-export class UpdateBillingAddressSuccess implements Action {
- type: typeof UPDATE_BILLING_ADDRESS_SUCCESS = UPDATE_BILLING_ADDRESS_SUCCESS;
- constructor(readonly payload: BillingInfo) { }
-}
-
-export const CREATE_PAYMENT_METHOD = '[SUBSCRIPTION Making payment intent session] Create payment method';
-export class CreatePaymentMethod implements Action {
- type: typeof CREATE_PAYMENT_METHOD = CREATE_PAYMENT_METHOD;
- constructor(readonly payload: CreatePaymentMethodPackage) { }
-}
-
-export const CREATE_PAYMENT_METHOD_FAILED = '[SUBSCRIPTION Making payment intent session] Create payment method failed';
-export class CreatePaymentMethodFailed implements Action {
- type: typeof CREATE_PAYMENT_METHOD_FAILED = CREATE_PAYMENT_METHOD_FAILED;
- constructor(readonly payload: Status) { }
-}
-
-export const CHECK_OUT = '[SUBSCRIPTION Making payment intent session] Checkout';
-export class Checkout implements Action {
- type: typeof CHECK_OUT = CHECK_OUT;
- constructor(readonly payload: Card) { }
-}
-
-export const UPDATE_SUBSCRIPTION = '[SUBSCRIPTION Making payment intent session] Update subscription';
-export class UpdateSubscription implements Action {
- type: typeof UPDATE_SUBSCRIPTION = UPDATE_SUBSCRIPTION;
- constructor(readonly payload: SubscriptionPackage) { }
-}
-
-export const CANCEL_SUBSCRIPTION = '[SUBSCRIPTION Making payment intent session] Cancel subscription attempt';
-export class CancelSubscription implements Action {
- type: typeof CANCEL_SUBSCRIPTION = CANCEL_SUBSCRIPTION;
- constructor(readonly payload: SubscriptionIntent) { }
-}
-
-export const SET_SUBSCRIPTION_INTENT_PREV_STAGE = '[SUBSCRIPTION Making payment intent session] Set subscription intent previous stage';
-export class SetSubscriptionIntentPrevStage implements Action {
- type: typeof SET_SUBSCRIPTION_INTENT_PREV_STAGE = SET_SUBSCRIPTION_INTENT_PREV_STAGE;
- constructor(readonly payload: string) { }
-}
-
-export const UPDATE_SUBSCRIPTION_INTENT_STATUS = '[SUBSCRIPTION Making payment intent session] Update subscription intent status';
-export class UpdateSubscriptionIntentStatus implements Action {
- type: typeof UPDATE_SUBSCRIPTION_INTENT_STATUS = UPDATE_SUBSCRIPTION_INTENT_STATUS;
- constructor(readonly payload: Status) { }
-}
-
-export const CLEAR_SUBSCRIPTION_INTENT_STATUS = '[SUBSCRIPTION Making payment intent session] Clear subscription intent status';
-export class ClearSubscriptionIntentStatus implements Action {
- type: typeof CLEAR_SUBSCRIPTION_INTENT_STATUS = CLEAR_SUBSCRIPTION_INTENT_STATUS;
- constructor() { }
-}
-
-export const RESET_SUBSCRIPTION_INTENT = '[SUBSCRIPTION Making payment intent session] Reset subscription intent to initial state';
-export class ResetSubscriptionIntent implements Action {
- type: typeof RESET_SUBSCRIPTION_INTENT = RESET_SUBSCRIPTION_INTENT;
-}
-
-export const CLEAR_PREV_STAGE = '[SUBSCRIPTION Making payment intent session] Set up intent default subIntent stage';
-export class ClearPrevStage implements Action {
- type: typeof CLEAR_PREV_STAGE = CLEAR_PREV_STAGE;
-}
-
-export const UPDATE_AMOUNT = '[SUBSCRIPTION Making payment intent session] Update payment amount';
-export class UpdateAmount implements Action {
- type: typeof UPDATE_AMOUNT = UPDATE_AMOUNT;
- constructor(readonly payload: PaidAmount) { }
-}
-
-export const UPDATE_PROMO_SAVINGS = '[SUBSCRIPTION Making payment intent session] Update promo savings';
-export class UpdatePromoSavings implements Action {
- type: typeof UPDATE_PROMO_SAVINGS = UPDATE_PROMO_SAVINGS;
- constructor(readonly payload: number) { }
-}
-
-// Resolving payment session actions
-export const FETCH_LATEST_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription';
-export class FetchLatestSubscription implements Action {
- type: typeof FETCH_LATEST_SUBSCRIPTION = FETCH_LATEST_SUBSCRIPTION;
- constructor(readonly payload: { custId: string }) { }
-}
-
-export const FETCH_LATEST_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription success';
-export class FetchLatestSubscriptionSuccess implements Action {
- type: typeof FETCH_LATEST_SUBSCRIPTION_SUCCESS = FETCH_LATEST_SUBSCRIPTION_SUCCESS;
- constructor(readonly payload: Plan) { }
-}
-
-export const POLL_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Poll for unpaid subscription';
-export class PollUnpaidSubscription implements Action {
- type: typeof POLL_UNPAID_SUBSCRIPTION = POLL_UNPAID_SUBSCRIPTION;
- constructor(readonly payload: { custId: string }) { }
-}
-
-export const POLL_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Poll for unpaid success';
-export class PollUnpaidSubscriptionSuccess implements Action {
- type: typeof POLL_UNPAID_SUBSCRIPTION_SUCCESS = POLL_UNPAID_SUBSCRIPTION_SUCCESS;
- constructor(readonly payload: Plan) { }
-}
-
-export const CANCEL_POLL_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Cancel polling for latest subscriptions';
-export class CancelPollSubscription implements Action {
- type: typeof CANCEL_POLL_SUBSCRIPTION = CANCEL_POLL_SUBSCRIPTION;
- constructor() { }
-}
-
-export const RESET_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Reset subscription to initial state';
-export class ResetSubscription implements Action {
- type: typeof RESET_SUBSCRIPTION = RESET_SUBSCRIPTION;
-}
-
-export const REFRESH_SUBSCRIPTION_INTENT = '[SUBSCRIPTION Resolving payment session] Refesh unresolved subscription';
-export class RefeshSubscriptionIntent implements Action {
- type: typeof REFRESH_SUBSCRIPTION_INTENT = REFRESH_SUBSCRIPTION_INTENT;
- constructor(readonly payload: RefreshPackage) { }
-}
-
-export const REFRESH_SUBSCRIPTION_INTENT_SUCCESS = '[SUBSCRIPTION Resolving payment session] Refesh unresolved subscription success';
-export class RefeshSubscriptionIntentSuccess implements Action {
- type: typeof REFRESH_SUBSCRIPTION_INTENT_SUCCESS = REFRESH_SUBSCRIPTION_INTENT_SUCCESS;
- constructor(readonly payload: SubscriptionIntent) { }
-}
-
-export const REQUIRE_ACTION = '[SUBSCRIPTION Resolving payment session] Require action on payment method';
-export class RequireAction implements Action {
- type: typeof REQUIRE_ACTION = REQUIRE_ACTION;
- constructor(readonly payload: ConfirmPackage[]) { }
-}
-
-export const RESOLVE_PAYMENT = '[SUBSCRIPTION Resolving payment session] Resolve payment outstanding subsriptions';
-export class ResolvePayment implements Action {
- type: typeof RESOLVE_PAYMENT = RESOLVE_PAYMENT;
-}
-
-export const SHOW_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Show unpaid subscription list';
-export class ShowUnpaidSubscription implements Action {
- type: typeof SHOW_UNPAID_SUBSCRIPTION = SHOW_UNPAID_SUBSCRIPTION;
- constructor() { }
-}
-
-export const RESUME_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch unpaid subscriptions';
-export class ResumeUnpaidSubscription implements Action {
- type: typeof RESUME_UNPAID_SUBSCRIPTION = RESUME_UNPAID_SUBSCRIPTION;
- constructor(readonly payload: {
- unpaidInvoices: Invoice[],
- name: string,
- authUser: UserModel
- }) { }
-}
-
-export const RESUME_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Fetch unpaid subscriptions success';
-export class ResumeUnpaidSubscriptionSuccess implements Action {
- type: typeof RESUME_UNPAID_SUBSCRIPTION_SUCCESS = RESUME_UNPAID_SUBSCRIPTION_SUCCESS;
- constructor(readonly payload: {
- unpaid: Unpaid,
- subscriptions: StripeSubscription[],
- status: Status
- }) { }
-}
-
-export const PAY_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Pay unpaid subscription';
-export class PayUnpaidSubscription implements Action {
- type: typeof PAY_UNPAID_SUBSCRIPTION = PAY_UNPAID_SUBSCRIPTION;
- constructor(readonly payload: UnpaidPackage) { }
-}
-
-export const CONFIRM = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and pastdue payments';
-export class Confirm implements Action {
- type: typeof CONFIRM = CONFIRM;
- constructor(readonly payload: ConfirmPackage) { }
-}
-
-export const COMPLETE_PAYMENT = '[SUBSCRIPTION Resolving payment session] Completed resolving unresolved invoices';
-export class CompletePayment implements Action {
- type: typeof COMPLETE_PAYMENT = COMPLETE_PAYMENT;
-}
-
-export const UPDATE_PAST_DUE = '[SUBSCRIPTION Resolving payment session] Retry past due payment';
-export class UpdatePastDue implements Action {
- type: typeof UPDATE_PAST_DUE = UPDATE_PAST_DUE;
- constructor(readonly payload: PastDue) { }
-}
-
-export const UPDATE_INCOMPLETE = '[SUBSCRIPTION Resolving payment session] Retry incomplete Payment';
-export class UpdateIncomplete implements Action {
- type: typeof UPDATE_INCOMPLETE = UPDATE_INCOMPLETE;
- constructor(readonly payload: Incomplete) { }
-}
-
-export const UPDATE_UNPAID = '[SUBSCRIPTION Resolving payment session] Update unpaid';
-export class UpdateUnpaid implements Action {
- type: typeof UPDATE_UNPAID = UPDATE_UNPAID;
- constructor(readonly payload: Unpaid) { }
-}
-
-export const UPDATE_SUBSCRIPTION_STATUS = '[SUBSCRIPTION] Update subscriptionstatus';
-export class UpdateSubscriptionStatus implements Action {
- type: typeof UPDATE_SUBSCRIPTION_STATUS = UPDATE_SUBSCRIPTION_STATUS;
- constructor(readonly payload: Status) { }
-}
-
-export const CLEAR_SUBSCRIPTION_STATUS = '[SUBSCRIPTION] Clear subscriptionstatus';
-export class ClearSubscriptionStatus implements Action {
- type: typeof CLEAR_SUBSCRIPTION_STATUS = CLEAR_SUBSCRIPTION_STATUS;
- constructor() { }
-}
-
-export const CLEAR_SUBSCRIPTION = '[SUBSCRIPTION] Clear subscriptions';
-export class ClearSubscription implements Action {
- type: typeof CLEAR_SUBSCRIPTION = CLEAR_SUBSCRIPTION;
- constructor() { }
-}
-
-export const LOAD_STRIPE = '[SUBSCRIPTION] load stripe api';
-export class LoadStripe implements Action {
- type: typeof LOAD_STRIPE = LOAD_STRIPE;
- constructor() { }
-}
-
-export const LOAD_STRIPE_SUCCESS = '[SUBSCRIPTION] load stripe api success';
-export class LoadStripeSuccess implements Action {
- type: typeof LOAD_STRIPE_SUCCESS = LOAD_STRIPE_SUCCESS;
- constructor() { }
-}
-
-export const LOAD_STRIPE_FAILED = '[SUBSCRIPTION] load stripe api failed';
-export class LoadStripeFailed implements Action {
- type: typeof LOAD_STRIPE_FAILED = LOAD_STRIPE_FAILED;
- constructor(readonly payload: Status) { }
-}
-
-export const APPLY_DISCOUNT_PREVIEW = '[SUBSCRIPTION] Apply discount preview';
-export class ApplyDiscountPreview implements Action {
- type: typeof APPLY_DISCOUNT_PREVIEW = APPLY_DISCOUNT_PREVIEW;
- constructor(readonly payload: { subIntentPkg: SubscriptionIntent, coupon?: string }) { }
-}
-
-export const APPLY_DISCOUNT_PREVIEW_SUCCESS = '[SUBSCRIPTION] Apply discount preview success';
-export class ApplyDiscountPreviewSuccess implements Action {
- type: typeof APPLY_DISCOUNT_PREVIEW_SUCCESS = APPLY_DISCOUNT_PREVIEW_SUCCESS;
- constructor(readonly payload: { coupons: Coupon[], amount: PaidAmount }) { }
-}
-
-export const APPLY_DISCOUNT_PREVIEW_FAILED = '[SUBSCRIPTION] Apply discount preview failed';
-export class ApplyDiscountPreviewFailed implements Action {
- type: typeof APPLY_DISCOUNT_PREVIEW_FAILED = APPLY_DISCOUNT_PREVIEW_FAILED;
- constructor(readonly payload: Status) { }
-}
-
-
-// Payment method actions
-export const FETCH_PAYMENT_METHOD_LIST = '[SUBSCRIPTION] Fetch payment methods';
-export class FetchPaymentMethodList implements Action {
- type: typeof FETCH_PAYMENT_METHOD_LIST = FETCH_PAYMENT_METHOD_LIST;
-}
-
-export const FETCH_PAYMENT_METHOD_LIST_SUCCESS = '[SUBSCRIPTION] Fetch payment methods success';
-export class FetchPaymentMethodListSuccess implements Action {
- type: typeof FETCH_PAYMENT_METHOD_LIST_SUCCESS = FETCH_PAYMENT_METHOD_LIST_SUCCESS;
- constructor(readonly payload: { paymentMethods: PaymentMethod[] }) { }
-}
-
-export const FETCH_DEFAULT_PM = '[SUBSCRIPTION] Fetch default payment methods';
-export class FetchDefaultPm implements Action {
- type: typeof FETCH_DEFAULT_PM = FETCH_DEFAULT_PM;
-}
-
-export const FETCH_DEFAULT_PM_SUCCESS = '[SUBSCRIPTION] Fetch default payment methods success';
-export class FetchDefaultPmSuccess implements Action {
- type: typeof FETCH_DEFAULT_PM_SUCCESS = FETCH_DEFAULT_PM_SUCCESS;
- constructor(readonly payload: { defPM: PaymentMethod }) { }
-}
-
-export const EDIT_PM = '[SUBSCRIPTION] Edit payment method';
-export class EditPM implements Action {
- type: typeof EDIT_PM = EDIT_PM;
- constructor(readonly payload: PMPkgEdit) { }
-}
-
-export const EDIT_PM_SUCCESS = '[SUBSCRIPTION] Edit payment method success';
-export class EditPMSuccess implements Action {
- type: typeof EDIT_PM_SUCCESS = EDIT_PM_SUCCESS;
- constructor(readonly payload: PaymentMethod) { }
-}
-
-export const ADD_PM = '[SUBSCRIPTION] Add payment method';
-export class AddPM implements Action {
- type: typeof ADD_PM = ADD_PM;
- constructor(readonly payload: PMPkgAdd) { }
-}
-
-export const ADD_PM_SUCCESS = '[SUBSCRIPTION] Add payment method success';
-export class AddPMSuccess implements Action {
- type: typeof ADD_PM_SUCCESS = ADD_PM_SUCCESS;
- constructor(readonly payload: PaymentMethod) { }
-}
-
-export const DELETE_PM = '[SUBSCRIPTION] Delete payment method';
-export class DeletePM implements Action {
- type: typeof DELETE_PM = DELETE_PM;
- constructor(readonly payload: string) { }
-}
-
-export const DELETE_PM_SUCCESS = '[SUBSCRIPTION] Delete payment method success';
-export class DeletePMSuccess implements Action {
- type: typeof DELETE_PM_SUCCESS = DELETE_PM_SUCCESS;
- constructor(readonly payload: string) { }
-}
-
-export const CHANGE_PM = '[SUBSCRIPTION] Change default payment method';
-export class ChangePM implements Action {
- type: typeof CHANGE_PM = CHANGE_PM;
- constructor(readonly payload: { custId: string, pmId: string }) { }
-}
-
-export const CHANGE_PM_SUCCESS = '[SUBSCRIPTION] Change default payment method success';
-export class ChangePMSuccess implements Action {
- type: typeof CHANGE_PM_SUCCESS = CHANGE_PM_SUCCESS;
- constructor(readonly payload: { defPM: PaymentMethod }) { }
-}
-
-// Payment success
-export const CONFIRM_ACTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and require action success';
-export class ConfirmActionSuccess implements Action {
- type: typeof CONFIRM_ACTION_SUCCESS = CONFIRM_ACTION_SUCCESS;
- constructor(readonly payload: Plan) { }
-}
-
-export const CONFIRM_PAYMENT_SUCCESS = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and require payment method success';
-export class ConfirmPaymentSuccess implements Action {
- type: typeof CONFIRM_PAYMENT_SUCCESS = CONFIRM_PAYMENT_SUCCESS;
- constructor(readonly payload: Plan) { }
-}
-
-export const PAY_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Pay unpaid subscription success';
-export class PayUnpaidSubscriptionSuccess implements Action {
- type: typeof PAY_UNPAID_SUBSCRIPTION_SUCCESS = PAY_UNPAID_SUBSCRIPTION_SUCCESS;
- constructor(readonly payload: Plan) { }
-}
-
-export const UPDATE_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Making payment intent session] Update subscription success';
-export class UpdateSubscriptionSuccess implements Action {
- type: typeof UPDATE_SUBSCRIPTION_SUCCESS = UPDATE_SUBSCRIPTION_SUCCESS;
- constructor(readonly payload: Plan) { }
-}
-
-// trial subscription section
-export const SET_TRIAL_MODE = '[SUBSCRIPTION Making payment intent session] Starting a trial subscription';
-export class SetMode implements Action {
- type: typeof SET_TRIAL_MODE = SET_TRIAL_MODE;
- constructor(readonly payload: Mode) { }
-}
-
-export const UPDATE_TRIAL = '[SUBSCRIPTION] Update trials object';
-export class UpdateTrial implements Action {
- type: typeof UPDATE_TRIAL = UPDATE_TRIAL;
- constructor(readonly payload: Trial) { }
-}
-
-export const CHECK_OUT_TRIAL = '[SUBSCRIPTION Making payment intent session] Checkout a trial subscription';
-export class CheckoutTrial implements Action {
- type: typeof CHECK_OUT_TRIAL = CHECK_OUT_TRIAL;
- constructor(readonly payload: TrialPmtPkg) { }
-}
-
-export const CHECK_OUT_TRIAL_SUCCESS = '[SUBSCRIPTION Making payment intent session] Checkout a trial subscription success';
-export class CheckoutTrialSuccess implements Action {
- type: typeof CHECK_OUT_TRIAL_SUCCESS = CHECK_OUT_TRIAL_SUCCESS;
- constructor(readonly payload: { card?: Card, subs: StripeSubscription[], amount?: PaidAmount }) { }
-}
-
-export type SubscriptionIntentAction =
- | CompletePayment
- | ConfirmActionSuccess
- | ConfirmPaymentSuccess
- | Checkout
- | StartBillingInfo
- | StartBillingInfoSuccess
- | CreateSubscriptionIntentFailed
- | CreatePaymentMethodFailed
- | ClearSubscriptionIntentStatus
- | GotoBillingAddress
- | GotoCheckout
- | GotoCheckoutReview
- | GotoServices
- | PayUnpaidSubscriptionSuccess
- | RefeshSubscriptionIntent
- | RefeshSubscriptionIntentSuccess
- | ResetSubscriptionIntent
- | SetSubscriptionIntentPrevStage
- | UpdateSubscriptionIntentStatus
- | StartCheckout
- | StartCheckoutSuccess
- | UpdateBillingAddressSuccess
- | UpdateSubscriptionSuccess
- | UpdateAmount
- | UpdatePromoSavings
- | ClearPrevStage
- | GotoUsageDetail
- | LoadStripe
- | LoadStripeFailed
- | ApplyDiscountPreviewSuccess
- | ApplyDiscountPreviewFailed
- | SetMode
- | CheckoutTrialSuccess
-
-export type SubscriptionAction =
- | CompletePayment
- | Confirm
- | ConfirmActionSuccess
- | ConfirmPaymentSuccess
- | ClearSubscriptionStatus
- | ClearSubscription
- | GotoCheckout
- | GotoServices
- | FetchLatestSubscriptionSuccess
- | PollUnpaidSubscriptionSuccess
- | PayUnpaidSubscriptionSuccess
- | UpdateUnpaid
- | UpdatePastDue
- | UpdateIncomplete
- | ResetSubscription
- | ResumeUnpaidSubscription
- | ResumeUnpaidSubscriptionSuccess
- | StartCheckoutSuccess
- | UpdateSubscriptionStatus
- | UpdateSubscription
- | UpdateSubscriptionSuccess
- | StartBillingInfoSuccess
- | FetchPaymentMethodList
- | FetchPaymentMethodListSuccess
- | FetchDefaultPm
- | FetchDefaultPmSuccess
- | EditPM
- | EditPMSuccess
- | AddPM
- | AddPMSuccess
- | DeletePMSuccess
- | ChangePMSuccess
- | CheckoutTrialSuccess
- | UpdateTrial
-
-
diff --git a/Development/client/src/app/app-routing.module.ts b/Development/client/src/app/app-routing.module.ts
index 4bc3b7f..eff504f 100644
--- a/Development/client/src/app/app-routing.module.ts
+++ b/Development/client/src/app/app-routing.module.ts
@@ -1,23 +1,20 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
+
import { PageNotFoundComponent } from './page-not-found.component';
import { AuthGuard } from './domain/guards/auth.guard';
+
import { DashboardComponent } from './dashboard/dashboard.component';
import { ReportComponent } from './report.component';
import { AppMainComponent } from './app.main.component';
import { AppPreloader } from './app-preloader';
import { AppPasswordResetComp } from './pages/app.password-reset.component';
-import { NotificationRedirectGuard } from './domain/guards/notification-redirect.guard';
import { SettingsGuard } from './domain/guards/settings-guard.service';
-import { MembershipResolver } from './domain/resolvers/membership-resolver';
const routes: Routes = [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{
path: '', component: AppMainComponent,
- resolve: {
- membership: MembershipResolver
- },
children: [
{
path: 'home',
@@ -31,15 +28,16 @@ const routes: Routes = [
path: 'customers',
loadChildren: () => import('./customers/customer.module').then(m => m.CustomersModule),
},
- {
- path: 'partners',
- loadChildren: () => import('./partners/partners.module').then(m => m.PartnersModule),
- },
{
path: 'profile',
loadChildren: () => import('./profile/profile.module').then((m) => m.ProfileModule),
- runGuardsAndResolvers: 'always',
+ // runGuardsAndResolvers: "always",
},
+ // {
+ // path: 'membership',
+ // loadChildren: () => import('./subscription/membership.module').then((m) => m.MembershipModule),
+ // // runGuardsAndResolvers: "always",
+ // },
{
path: 'billing',
loadChildren: () => import('./billing/billing.module').then(m => m.BillingModule),
@@ -80,16 +78,6 @@ const routes: Routes = [
runGuardsAndResolvers: 'always',
data: { preload: true }
},
- {
- path: 'partner-customers',
- loadChildren: () => import('./partner-customers/partner-customers.module').then(m => m.PartnerCustomersModule),
- runGuardsAndResolvers: 'always'
- },
- {
- path: 'settings',
- loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
- runGuardsAndResolvers: 'always'
- },
],
},
{
@@ -115,39 +103,8 @@ const routes: Routes = [
roles: null
},
},
- {
- path: 'signup',
- loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule)
- },
- {
- path: 'manage-subscription',
- component: PageNotFoundComponent,
- canActivate: [NotificationRedirectGuard],
- data: {
- redirectTo: ['profile', 'myservices'],
- redirectToNoSubs: ['profile', 'services'],
- loginNotice: $localize`:Login notice for manage-subscription link@@manageSubLoginNotice:Please log in with your Master account to manage your subscriptions.`
- }
- },
- {
- path: 'update-pm',
- component: PageNotFoundComponent,
- canActivate: [NotificationRedirectGuard],
- data: {
- redirectTo: ['profile', 'payment-method-list'],
- loginNotice: $localize`:Login notice for update-pm link@@updatePmLoginNotice:Please log in with your Master account to update your payment method.`
- }
- },
- {
- path: 'update-bill-address',
- component: PageNotFoundComponent,
- canActivate: [NotificationRedirectGuard],
- data: {
- redirectTo: ['profile', 'billing-address'],
- loginNotice: $localize`:Login notice for update-bill-address link@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.`
- }
- },
{ path: '**', component: PageNotFoundComponent },
+ // { path: '/denied', component: AccessDeniedComponent },
];
@NgModule({
@@ -166,6 +123,6 @@ const routes: Routes = [
exports: [
RouterModule
],
- providers: [AppPreloader, MembershipResolver],
+ providers: [AppPreloader],
})
export class AppRoutingModule { }
diff --git a/Development/client/src/app/app.component.spec.ts b/Development/client/src/app/app.component.spec.ts
new file mode 100644
index 0000000..f71e345
--- /dev/null
+++ b/Development/client/src/app/app.component.spec.ts
@@ -0,0 +1,36 @@
+/* tslint:disable:no-unused-variable */
+
+import { TestBed, async } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { AppComponent } from './app.component';
+import { AppTopbarComponent } from './app.topbar.component';
+import { AppInlineProfileComponent } from './app.profile.component';
+import { AppFooterComponent } from './app.footer.component';
+import { AppBreadcrumbComponent } from './app.breadcrumb.component';
+import { AppMenuComponent, AppSubMenuComponent } from './app.menu.component';
+import { BreadcrumbService } from './breadcrumb.service';
+import { ScrollPanelModule} from 'primeng/primeng';
+
+describe('AppComponent', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [ RouterTestingModule, ScrollPanelModule ],
+ declarations: [ AppComponent,
+ AppTopbarComponent,
+ AppMenuComponent,
+ AppSubMenuComponent,
+ AppFooterComponent,
+ AppBreadcrumbComponent,
+ AppInlineProfileComponent
+ ],
+ providers: [BreadcrumbService]
+ });
+ TestBed.compileComponents();
+ });
+
+ it('should create the app', async(() => {
+ const fixture = TestBed.createComponent(AppComponent);
+ const app = fixture.debugElement.componentInstance;
+ expect(app).toBeTruthy();
+ }));
+});
diff --git a/Development/client/src/app/app.component.ts b/Development/client/src/app/app.component.ts
index 7769a54..eb6bcb9 100644
--- a/Development/client/src/app/app.component.ts
+++ b/Development/client/src/app/app.component.ts
@@ -1,9 +1,8 @@
import { Component, OnInit, OnDestroy, HostBinding } from '@angular/core';
import * as L from 'leaflet';
import { globals, Roles, RoleIds, ProdTypes, ProdType, vehTypes, VehType, MatType, matTypes } from './shared/global';
-import { filter } from 'rxjs/operators';
-import { NavigationEnd, NavigationError, NavigationCancel, NavigationStart } from '@angular/router';
+import { NavigationEnd } from '@angular/router';
import { environment } from '@environments/environment';
import { BaseComp } from './shared/base/base.component';
@@ -19,11 +18,6 @@ export class AppComponent extends BaseComp implements OnInit, OnDestroy {
@HostBinding('@.disabled')
public animationsDisabled = L.Browser.mobile; // Disable Web Animation as it is not turn on as default in IOS
- private navigationStartTime: number = 0;
- private previousUrl: string = '';
- private sessionPageCount: number = 0;
- private pageStartTime: number = 0;
-
get showFooter() {
return location.href.indexOf('/login') != -1;
}
@@ -31,450 +25,24 @@ export class AppComponent extends BaseComp implements OnInit, OnDestroy {
constructor() {
super();
this["name"] = "AppComp";
+
+ // Subscribe to router events and send page views to Google Analytics
+ this.router.events.subscribe(event => {
+ if (event instanceof NavigationEnd) {
+ if (!environment.production)
+ console.log(event.urlAfterRedirects);
+
+ // if (this.authSvc.user && this.authSvc.byPUserId) {
+ // ga('set', 'userId', this.authSvc.byPUserId);
+ // ga('set', 'dimension1', this.authSvc.byPUserId);
+ // ga('set', 'page', event.urlAfterRedirects);
+ // ga('send', 'pageview');
+ // }
+ }
+ });
}
ngOnInit() {
- // Initialize GA4 when Angular app is ready
- this.gaSvc.initialize();
-
- if (!environment.production) {
- !environment.production && console.log('GA4 Service initialized:', this.gaSvc.isInitialized());
- }
-
- // Track session start
- this.trackSessionStart();
-
- // Subscribe to router events for comprehensive navigation tracking
- this.router.events.subscribe(event => {
- if (event instanceof NavigationStart) {
- this.handleNavigationStart(event);
- } else if (event instanceof NavigationEnd) {
- this.handleNavigationEnd(event);
- } else if (event instanceof NavigationError) {
- this.handleNavigationError(event);
- } else if (event instanceof NavigationCancel) {
- this.handleNavigationCancel(event);
- }
- });
-
- // Track initial page load
- this.pageStartTime = Date.now();
- }
-
- /**
- * Extract page title from URL path for analytics
- * @param url - The URL path
- * @returns Human-readable page title
- */
- private getPageTitle(url: string): string {
- // Remove query parameters and fragments
- const cleanUrl = url.split('?')[0].split('#')[0];
-
- // Extract main route segments
- const segments = cleanUrl.split('/').filter(segment => segment.length > 0);
-
- if (segments.length === 0) {
- return 'Dashboard';
- }
-
- // Map common routes to readable titles
- const routeTitleMap: { [key: string]: string } = {
- 'login': 'Login',
- 'dashboard': 'Dashboard',
- 'jobs': 'Jobs',
- 'job': 'Job Details',
- 'clients': 'Clients',
- 'client': 'Client Details',
- 'accounts': 'Accounts',
- 'billing': 'Billing',
- 'profile': 'Profile',
- 'tools': 'Tools',
- 'areas': 'Areas Management',
- 'upload': 'File Upload',
- 'track': 'Tracking',
- 'admin': 'Administration'
- };
-
- const mainRoute = segments[0];
- return routeTitleMap[mainRoute] || this.capitalizeRoute(mainRoute);
- }
-
- /**
- * Capitalize route name for display
- * @param route - Route string
- * @returns Capitalized route name
- */
- private capitalizeRoute(route: string): string {
- return route.charAt(0).toUpperCase() + route.slice(1).replace(/-/g, ' ');
- }
-
- /**
- * Handle navigation start event
- * @param event - NavigationStart event
- */
- private handleNavigationStart(event: NavigationStart): void {
- this.navigationStartTime = Date.now();
-
- // Track navigation start
- this.gaSvc.trackEvent('navigation_started', {
- navigation_type: 'route_change',
- source_url: this.previousUrl,
- destination_url: event.url,
- navigation_method: event.navigationTrigger === 'imperative' ? 'programmatic' : 'router_link',
- navigation_timing_ms: 0,
- is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId),
- session_page_count: this.sessionPageCount,
- time_on_previous_page_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0,
- user_id: this.authSvc.byPUserId,
- user_role: this.getUserRole(),
- referrer: document.referrer,
- user_agent: navigator.userAgent,
- viewport_width: window.innerWidth,
- viewport_height: window.innerHeight,
- screen_resolution: `${screen.width}x${screen.height}`
- });
- }
-
- /**
- * Handle successful navigation end
- * @param event - NavigationEnd event
- */
- private handleNavigationEnd(event: NavigationEnd): void {
- const navigationTime = this.navigationStartTime ? Date.now() - this.navigationStartTime : 0;
- this.sessionPageCount++;
-
- if (!environment.production) {
- console.log('Page navigation:', event.urlAfterRedirects);
- }
-
- // Track navigation completion
- this.gaSvc.trackEvent('navigation_completed', {
- navigation_type: 'route_change',
- source_url: this.previousUrl,
- destination_url: event.urlAfterRedirects,
- navigation_method: 'router_link',
- navigation_timing_ms: navigationTime,
- page_title: this.getPageTitle(event.urlAfterRedirects),
- previous_page_title: this.previousUrl ? this.getPageTitle(this.previousUrl) : '',
- is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId),
- session_page_count: this.sessionPageCount,
- time_on_previous_page_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0,
- user_id: this.authSvc.byPUserId,
- user_role: this.getUserRole(),
- referrer: document.referrer,
- user_agent: navigator.userAgent,
- viewport_width: window.innerWidth,
- viewport_height: window.innerHeight,
- screen_resolution: `${screen.width}x${screen.height}`,
- bounce_candidate: this.sessionPageCount === 1
- });
-
- // Track traditional page view for backward compatibility
- this.gaSvc.trackPageView(
- this.getPageTitle(event.urlAfterRedirects),
- event.urlAfterRedirects
- );
-
- // Set user ID if user is authenticated
- if (this.authSvc.user && this.authSvc.byPUserId) {
- this.gaSvc.setUserId(this.authSvc.byPUserId);
-
- // Set user properties for better segmentation
- this.gaSvc.setUserProperties({
- user_type: 'authenticated',
- client_name: this.authSvc.user.name || 'unknown'
- });
- }
-
- // Update tracking variables
- this.previousUrl = event.urlAfterRedirects;
- this.pageStartTime = Date.now();
-
- // Track slow page loads (threshold: 3 seconds)
- if (navigationTime > 3000) {
- this.gaSvc.trackEvent('slow_page_load', {
- page_title: this.getPageTitle(event.urlAfterRedirects),
- load_time_ms: navigationTime,
- connection_type: this.getConnectionType(),
- device_type: this.getDeviceType(),
- platform: 'web'
- });
- }
- }
-
- /**
- * Handle navigation error
- * @param event - NavigationError event
- */
- private handleNavigationError(event: NavigationError): void {
- const navigationTime = this.navigationStartTime ? Date.now() - this.navigationStartTime : 0;
-
- if (!environment.production) {
- console.error('Navigation error:', event.error, 'URL:', event.url);
- }
-
- // Determine error type based on error message
- let errorType: 'route_not_found' | 'navigation_cancelled' | 'guard_rejected' | 'resolver_error' | 'timeout' | 'network_error' | 'permission_denied' = 'navigation_cancelled';
-
- if (event.error?.message?.includes('Cannot match any routes')) {
- errorType = 'route_not_found';
- } else if (event.error?.message?.includes('guard')) {
- errorType = 'guard_rejected';
- } else if (event.error?.message?.includes('resolver')) {
- errorType = 'resolver_error';
- } else if (event.error?.message?.includes('timeout')) {
- errorType = 'timeout';
- } else if (event.error?.message?.includes('network')) {
- errorType = 'network_error';
- } else if (event.error?.message?.includes('permission')) {
- errorType = 'permission_denied';
- }
-
- // Track navigation error
- this.gaSvc.trackEvent('navigation_error', {
- error_type: errorType,
- error_message: event.error?.message || 'Unknown navigation error',
- error_code: event.error?.name || 'NavigationError',
- error_stack: event.error?.stack || '',
- attempted_url: event.url,
- source_url: this.previousUrl,
- navigation_method: 'router_link',
- error_timestamp: new Date().toISOString(),
- navigation_timing_ms: navigationTime,
- is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId),
- user_permissions: this.getUserPermissions(),
- session_duration_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0,
- previous_successful_navigation: this.previousUrl,
- user_id: this.authSvc.byPUserId,
- user_role: this.getUserRole(),
- browser_info: navigator.userAgent,
- device_type: this.getDeviceType(),
- route_depth: event.url.split('/').length - 1,
- resolution_action: this.getResolutionAction(errorType),
- resolution_successful: false,
- resolution_time_ms: 0
- });
-
- // Attempt to resolve the error
- this.resolveNavigationError(event, errorType);
- }
-
- /**
- * Handle navigation cancel
- * @param event - NavigationCancel event
- */
- private handleNavigationCancel(event: NavigationCancel): void {
- const navigationTime = this.navigationStartTime ? Date.now() - this.navigationStartTime : 0;
-
- if (!environment.production) {
- console.log('Navigation cancelled:', event.reason, 'URL:', event.url);
- }
-
- // Track navigation cancellation
- this.gaSvc.trackEvent('navigation_cancelled', {
- error_type: 'navigation_cancelled',
- error_message: event.reason || 'Navigation was cancelled',
- error_code: 'NavigationCancel',
- attempted_url: event.url,
- source_url: this.previousUrl,
- navigation_method: 'router_link',
- error_timestamp: new Date().toISOString(),
- navigation_timing_ms: navigationTime,
- is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId),
- user_permissions: this.getUserPermissions(),
- session_duration_ms: this.pageStartTime ? Date.now() - this.pageStartTime : 0,
- previous_successful_navigation: this.previousUrl,
- user_id: this.authSvc.byPUserId,
- user_role: this.getUserRole(),
- browser_info: navigator.userAgent,
- device_type: this.getDeviceType(),
- route_depth: event.url.split('/').length - 1,
- resolution_action: 'none',
- resolution_successful: false,
- resolution_time_ms: 0
- });
- }
-
- /**
- * Get user role from user model using shared analytics helpers
- */
- private getUserRole(): string {
- if (!this.authSvc.user?.roles) {
- return 'anonymous';
- }
-
- // Use shared analytics helper through base component convenience method
- return this.getAnalyticsUserRole();
- }
-
- /**
- * Get user permissions from user model
- */
- private getUserPermissions(): string[] {
- if (!this.authSvc.user?.roles) {
- return [];
- }
-
- const permissions: string[] = [];
- const roles = this.authSvc.user.roles;
-
- // Map roles to permissions
- if (roles.admin) permissions.push('admin', 'full_access');
- if (roles.officer) permissions.push('officer', 'job_management', 'financial_access');
- if (roles.pilot) permissions.push('pilot', 'job_execution', 'tracking_access');
- if (roles.applicator) permissions.push('applicator', 'job_execution', 'tracking_access');
- if (roles.client) permissions.push('client', 'job_creation', 'report_access');
- if (roles.inspector) permissions.push('inspector', 'report_access');
- if (roles.aircraft) permissions.push('aircraft', 'data_upload');
-
- return permissions;
- }
-
- /**
- * Determine device type based on screen size and user agent
- */
- private getDeviceType(): 'desktop' | 'mobile' | 'tablet' {
- const userAgent = navigator.userAgent;
-
- if (/tablet|ipad|playbook|silk/i.test(userAgent)) {
- return 'tablet';
- }
-
- if (/mobile|iphone|ipod|android|blackberry|opera|mini|windows\sce|palm|smartphone|iemobile/i.test(userAgent)) {
- return 'mobile';
- }
-
- return 'desktop';
- }
-
- /**
- * Determine connection type based on Network Information API
- */
- private getConnectionType(): 'wifi' | 'cellular' | 'ethernet' | 'unknown' {
- // Check if Network Information API is available
- if ('connection' in navigator) {
- const connection = (navigator as any).connection;
- const effectiveType = connection?.effectiveType;
-
- // Map effective connection types to our categories
- if (effectiveType === 'slow-2g' || effectiveType === '2g' || effectiveType === '3g') {
- return 'cellular';
- }
- if (effectiveType === '4g') {
- return 'cellular';
- }
-
- // Check connection type if available
- const type = connection?.type;
- if (type === 'wifi') return 'wifi';
- if (type === 'ethernet') return 'ethernet';
- if (type === 'cellular') return 'cellular';
- }
-
- return 'unknown';
- }
-
- /**
- * Determine resolution action based on error type
- */
- private getResolutionAction(errorType: string): 'redirect_to_home' | 'redirect_to_login' | 'show_error_page' | 'retry_navigation' | 'none' {
- switch (errorType) {
- case 'route_not_found':
- return 'redirect_to_home';
- case 'guard_rejected':
- case 'permission_denied':
- return 'redirect_to_login';
- case 'resolver_error':
- case 'timeout':
- case 'network_error':
- return 'retry_navigation';
- default:
- return 'show_error_page';
- }
- }
-
- /**
- * Attempt to resolve navigation errors
- */
- private resolveNavigationError(event: NavigationError, errorType: string): void {
- const resolutionStartTime = Date.now();
- const action = this.getResolutionAction(errorType);
-
- switch (action) {
- case 'redirect_to_home':
- this.router.navigate(['/']).then(success => {
- this.trackResolutionResult(event, action, success, resolutionStartTime);
- });
- break;
-
- case 'redirect_to_login':
- this.router.navigate(['/login']).then(success => {
- this.trackResolutionResult(event, action, success, resolutionStartTime);
- });
- break;
-
- case 'retry_navigation':
- // Retry the original navigation after a brief delay
- setTimeout(() => {
- this.router.navigate([event.url]).then(success => {
- this.trackResolutionResult(event, action, success, resolutionStartTime);
- });
- }, 1000);
- break;
-
- default:
- this.trackResolutionResult(event, action, false, resolutionStartTime);
- break;
- }
- }
-
- /**
- * Track the result of navigation error resolution
- */
- private trackResolutionResult(event: NavigationError, action: string, success: boolean, startTime: number): void {
- const resolutionTime = Date.now() - startTime;
-
- // Update the original navigation error event with resolution results
- this.gaSvc.trackEvent('navigation_error', {
- error_type: 'navigation_cancelled',
- error_message: event.error?.message || 'Navigation error resolved',
- error_code: event.error?.name || 'NavigationError',
- attempted_url: event.url,
- source_url: this.previousUrl,
- navigation_method: 'router_link',
- error_timestamp: new Date().toISOString(),
- is_authenticated: !!(this.authSvc.user && this.authSvc.byPUserId),
- user_id: this.authSvc.byPUserId,
- user_role: this.getUserRole(),
- resolution_action: action as any,
- resolution_successful: success,
- resolution_time_ms: resolutionTime
- });
- }
-
- /**
- * Track session start event
- */
- private trackSessionStart(): void {
- // Get current route for entry page
- const entryPage = this.router.url || '/';
-
- // Track session start with required parameters
- this.gaSvc.trackEvent('session_start', {
- platform: 'web',
- user_role: this.getUserRole(),
- entry_page: entryPage,
- referrer: document.referrer || undefined,
- session_id: this.generateSessionId(),
- user_id: this.authSvc.byPUserId
- });
- }
-
- /**
- * Generate a unique session ID
- */
- private generateSessionId(): string {
- return 'sess_' + Date.now().toString(36) + Math.random().toString(36).substr(2);
}
ngOnDestroy() {
diff --git a/Development/client/src/app/app.main.component.html b/Development/client/src/app/app.main.component.html
index fd226f4..f9ec3db 100644
--- a/Development/client/src/app/app.main.component.html
+++ b/Development/client/src/app/app.main.component.html
@@ -28,23 +28,14 @@
-
-
- {{ getExpiryWarningMessage(warning) }}
-
-
+
diff --git a/Development/client/src/app/app.main.component.ts b/Development/client/src/app/app.main.component.ts
index 425b95b..1b87935 100644
--- a/Development/client/src/app/app.main.component.ts
+++ b/Development/client/src/app/app.main.component.ts
@@ -1,22 +1,14 @@
-import { Component, AfterViewInit, ElementRef, ViewChild, OnDestroy, OnInit, NgZone, ChangeDetectorRef, AfterViewChecked } from '@angular/core';
+import { Component, AfterViewInit, ElementRef, ViewChild, OnDestroy, OnInit, NgZone } from '@angular/core';
import { MenuService } from './app.menu.service';
import { AuthService } from './domain/services/auth.service';
-import { ActivatedRoute, Router } from '@angular/router';
+import { Router } from '@angular/router';
import { ConfirmationService } from 'primeng-lts/api';
import { DomSanitizer } from '@angular/platform-browser';
-import { Observable, combineLatest } from 'rxjs';
-import { map } from 'rxjs/operators';
+
import cloneDeep from 'clone-deep';
import { globals } from './shared/global';
import { AppConfigService } from './domain/services/app-config.service';
import { IAppConfig } from './domain/models/appconfig.model';
-import { Compound, FetchLatestSubscriptionSuccess, GotoServices, SetMode } from './actions/subscription.actions';
-import { Mode, SUB } from './profile/common';
-import { Store } from '@ngrx/store';
-import { IMembership, UserModel } from './auth/models/user.model';
-import { ExpiryWarning } from './domain/models/subscription.model';
-import { buildExpiryWarningMessage } from './app.profile.component';
-import * as fromStore from '../../src/app/reducers/index';
enum MenuOrientation {
STATIC,
@@ -29,7 +21,7 @@ enum MenuOrientation {
selector: 'app-main',
templateUrl: './app.main.component.html'
})
-export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, AfterViewChecked {
+export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit {
layoutCompact = true;
@@ -74,46 +66,27 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
rippleMouseDownListener: any;
settings: IAppConfig;
- membership: IMembership;
- user$: Observable;
- expiryWarning$: Observable;
constructor(
+
public readonly zone: NgZone,
private router: Router,
private readonly sanitizer: DomSanitizer,
private readonly menuService: MenuService,
private readonly authSvc: AuthService,
private readonly appConfSvc: AppConfigService,
- private readonly confirmSvc: ConfirmationService,
- private readonly route: ActivatedRoute,
- private readonly store: Store,
- private cdr: ChangeDetectorRef
- ) {
- this.membership = this.route.snapshot.data['membership'];
+ private readonly confirmSvc: ConfirmationService) {
+
this.settings = cloneDeep(this.appConfSvc.settings);
- this.user$ = this.store.select(fromStore.selectAuthUser);
- this.expiryWarning$ = combineLatest([
- this.store.select(fromStore.selectExpiryWarning),
- this.store.select(fromStore.selectNoSubsWarning)
- ]).pipe(map(([expiry, noSubs]) => expiry ?? noSubs));
}
ngOnInit() {
this.zone.runOutsideAngular(() => { this.bindRipple(); });
- if (/*!this.authSvc.isBillable &&*/ !this.settings.noPopup)
+ if (!this.authSvc.isBillable && !this.settings.noPopup)
this.showPaidPopup();
}
- getExpiryWarningMessage(warning: ExpiryWarning): string {
- return buildExpiryWarningMessage(warning);
- }
-
- onNavigateToManageSubscription(): void {
- this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
- }
-
bindRipple() {
this.rippleInitListener = this.init.bind(this);
document.addEventListener('DOMContentLoaded', this.rippleInitListener);
@@ -229,13 +202,6 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
ngAfterViewInit() {
this.layoutContainer = this.layourContainerViewChild.nativeElement as HTMLDivElement;
- if (this.membership) {
- setTimeout(() => this.store.dispatch(new FetchLatestSubscriptionSuccess({ membership: this.membership })), 100);
- }
- }
-
- ngAfterViewChecked() {
- this.cdr.detectChanges();
}
onLayoutClick() {
@@ -417,39 +383,20 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
return this.authSvc.isAdmin;
}
- get isApplicator() {
- return this.authSvc.isApplicator;
- }
-
get shouldShowPaidMsg() {
- return false; // Disable paid notification popup for now
- return (/*!this.authSvc.isBillable && */!this.authSvc.isAdmin && !this.authSvc.isClientUser && !this.authSvc.isInspector);
+ return (!this.authSvc.isBillable && !this.authSvc.isAdmin && !this.authSvc.isClientUser && !this.authSvc.isInspector);
}
showPaidPopup() {
- if (!this.shouldShowPaidMsg) return false; // Skip paid notification popup for now
+ if (!this.shouldShowPaidMsg) return; // Skip paid notification popup for now
- // let msgHtml = $localize`:Paid start time notification popup message@@paidInformMsg:Dear Agmission Customers,
- // Ag-Mission will become a paid service on Saturday, July 15th 2023.
- // Please contact Ag-Nav Inc. at 1-800-99-AGNAV, or email joset@agnav.com at your earliest convenience.
- //
- // Current Ag-Mission users will get the rest of 2023 at the lowest “Unlimited” tier price, and 10% off for 2024; with a special discount for users that subscribe in advance for 2024.
- // For more information, please CLICK HERE to view the features presentation.
- // If you have any questions please do not hesitate to call us or email general@agnav.com
- // `;
-
- let msgHtml = `
- Dear Ag-Mission Users,
- We sincerely apologize for the recent disruptions in accessing Ag-Mission and retrieving data. Our main server in New Jersey has been experiencing intermittent downtime since Monday, and the automatic rollover to the backup server encountered data migration challenges.
- Our team is working diligently to resolve these issues and restore data access. We anticipate the process may take a couple of days to complete. We appreciate your patience and understanding as we work to resolve this matter as quickly as possible.
- Action Required
-
- Update your Platinum units to software version 2.21.3 via the AgNav website to ensure continued functionality and compatibility with AgMission.
- If you’re using Aircraft Job Assignment feature, all aircraft must be activated under Entities in your master account provided by AgNav. (The number of aircraft allowed is based on your selected package).
-
- Thank you for your continued support.
- Best regards,
- Ag-Mission Team
+ let msgHtml = $localize`:Paid start time notification popup message@@paidInformMsg:Dear Agmission Customers,
+ Ag-Mission will become a paid service on Saturday, July 15th 2023.
+ Please contact Ag-Nav Inc. at 1-800-99-AGNAV, or email joset@agnav.com at your earliest convenience.
+
+ Current Ag-Mission users will get the rest of 2023 at the lowest “Unlimited” tier price, and 10% off for 2024; with a special discount for users that subscribe in advance for 2024.
+ For more information, please CLICK HERE to view the features presentation.
+ If you have any questions please do not hesitate to call us or email general@agnav.com
`;
this.confirmSvc.confirm({
@@ -465,26 +412,4 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
}
});
}
-
- canDisplayTrial() {
- // this.membership is populated synchronously by the route resolver, before the
- // NgRx auth store is hydrated (FetchLatestSubscriptionSuccess fires 100ms later).
- // Using it here prevents the banner from flashing on F5 reload for users who
- // already have subscriptions (including trialing ones).
- if (this.membership?.subscriptions?.length > 0) return false;
- return this.authSvc.canDisplayTrial(this.membership?.trials);
- }
-
- accept() {
- if (this.router.url.includes(SUB.SERVICES)) return this.store.dispatch(new SetMode(Mode.TRIALING));
- return this.store.dispatch(new Compound([new SetMode(Mode.TRIALING), new GotoServices()]));
- }
-
- isTrialDays() {
- return this.authSvc.isTrialDays(this.membership?.trials);
- }
-
- canDisplayAcceptTrial() {
- return this.authSvc.canAcceptTrial(this.router.url);
- }
}
\ No newline at end of file
diff --git a/Development/client/src/app/app.menu.component.ts b/Development/client/src/app/app.menu.component.ts
index 1705f8c..720dd1e 100644
--- a/Development/client/src/app/app.menu.component.ts
+++ b/Development/client/src/app/app.menu.component.ts
@@ -1,12 +1,10 @@
import { Component, OnInit } from '@angular/core';
import { AppMainComponent } from './app.main.component';
+
import { AuthService } from './domain/services/auth.service';
import { RoleIds } from './shared/global';
+
import { MenuItem } from 'primeng/api';
-import { Store } from '@ngrx/store';
-import { selectSubLimit } from './reducers';
-import { FetchSubPlans } from './actions/sub-plans.actions';
-import { SubKeys } from './profile/common';
@Component({
selector: 'app-menu',
@@ -17,225 +15,172 @@ import { SubKeys } from './profile/common';
`
})
export class AppMenuComponent implements OnInit {
- model: any[] = [];
+ /**
+ Convention: every root item with children must have routerLink for selected check after page reloaded
+ **/
+ model: any[];
constructor(
+
public readonly app: AppMainComponent,
- private readonly authSvc: AuthService,
- private readonly store: Store<{}>
- ) { }
+ private readonly authSvc: AuthService) {
+
+ }
ngOnInit() {
+ const mItems: MenuItem[] = [
+ { id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] }
+ ];
if (this.authSvc.hasRole([RoleIds.ADMIN])) {
- this.creatAdminMenu()
- } else if (this.authSvc.isPartner) {
- this.createPartnerMenu();
- } else {
- this.createUserMenu();
- }
- }
-
- creatAdminMenu() {
- const mItems: MenuItem[] = [
- { id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
- { id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
- { id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
- { label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
- {
- id: 'settings',
- label: $localize`:@@settings:Settings`, icon: 'settings',
- routerLink: ['/settings'],
- items: [
- { id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] }
- ]
- },
- ];
- this.model = mItems;
- }
-
- createPartnerMenu() {
- const mItems: MenuItem[] = [
- { id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
- {
- id: 'partner-customers',
- label: $localize`:@@partnerCustomers:Partner Customers`,
- icon: 'business',
- routerLink: ['/partner-customers']
- },
- {
- id: 'Help',
- label: $localize`:@@help:Help`, icon: 'help_outline',
- items: [{
- label: $localize`:@@trainingVideos:Training Videos`,
- icon: 'video_library',
- url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
- target: '_blank'
- }]
- }
- ];
- this.model = mItems;
- }
-
- createUserMenu() {
- this.store.select(selectSubLimit).subscribe({
- next: (subLimit) => {
- const hasSubLimit = !!subLimit && (Object.keys(subLimit.package || {}).length > 0 || Object.keys(subLimit.addon || {}).length > 0);
- const mItems: MenuItem[] = [
- { id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] }
- ];
- if (hasSubLimit) {
- this.addSubItems(mItems, subLimit);
- }
- this.model = mItems;
- },
- error: (err) => {
- console.log(err);
- }
- });
- this.store.dispatch(new FetchSubPlans());
- }
-
- private addSubItems(mItems: MenuItem[], subLimit: any) {
- const hasTracking = subLimit?.addon?.[SubKeys.TRACKING]?.airCraft?.numOfVehicle > 0;
- const hasPackage = Object.keys(subLimit.package || {}).length > 0;
- const hasOnlyTracking = !hasPackage && hasTracking;
- const hasOnlyPackage = hasPackage && !hasTracking;
-
- if (hasOnlyTracking) {
- this.addOnlyTrackingItems(mItems);
- } else if (hasOnlyPackage) {
- this.addOnlyPackageItems(mItems);
- } else if (hasPackage && hasTracking) {
- this.addFullAccessItems(mItems);
- }
-
- mItems.push(
- {
- id: 'Help',
- label: $localize`:@@help:Help`, icon: 'help_outline',
- items: [{
- label: $localize`:@@trainingVideos:Training Videos`,
- icon: 'video_library',
- url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
- target: '_blank'
- }]
- }
- )
- }
-
- private addOnlyTrackingItems(mItems: MenuItem[]) {
- if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
mItems.push(
- {
- id: 'entities',
- label: $localize`:@@entities:Entities`, icon: 'library_books',
- routerLink: ['/entities'],
- items: [{ label: $localize`:@@aircraft:Aircraft`, icon: 'airplanemode_active', routerLink: ['/entities/aircraft'] }]
- },
- {
- id: 'tools',
- label: $localize`:@@tools:Tools`, icon: 'extension',
- routerLink: ['/tools'],
+ ...[
+ { id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
+ { label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
+ // { label: $localize`:@@reports:Reports`, icon: 'print', routerLink: ['/reports'] },
+ ]);
+ } else {
+ if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
+ mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] });
+ }
+
+ if (!this.authSvc.isClientUser) {
+ mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] });
+ }
+ mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] });
+
+ if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.PILOT, RoleIds.OFFICER])) {
+ mItems.push({
+ id: 'invoice',
+ label: $localize`:@@invoices:Invoices`, icon: 'receipt_long',
+ routerLink: ['/invoices'],
items: [
- { id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }
]
- }
- );
- }
- if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) {
- mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] });
- }
- }
-
- private addOnlyPackageItems(mItems: MenuItem[]) {
- if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
- mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] });
- }
-
- if (!this.authSvc.isClientUser) {
- mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] });
- }
- mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] });
-
- if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
- this.addEntitiesAndToolsItems(mItems);
- }
-
- this.addInvoiceItems(mItems);
- }
-
- private addFullAccessItems(mItems: MenuItem[]) {
- if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
- mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] });
- }
-
- if (!this.authSvc.isClientUser) {
- mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] });
- }
- mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] });
-
- if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
- this.addEntitiesAndToolsItems(mItems);
- }
- if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) {
- mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] });
- }
-
- this.addInvoiceItems(mItems);
- }
-
- private addEntitiesAndToolsItems(mItems: MenuItem[]) {
- mItems.push(
- {
- id: 'entities',
- label: $localize`:@@entities:Entities`, icon: 'library_books',
- routerLink: ['/entities'],
- items: this.authSvc.isClientUser ?
- [
- { label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] },
- { label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] },
- ]
- : [
- { label: $localize`:@@aircraft:Aircraft`, icon: 'airplanemode_active', routerLink: ['/entities/aircraft'] },
- { label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] },
- { label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] },
- { label: $localize`:@@pilots:Pilots`, icon: 'contacts', routerLink: ['/entities/pilots'] }
- ]
- },
- {
- id: 'tools',
- label: $localize`:@@tools:Tools`, icon: 'extension',
- routerLink: ['/tools'],
- items: [
- { id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
- { id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] },
- { id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }
- ]
+ });
}
- );
+ const invoice = mItems.filter(i => i.id === 'invoice');
+ if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.OFFICER, RoleIds.PILOT])) {
+ invoice[0].items.push(...[
+ { id: 'view', label: $localize`:@@viewInvoice:View Invoices`, icon: 'list', routerLink: ['/invoices'] }
+ ]);
+ }
+ if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
+ invoice[0].items.push(...[
+ { id: 'view', label: $localize`:@@viewEditInvoice:View/Edit Invoices`, icon: 'list', routerLink: ['/invoices'] }
+ ]);
+ }
+ if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT])) {
+ invoice[0].items.push(...[
+ {
+ id: 'costing-item',
+ label: $localize`:@@costingItems:Costing Items`,
+ icon: 'payments',
+ routerLink: ['/invoices/costing-items']
+ },
+ ]);
+ }
+ if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
+ invoice[0].items.push(...[
+ {
+ id: 'settings',
+ label: $localize`:@@invoiceSettings:Invoice Settings`,
+ icon: 'settings',
+ routerLink: ['/invoices/settings']
+ },
+ ]);
+ }
+ if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
+ mItems.push(
+ ...[
+ {
+ id: 'entities',
+ label: $localize`:@@entities:Entities`, icon: 'library_books',
+ routerLink: ['/entities'],
+ items: this.authSvc.isClientUser ?
+ [
+ { label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] },
+ { label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] },
+ ]
+ : [
+ { label: $localize`:@@aircraft:Aircraft`, icon: 'airplanemode_active', routerLink: ['/entities/aircraft'] },
+ { label: $localize`:@@crops:Crops`, icon: 'list', routerLink: ['/entities/crops'] },
+ { label: $localize`:@@products:Products`, icon: 'widgets', routerLink: ['/entities/products'] },
+ { label: $localize`:@@pilots:Pilots`, icon: 'contacts', routerLink: ['/entities/pilots'] }
+ ]
+ },
+ {
+ id: 'tools',
+ label: $localize`:@@tools:Tools`, icon: 'extension',
+ routerLink: ['/tools'],
+ items: [
+ { id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
+ { id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] }
+ ]
+ }
+ ]);
+ }
+
+ if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) {
+ mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] });
+ }
+
+ const tools = mItems.filter(i => i.id === 'tools');
+ if (tools && tools.length) {
+ tools[0].items.push({ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] });
+ }
+
+ }
+ this.model = mItems;
+ // let validLinks = [];
+ // this.getValidLinks(this.router.config, this.router.config[0], validLinks);
+ // this.updateMenuItem(this.model, validLinks);
}
- private addInvoiceItems(mItems: MenuItem[]) {
- if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.OFFICER])) {
- mItems.push({
- id: 'invoice',
- label: $localize`:@@invoices:Invoices`, icon: 'receipt_long',
- routerLink: ['/invoices'],
- items: []
- });
- }
- const invoice = mItems.find(i => i.id === 'invoice');
- if (invoice) {
- if (this.authSvc.hasRole([RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.OFFICER])) {
- invoice.items.push({ id: 'view', label: $localize`:@@viewInvoice:View Invoices`, icon: 'list', routerLink: ['/invoices'] });
+ /*
+ private getValidLinks(routes: Route[], parent: Route, links: string[]) {
+ for (const route of routes) {
+ if (!route.children || route.children.length === 0) {
+ if (!route.data || (route.data && (!route.data.roles || this.authService.hasRole(route.data.roles)))) {
+ if (route.path !== '**') {
+ let combinedPath = parent ? `/${parent.path}/${route.path}` : route.path;
+ combinedPath = combinedPath.replace('/:id', '');
+ if (!combinedPath.startsWith('/')) {
+ combinedPath = '/' + combinedPath;
+ }
+ if (combinedPath.length > 1 && combinedPath.endsWith('/')) {
+ combinedPath = combinedPath.slice(0, combinedPath.length - 1);
+ }
+ links.push(combinedPath);
+ }
+ }
+ } else {
+ this.getValidLinks(route.children, route, links);
+ parent = null;
+ }
}
- if (this.authSvc.hasRole([RoleIds.APP])) {
- invoice.items.push(
- { id: 'view', label: $localize`:@@viewEditInvoice:View/Edit Invoices`, icon: 'list', routerLink: ['/invoices'] },
- { id: 'costing-item', label: $localize`:@@costingItems:Costing Items`, icon: 'payments', routerLink: ['/invoices/costing-items'] },
- { id: 'settings', label: $localize`:@@invoiceSettings:Invoice Settings`, icon: 'settings', routerLink: ['/invoices/settings'] }
- );
- }
- }
}
-}
+
+ private updateMenuItem(mnuItems: MenuItem[], validLinks: string[]) {
+ for (const it of mnuItems) {
+ if (it.items) {
+ // this.updateMenuItem(it.items, validLinks);
+ let visible = false;
+ // it.items.forEach(el => {
+ // if (!el.hasOwnProperty('visible') || el.visible) {
+ // visible = true;
+ // return;
+ // }
+ // });
+ if (!visible) {
+ it.visible = false; // hide menu item which has none sub-items allowed to be shown
+ }
+ } else {
+ if (it.routerLink && it.routerLink.length > 0) {
+ if (validLinks.indexOf(it.routerLink[0]) === -1) {
+ it.visible = false;
+ }
+ }
+ }
+ }
+ }
+ */
+}
\ No newline at end of file
diff --git a/Development/client/src/app/app.module.ts b/Development/client/src/app/app.module.ts
index 8df5c2f..277df52 100644
--- a/Development/client/src/app/app.module.ts
+++ b/Development/client/src/app/app.module.ts
@@ -1,4 +1,4 @@
-import { APP_INITIALIZER, NgModule, TRANSLATIONS, LOCALE_ID, TRANSLATIONS_FORMAT, Injector } from '@angular/core';
+import { NgModule, TRANSLATIONS, LOCALE_ID, TRANSLATIONS_FORMAT, Injector } from '@angular/core';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@@ -10,7 +10,6 @@ import { ButtonModule } from 'primeng/button';
import { MenuModule } from 'primeng/menu';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
-import { DialogModule } from 'primeng/dialog';
import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ToastModule } from 'primeng/toast';
@@ -22,6 +21,7 @@ import { AppMainComponent } from './app.main.component';
import { AppMenuComponent } from './app.menu.component';
import { AppMenuitemComponent } from './app.menuitem.component';
import { AppTopbarComponent } from './app.topbar.component';
+import { AppFooterComponent } from './app.footer.component';
import { AppInlineProfileComponent } from './app.profile.component';
import { DashboardComponent } from './dashboard/dashboard.component';
@@ -52,10 +52,8 @@ import { AppConfigService } from './domain/services/app-config.service';
import { AuthInterceptor } from './domain/services/auth-interceptor.service';
import { SettingsGuard } from './domain/guards/settings-guard.service';
-
+import { LanguageSwicherComponent } from './language-swicher.component';
import { AppEffects } from './effects/app.effects';
-import { SubPlansEffects } from './effects/sub-plans.effects';
-
import { Utils } from './shared/utils';
import { AppInjector } from './app-injector';
import { BaseComp } from './shared/base/base.component';
@@ -67,11 +65,8 @@ import { GlobalModule } from './shared/global.module';
import { HttpCancelService } from './domain/services/httpcancel.service';
import { ManageHttpInterceptor } from './domain/services/managehttp.interceptor.service';
import { InvoiceService } from '@app/domain/services/invoice.service';
+
import '@app/shared/number.extension';
-import { RoutingEffects } from './effects/routing.effects';
-import { SubscriptionEffects } from './effects/subscription.effects';
-import { AppSharedModule } from './shared/app-shared.module';
-import { GlobalErrorInterceptor } from './domain/services/global-error.interceptor';
// Use the require method provided by webpack
declare const require;
@@ -83,11 +78,17 @@ export function translationsFactory(locale: string) {
return require(`raw-loader!../locale/messages.${locale}.xlf`).default;
}
+// export function loadSetting(appInitService: AppConfig) {
+// return (): Promise => {
+// return appInitService.load();
+// }
+// }
+
@NgModule({
imports: [
BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule,
InputTextModule, ButtonModule, MenuModule, ProgressSpinnerModule, ScrollPanelModule,
- MessagesModule, ToastModule, ConfirmDialogModule, DialogModule, DropdownModule, CheckboxModule, AppSharedModule,
+ MessagesModule, ToastModule, ConfirmDialogModule, DropdownModule, CheckboxModule,
// The store that defines our app state
StoreModule.forRoot(reducers, {
metaReducers,
@@ -101,7 +102,7 @@ export function translationsFactory(locale: string) {
// Must instrument after importing StoreModule
StoreDevtoolsModule.instrument({ name: 'AgMission', maxAge: 15, logOnly: environment.production }),
AppRoutingModule,
- EffectsModule.forRoot([AppEffects, SubPlansEffects, RoutingEffects, SubscriptionEffects]),
+ EffectsModule.forRoot([AppEffects]),
],
declarations: [
BaseComp,
@@ -115,10 +116,14 @@ export function translationsFactory(locale: string) {
AppMenuitemComponent,
AppInlineProfileComponent,
AppTopbarComponent,
+ AppFooterComponent,
+ LanguageSwicherComponent,
ReportComponent,
- AppPasswordResetComp
+ AppPasswordResetComp,
],
providers: [
+ // AppConfig,
+ // { provide: APP_INITIALIZER, useFactory: loadSetting, deps: [AppConfig], multi: true },
{
provide: TRANSLATIONS,
useFactory: translationsFactory,
@@ -133,11 +138,6 @@ export function translationsFactory(locale: string) {
useClass: AuthInterceptor,
multi: true
},
- {
- provide: HTTP_INTERCEPTORS,
- useClass: GlobalErrorInterceptor,
- multi: true
- },
{
provide: ActionsSubject, useClass: AppDispatcher
},
@@ -149,8 +149,11 @@ export function translationsFactory(locale: string) {
exports: [],
entryComponents: []
})
-
export class AppModule {
+ // Diagnostic only: inspect router configuration
+ // constructor(router: Router) {
+ // console.log('Routes: ', JSON.stringify(router.config, undefined, 2));
+ // }
constructor(private readonly injector: Injector) {
AppInjector.setInjector(injector);
}
diff --git a/Development/client/src/app/app.profile.component.css b/Development/client/src/app/app.profile.component.css
deleted file mode 100644
index 8a06e77..0000000
--- a/Development/client/src/app/app.profile.component.css
+++ /dev/null
@@ -1,22 +0,0 @@
-.account-summary-info {
- padding-top: 0.5em;
- color: #fff;
- font-size: 0.95rem;
- font-weight: 500;
- text-align: right;
-}
-
-.account-summary-info .account-username {
- margin-right: 0.5em;
-}
-
-.account-summary-info .account-type {
- margin-right: 0.5em;
- font-style: italic;
- opacity: 0.85;
-}
-
-.account-summary-info .account-contact {
- color: #ffd700;
- opacity: 0.9;
-}
diff --git a/Development/client/src/app/app.profile.component.html b/Development/client/src/app/app.profile.component.html
deleted file mode 100644
index c13413b..0000000
--- a/Development/client/src/app/app.profile.component.html
+++ /dev/null
@@ -1,36 +0,0 @@
-
- {{ user.username }}
- {{ getAccountType(user) }}
- ({{ user.contact }})
-
- {{ getWarningMessage() }}
-
-
-
-
- AgMission subscriptions of {{ masterInfo?.name }} are managed by the Master account, please contact:
-
-
- Username
- {{ masterInfo?.username }}
-
-
- Contact
- {{ masterInfo?.contact }}
-
-
- Phone
- {{ masterInfo?.phone }}
-
-
- Email
- {{ masterInfo?.email }}
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Development/client/src/app/app.profile.component.ts b/Development/client/src/app/app.profile.component.ts
index ba72ed8..06c1084 100644
--- a/Development/client/src/app/app.profile.component.ts
+++ b/Development/client/src/app/app.profile.component.ts
@@ -1,138 +1,81 @@
-import { Component, Input, Output, EventEmitter } from '@angular/core';
-import { of } from 'rxjs';
-import { catchError } from 'rxjs/operators';
-import { globals } from './shared/global';
-import { UserModel } from './auth/models/user.model';
-import { UserService } from './domain/services/user.service';
-import { ExpiryWarning } from './domain/models/subscription.model';
+import { AppMainComponent } from './app.main.component';
+import { Component } from '@angular/core';
+import { trigger, state, transition, style, animate } from '@angular/animations';
-export function buildExpiryWarningMessage(expiryWarning: ExpiryWarning | null): string {
- if (!expiryWarning) return '';
-
- if (expiryWarning.noSubs) {
- return $localize`:No subscription warning@@noSubsWarning:No current AgMission service subscribed` +
- ' - ' + $localize`:Renew@@renewLabel:Renew`;
- }
-
- const messages: string[] = [];
- const daysLabel = (days: number) =>
- days === 0
- ? $localize`:Expiring today@@today:today`
- : `${$localize`:In@@in:in`} ${days} ${$localize`:Days@@days:days`}`;
-
- if (expiryWarning.package) {
- const pkg = expiryWarning.package;
- const days = pkg.daysUntilExpiry;
- const willRenew = pkg.willAutoRenew;
- const isTrial = pkg.isTrial;
- const isCanceled = pkg.isCanceled;
-
- if (isCanceled) {
- messages.push(`${pkg.name} ${$localize`:Package canceled@@pkgCanceled:canceled - access ended`} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
- } else if (isTrial) {
- if (willRenew) {
- messages.push(`${pkg.name} ${$localize`:Trial renewing@@pkgTrialRenewing:trial ends`} ${daysLabel(days)} - ${$localize`:Will auto-renew@@willAutoRenew:will Auto-Renew`}`);
- } else {
- messages.push(`${pkg.name} ${$localize`:Trial expiring@@pkgTrialExpiring:trial ends`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
- }
- } else {
- if (willRenew) {
- messages.push(`${pkg.name} ${$localize`:Package renewing@@pkgRenewing:renews`} ${daysLabel(days)}`);
- } else {
- messages.push(`${pkg.name} ${$localize`:Package expiring@@pkgExpiring:expires`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
- }
- }
- }
-
- if (expiryWarning.addons && expiryWarning.addons.length > 0) {
- expiryWarning.addons.forEach(addon => {
- const days = addon.daysUntilExpiry;
- const willRenew = addon.willAutoRenew;
- const isTrial = addon.isTrial;
- const isCanceled = addon.isCanceled;
-
- if (isCanceled) {
- messages.push(`${addon.name} ${$localize`:Addon canceled@@addonCanceled:canceled - access ended`} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
- } else if (isTrial) {
- if (willRenew) {
- messages.push(`${addon.name} ${$localize`:Addon trial renewing@@addonTrialRenewing:trial ends`} ${daysLabel(days)} - ${$localize`:Will auto-renew@@willAutoRenew:will Auto-Renew`}`);
- } else {
- messages.push(`${addon.name} ${$localize`:Addon trial expiring@@addonTrialExpiring:trial ends`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
- }
- } else {
- if (willRenew) {
- messages.push(`${addon.name} ${$localize`:Addon renewing@@addonRenewing:renews`} ${daysLabel(days)}`);
- } else {
- messages.push(`${addon.name} ${$localize`:Addon expiring@@addonExpiring:expires`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
- }
- }
- });
- }
-
- return messages.join('; ');
-}
+import { Store } from '@ngrx/store';
+import * as authActions from './auth/actions/auth.actions';
@Component({
selector: "app-inline-profile",
- templateUrl: "./app.profile.component.html",
- styleUrls: ['./app.profile.component.css']
+ template: `
+
+
+
+ `,
+ animations: [
+ trigger('menu', [
+ state('hidden', style({
+ height: '0px'
+ })),
+ state('visible', style({
+ height: '*'
+ })),
+ transition('visible => hidden', animate('400ms cubic-bezier(0.86, 0, 0.07, 1)')),
+ transition('hidden => visible', animate('400ms cubic-bezier(0.86, 0, 0.07, 1)'))
+ ])
+ ],
})
export class AppInlineProfileComponent {
- readonly globals = globals;
+ active: boolean;
- @Input() user: UserModel;
- @Input() expiryWarning: ExpiryWarning | null;
- @Output() navigateToSubscription = new EventEmitter();
+ constructor(
+ private readonly store: Store<{}>,
+ public readonly app: AppMainComponent) {}
- showMasterPopup = false;
- masterInfo: { username: string; contact?: string; name?: string; phone?: string; email?: string } | null = null;
- private masterInfoFetchedAt: number | null = null;
- private readonly MASTER_INFO_TTL_MS = 2 * 60 * 1000; // re-fetch after 2 minutes
-
- constructor(readonly userSvc: UserService) { }
-
- getAccountType(user: UserModel): string {
- return this.userSvc.getAccountType(user);
+ onClick(event) {
+ this.active = !this.active;
+ // setTimeout(() => {
+ // this.app.layoutMenuScrollerViewChild.moveBar();
+ // }, 450);
+ event.preventDefault();
}
- getWarningMessage(): string {
- return buildExpiryWarningMessage(this.expiryWarning);
- }
-
- onWarningClick(): void {
- // Always navigate to subscription for all accounts
- this.navigateToSubscription.emit();
- // Show master-account info popup only for sub-accounts:
- // skip if no parent, or parent is the same as this user (self-referencing master)
- const parentId = this.user?.parent;
- if (!parentId || parentId === this.user._id) return;
-
- const now = Date.now();
- const isFresh = this.masterInfoFetchedAt !== null && (now - this.masterInfoFetchedAt) < this.MASTER_INFO_TTL_MS;
- if (isFresh) {
- this.showMasterPopup = true;
- return;
- }
- this.userSvc.getUser(parentId, { view: 'profile' }).pipe(
- catchError(() => of(null))
- ).subscribe(master => {
- if (master) {
- this.masterInfo = {
- username: master.username ?? '',
- contact: master.contact,
- name: master.name,
- phone: master.phone,
- email: master.email,
- };
- } else {
- // Fallback: show whatever the parent field holds (may be a populated object)
- const p = this.user.parent;
- this.masterInfo = {
- username: (typeof p === 'object' && p?.username) ? p.username : '',
- };
- }
- this.masterInfoFetchedAt = Date.now();
- this.showMasterPopup = true;
- });
+ switchProfile() {}
+ onLogout(e) {
+ this.store.dispatch(new authActions.Logout());
+ e.preventDefault();
}
}
diff --git a/Development/client/src/app/app.topbar.component.html b/Development/client/src/app/app.topbar.component.html
deleted file mode 100644
index 95c5e7f..0000000
--- a/Development/client/src/app/app.topbar.component.html
+++ /dev/null
@@ -1,68 +0,0 @@
-
\ No newline at end of file
diff --git a/Development/client/src/app/app.topbar.component.ts b/Development/client/src/app/app.topbar.component.ts
index 1774ba8..9102ada 100644
--- a/Development/client/src/app/app.topbar.component.ts
+++ b/Development/client/src/app/app.topbar.component.ts
@@ -1,89 +1,91 @@
-import { Component, OnInit, OnDestroy } from '@angular/core';
+import { Component, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
-import { Observable, Subscription, combineLatest } from 'rxjs';
-import { first, filter, switchMap, map } from 'rxjs/operators';
+
+import { Subscription } from 'rxjs';
import { Store } from '@ngrx/store';
+
import { AppMainComponent } from './app.main.component';
+
import * as authActions from './auth/actions/auth.actions';
+
import * as fromStore from '../../src/app/reducers/index';
-import { UserModel } from './auth/models/user.model';
-import { ExpiryWarning } from './domain/models/subscription.model';
-import { SUB } from './profile/common';
-import { UserService } from './domain/services/user.service';
@Component({
selector: 'app-topbar',
- templateUrl: './app.topbar.component.html'
+ template: `
+
+ `,
})
-export class AppTopbarComponent implements OnInit, OnDestroy {
- user$: Observable;
- expiryWarning$: Observable;
- private sub$ = new Subscription();
+export class AppTopbarComponent implements OnDestroy {
+ _user: any;
+ private sub$: Subscription;
+ private user$ = this.store.select(fromStore.selectAuthUser);
constructor(
public readonly app: AppMainComponent,
private readonly store: Store<{}>,
- private readonly router: Router,
- private readonly userSvc: UserService
+ private router: Router
) {
- this.user$ = this.store.select(fromStore.selectAuthUser);
- this.expiryWarning$ = combineLatest([
- this.store.select(fromStore.selectExpiryWarning),
- this.store.select(fromStore.selectNoSubsWarning)
- ]).pipe(map(([expiry, noSubs]) => expiry ?? noSubs));
- }
-
- ngOnInit(): void {
- // Fetch fresh user data from server on component init (page load/reload)
- // This ensures header displays current data even if changed externally
- this.sub$.add(
- this.user$.pipe(
- first(), // Only run once on init
- filter(user => !!user && !!user._id), // Only if user exists
- switchMap(user => this.userSvc.getUser(user._id, { view: 'profile' }))
- ).subscribe(freshUser => {
- if (freshUser) {
- this.store.dispatch(new authActions.RefreshUserData({
- user: this.mapUserToUserModel(freshUser)
- }));
- }
- })
- );
- }
-
- ngOnDestroy(): void {
- this.sub$.unsubscribe();
- }
-
- /**
- * Map User (from API) to UserModel (for store)
- * Only maps fields that should be refreshed from server
- */
- private mapUserToUserModel(user: any): UserModel {
- return {
- _id: user._id,
- name: user.name || '',
- username: user.username || '',
- roles: user.roles || [],
- parent: user.parent || '',
- lang: user.lang || 'en',
- pre: user.pre || 0,
- billable: user.billable,
- membership: user.membership,
- contact: user.contact || ''
- };
- }
-
- manageServices() {
- return this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
- }
-
- manageBilling() {
- this.router.navigate([SUB.PROFILE, SUB.PM_HISTORY]);
- }
-
- manageContact(user) {
- this.router.navigate([SUB.PROFILE, SUB.BILL_ADR_LIST]);
+ this.sub$ = this.user$.subscribe((user) => (this._user = user));
}
onLogout(e) {
@@ -91,15 +93,19 @@ export class AppTopbarComponent implements OnInit, OnDestroy {
e.preventDefault();
}
- updateUserProfile(userId: string) {
- this.router.navigate([SUB.PROFILE, 'edit', userId]);
+ updateUserProfile() {
+ this.router.navigate(['profile', this._user._id]);
}
- /**
- * Navigate to manage subscription page
- * Triggered by subscription expiry notification click
- */
- onNavigateToManageSubscription(): void {
- this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
+ manageServices() {
+ this.router.navigate(['profile/myservices', this._user._id]);
+ }
+
+ manageBilling() {
+ this.router.navigate(['profile/mybills', this._user._id]);
+ }
+
+ ngOnDestroy(): void {
+ if (this.sub$) this.sub$.unsubscribe();
}
}
diff --git a/Development/client/src/app/auth/actions/auth.actions.ts b/Development/client/src/app/auth/actions/auth.actions.ts
index 68d3c32..aaa4fd2 100644
--- a/Development/client/src/app/auth/actions/auth.actions.ts
+++ b/Development/client/src/app/auth/actions/auth.actions.ts
@@ -1,7 +1,6 @@
import { Action } from '@ngrx/store';
import { Authenticate } from '../models/auth.model';
import { UserModel } from '../models/user.model';
-import { Plan } from '@app/domain/models/subscription.model';
export const LOGIN = '[Login Page] Login';
export class Login implements Action {
@@ -34,14 +33,7 @@ export class Logout implements Action {
export const LOGOUT_COMPLETE = '[Auth API] Logout Complete';
export class LogoutComplete implements Action {
readonly type: typeof LOGOUT_COMPLETE = LOGOUT_COMPLETE;
-
-}
-
-export const REFRESH_USER_DATA = '[Auth] Refresh User Data';
-export class RefreshUserData implements Action {
- readonly type: typeof REFRESH_USER_DATA = REFRESH_USER_DATA;
-
- constructor(public payload: { user: UserModel }) { }
+
}
export type All =
@@ -49,5 +41,4 @@ export type All =
| LoginSuccess
| LoginFailed
| Logout
- | LogoutComplete
- | RefreshUserData;
+ | LogoutComplete;
diff --git a/Development/client/src/app/auth/effects/auth.effects.ts b/Development/client/src/app/auth/effects/auth.effects.ts
index bf2d87d..3a3cfa3 100644
--- a/Development/client/src/app/auth/effects/auth.effects.ts
+++ b/Development/client/src/app/auth/effects/auth.effects.ts
@@ -3,13 +3,16 @@ import { Router } from '@angular/router';
import { of } from 'rxjs';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { map, exhaustMap, catchError, tap } from 'rxjs/operators';
+
import { Store } from '@ngrx/store';
import * as authActions from '../actions/auth.actions';
import * as clientActions from '@app/client/actions/client.actions';
+
import { ClientService } from '@app/domain/services/client.service';
import { AuthService } from '@app/domain/services/auth.service';
import { globals } from '@app/shared/global';
+
@Injectable()
export class AuthEffects {
@Effect()
@@ -17,17 +20,17 @@ export class AuthEffects {
.pipe(
ofType(authActions.LOGIN),
map(action => action.payload),
- exhaustMap(auth => {
- return this.authSvc.login(auth).pipe(
+ exhaustMap(auth =>
+ this.authSvc.login(auth).pipe(
map(user => {
- return new authActions.LoginSuccess({ user })
+ return new authActions.LoginSuccess({ user: user });
}),
catchError(err => {
const errTag = (err.error && err.error.error) ? err.error.error['.tag'] : err.message || '';
return of(new authActions.LoginFailed(globals.apiErrorMsg(errTag)));
}),
- )
- })
+ ),
+ ),
);
@Effect({ dispatch: false })
@@ -50,9 +53,8 @@ export class AuthEffects {
private navigateDefault(lang) {
const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/';
- const returnUrl = this.router.parseUrl(this.router.url).queryParams['returnUrl'] || 'home';
- // Replace the current page with the next target url => prevent Back to previous
- window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + returnUrl);
+ // Replace the current page with the next target url => prevent Back to previous
+ window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + 'home');
}
@Effect()
diff --git a/Development/client/src/app/auth/login/login.component.html b/Development/client/src/app/auth/login/login.component.html
index fa5e38a..196a5b6 100644
--- a/Development/client/src/app/auth/login/login.component.html
+++ b/Development/client/src/app/auth/login/login.component.html
@@ -12,11 +12,8 @@
-
-
+
+
{{ userValidMsg() }}
Username
@@ -24,29 +21,22 @@
-
- Password
- is required
+
+ Password is required
Password
-
+
- You must complete the reCAPTCHA to log in.
+ You must complete the reCAPTCHA to log in.
-
\ No newline at end of file
diff --git a/Development/client/src/app/auth/login/login.component.ts b/Development/client/src/app/auth/login/login.component.ts
index dd95a1c..7c705a9 100644
--- a/Development/client/src/app/auth/login/login.component.ts
+++ b/Development/client/src/app/auth/login/login.component.ts
@@ -1,7 +1,5 @@
import { Component, OnInit, OnDestroy, ViewChild, isDevMode } from '@angular/core';
import { ReCaptcha2Component } from 'ngx-captcha';
-import { Subject } from 'rxjs';
-import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { Authenticate } from '../models/auth.model';
import * as authActions from '../actions/auth.actions';
@@ -38,57 +36,23 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy {
public captchaSuccess = false;
private _lastVerReqAt: number = 0;
- // Debounced validation to prevent flash error on Chrome autofill
- public showUsernameError = false;
- public showPasswordError = false;
- private usernameValidation$ = new Subject();
- private passwordValidation$ = new Subject();
-
constructor(
) {
super();
this['name'] = "LoginComp";
- const nav = this.router.getCurrentNavigation();
- if (nav) {
- const msgs: any[] = [];
- const state = nav.extras?.state;
- if (state?.changedPwd) {
- msgs.push({ severity: 'info', summary: '', detail: globals.pwdChangedOk });
+ if (this.router.getCurrentNavigation()) {
+ const routeSate = this.router.getCurrentNavigation().extras && this.router.getCurrentNavigation().extras.state;
+ if (routeSate && routeSate.changedPwd) {
+ this.msgs = [{ severity: 'info', summary: '', detail: globals.pwdChangedOk }];
}
- const returnUrl = nav.finalUrl?.queryParams?.['returnUrl'] ?? nav.extractedUrl?.queryParams?.['returnUrl'];
- const loginNotice = nav.finalUrl?.queryParams?.['loginNotice'] ?? nav.extractedUrl?.queryParams?.['loginNotice'];
- if (loginNotice) {
- msgs.push({ severity: 'info', summary: '', detail: loginNotice });
- }
- if (msgs.length) this.msgs = msgs;
}
}
ngOnInit() {
this.lang = this.authSvc.locale;
- // Debounce username validation by 100ms to handle Chrome autofill race condition
- this.sub$.add(
- this.usernameValidation$.pipe(
- debounceTime(100),
- distinctUntilChanged()
- ).subscribe(showError => {
- this.showUsernameError = showError;
- })
- );
-
- // Debounce password validation by 100ms to handle Chrome autofill race condition
- this.sub$.add(
- this.passwordValidation$.pipe(
- debounceTime(100),
- distinctUntilChanged()
- ).subscribe(showError => {
- this.showPasswordError = showError;
- })
- );
-
this.useReCaptcha && (
this.sub$.add(this.appActions.ofTypes([authActions.LOGIN_FAILED]).subscribe(action => {
this.captchaElem.resetCaptcha();
@@ -109,22 +73,6 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy {
return StringUtils.isEmpty(this.model.username) ? globals.usernameReqVal : globals.usernameInvalidVal;
}
- /**
- * Emits username validation state with debounce to prevent flash on Chrome autofill.
- * Called on input and blur events.
- */
- onUsernameValidation(invalid: boolean, dirty: boolean, touched: boolean) {
- this.usernameValidation$.next(invalid && (dirty || touched));
- }
-
- /**
- * Emits password validation state with debounce to prevent flash on Chrome autofill.
- * Called on input and blur events.
- */
- onPasswordValidation(invalid: boolean, dirty: boolean, touched: boolean) {
- this.passwordValidation$.next(invalid && (dirty || touched));
- }
-
handleSuccess(captchaResp: string): void {
// Verify user reponse token with server side within 2 minutes according to GG Ref: https://developers.google.com/recaptcha/docs/verify
this._lastVerReqAt = Date.now();
diff --git a/Development/client/src/app/auth/models/user.model.ts b/Development/client/src/app/auth/models/user.model.ts
index 0f9bc3b..5671e6c 100644
--- a/Development/client/src/app/auth/models/user.model.ts
+++ b/Development/client/src/app/auth/models/user.model.ts
@@ -1,5 +1,3 @@
-import { AGNavSubscription, Trial } from "@app/domain/models/subscription.model";
-
export interface UserModel {
_id: string;
name: string;
@@ -9,19 +7,11 @@ export interface UserModel {
lang: string;
pre: number;
billable?: boolean;
- membership?: IMembership,
- contact: string;
- country?: string;
- partner?: string;
+ membership?: IMembership
}
export interface IMembership {
- custId: string;
- endOfPeriod?: Number;
- subscriptions?: AGNavSubscription[];
- trials?: Trial;
- customLimits?: {
- maxVehicles?: number | null;
- maxAcres?: number | null;
- };
-}
+ status: string;
+ endPeriod: number,
+ subTier: string; // 'essential', 'enterprise'
+}
\ No newline at end of file
diff --git a/Development/client/src/app/billing/usage-list/usage-list.component.html b/Development/client/src/app/billing/usage-list/usage-list.component.html
index 3f87983..0ba7a9c 100644
--- a/Development/client/src/app/billing/usage-list/usage-list.component.html
+++ b/Development/client/src/app/billing/usage-list/usage-list.component.html
@@ -3,7 +3,7 @@
@@ -23,7 +23,7 @@
- Customer Spray Overview
+ Customer Spray Overview
@@ -57,12 +57,12 @@
- Total Spray
+ Total Spray
{{totals[col.field]}}
- {{totals[col.field] | number:'1.1-1':'en'}} ha
- {{haToAcres(totals[col.field]) | number:'1.1-1':'en'}} ac
+ {{totals[col.field] | number:'1.1-1':'en'}} ha
+ {{haToAcres(totals[col.field]) | number:'1.1-1':'en'}} ac
diff --git a/Development/client/src/app/client/client.module.ts b/Development/client/src/app/client/client.module.ts
index fcce23c..f325a52 100644
--- a/Development/client/src/app/client/client.module.ts
+++ b/Development/client/src/app/client/client.module.ts
@@ -17,7 +17,7 @@ import { ToastModule } from 'primeng/toast';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { ClientEffects } from './effects/client.effects';
-import * as fromClients from './reducers/clients.reducer';
+import * as fromClients from './reducers/clients-reducer';
import { ClientListComponent } from './client-list/client-list.component';
import { ClientsRoutingModule } from './client-routing.module';
diff --git a/Development/client/src/app/client/effects/client.effects.ts b/Development/client/src/app/client/effects/client.effects.ts
index eeaf86c..a49a95d 100644
--- a/Development/client/src/app/client/effects/client.effects.ts
+++ b/Development/client/src/app/client/effects/client.effects.ts
@@ -25,7 +25,7 @@ export class ClientEffects {
loadClients$: Observable = this.actions$.pipe(
ofType(clientActions.FETCH),
switchMap(() =>
- this.clientSvc.loadClients({ byPuid: this.authSvc.user.parent }).pipe(
+ this.clientSvc.loadClients({ byUserId: this.authSvc.user.parent }).pipe(
map(clients => new clientActions.FetchSuccess(clients)),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients));
diff --git a/Development/client/src/app/client/reducers/clients.reducer.ts b/Development/client/src/app/client/reducers/clients-reducer.ts
similarity index 100%
rename from Development/client/src/app/client/reducers/clients.reducer.ts
rename to Development/client/src/app/client/reducers/clients-reducer.ts
diff --git a/Development/client/src/app/client/reducers/index.ts b/Development/client/src/app/client/reducers/index.ts
index 9bbdd23..7ae8906 100644
--- a/Development/client/src/app/client/reducers/index.ts
+++ b/Development/client/src/app/client/reducers/index.ts
@@ -3,64 +3,31 @@ import {
createFeatureSelector,
} from '@ngrx/store';
-import * as fromClients from './clients.reducer';
+import * as fromClients from './clients-reducer';
export const getClientsState = createFeatureSelector(fromClients.FEATURE_KEY);
-// Safe wrapper to handle undefined state during lazy module loading
-export const getClientsStateOrInitial = createSelector(
- getClientsState,
- (state) => {
- if (!state) {
- return {
- ids: [],
- entities: {},
- loading: false,
- loaded: false,
- selectedId: null
- };
- }
- return state;
- }
-);
-
export const getSelectedClientId = createSelector(
- getClientsStateOrInitial,
+ getClientsState,
fromClients.getSelectedId
);
export const isLoading = createSelector(
- getClientsStateOrInitial,
+ getClientsState,
fromClients.getIsLoading
);
export const isLoaded = createSelector(
- getClientsStateOrInitial,
+ getClientsState,
fromClients.getIsLoaded
);
-// Entity selectors wrapped for safety during lazy loading
-const entitySelectors = fromClients.adapter.getSelectors(getClientsStateOrInitial);
-
-export const getClientsIds = createSelector(
- entitySelectors.selectIds,
- (ids) => ids || []
-);
-
-export const getClientEntities = createSelector(
- entitySelectors.selectEntities,
- (entities) => entities || {}
-);
-
-export const getAllClients = createSelector(
- entitySelectors.selectAll,
- (clients) => clients || []
-);
-
-export const getTotalClients = createSelector(
- entitySelectors.selectTotal,
- (total) => total || 0
-);
+export const {
+ selectIds: getClientsIds,
+ selectEntities: getClientEntities,
+ selectAll: getAllClients,
+ selectTotal: getTotalClients,
+} = fromClients.adapter.getSelectors(getClientsState);
export const getSelectedClient = createSelector(
getClientEntities,
diff --git a/Development/client/src/app/customers/customer-edit/customer-edit.component.css b/Development/client/src/app/customers/customer-edit/customer-edit.component.css
index d196689..e69de29 100644
--- a/Development/client/src/app/customers/customer-edit/customer-edit.component.css
+++ b/Development/client/src/app/customers/customer-edit/customer-edit.component.css
@@ -1,128 +0,0 @@
-.theme-color {
- color: #4caf50;
-}
-
-ul {
- padding-inline-start: 20px;
-}
-
-/* Partner Selection Integration Styles */
-
-.partner-option {
- display: flex;
- align-items: center;
- padding: 8px 0;
-}
-
-.partner-info {
- display: flex;
- flex-direction: column;
-}
-
-.partner-name {
- font-weight: 500;
- color: #333;
-}
-
-.partner-description {
- font-size: 0.85em;
- color: #666;
- margin-top: 2px;
-}
-
-.partner-selected {
- display: flex;
- align-items: center;
-}
-
-.partner-config-section {
- margin-top: 20px;
- padding: 20px;
- border: 1px solid #dee2e6;
- border-radius: 4px;
- background-color: #f8f9fa;
-}
-
-.partner-config-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 15px;
-}
-
-.partner-config-header h3 {
- margin: 0;
- color: #333;
- font-size: 1.1em;
-}
-
-.partner-loading {
- display: flex;
- align-items: center;
- color: #007bff;
- padding: 10px;
- background-color: #e7f3ff;
- border: 1px solid #b3d9ff;
- border-radius: 4px;
- margin-bottom: 15px;
-}
-
-.partner-error {
- display: flex;
- align-items: center;
- color: #dc3545;
- padding: 10px;
- background-color: #f8d7da;
- border: 1px solid #f5c6cb;
- border-radius: 4px;
- margin-bottom: 15px;
-}
-
-.satloc-config-section {
- padding: 15px;
- background-color: #ffffff;
- border: 1px solid #ddd;
- border-radius: 4px;
-}
-
-.config-description {
- margin-bottom: 15px;
- color: #666;
- font-size: 0.9em;
-}
-
-.config-placeholder {
- display: flex;
- align-items: center;
- padding: 15px;
- background-color: #e8f4fd;
- border: 1px solid #b3d9ff;
- border-radius: 4px;
- color: #0c5aa6;
-}
-
-/* Common label span for form fields */
-.form-label-span {
- margin-right: 12px;
-}
-
-/* Responsive Design */
-@media (max-width: 768px) {
- .partner-config-header {
- flex-direction: column;
- align-items: flex-start;
- gap: 10px;
- }
-
- .partner-option {
- padding: 12px 0;
- }
-
- .partner-config-section {
- padding: 15px;
- }
-}
-
-.partner-selected>span {
- font-weight: 600;
-}
\ No newline at end of file
diff --git a/Development/client/src/app/customers/customer-edit/customer-edit.component.html b/Development/client/src/app/customers/customer-edit/customer-edit.component.html
index 575b5cb..8ef753f 100644
--- a/Development/client/src/app/customers/customer-edit/customer-edit.component.html
+++ b/Development/client/src/app/customers/customer-edit/customer-edit.component.html
@@ -5,16 +5,14 @@
-
-
-
- {{SubTexts.lastTrial}}
-
-
- {{SubTexts.lastStartDate}}: {{toTimestamp(trials.lastStartDate) | tsToDate: lang}}
- {{SubTexts.lastEndDate}}: {{toTimestamp(trials.lastEndDate) | tsToDate: lang}}
-
-
-
-
-
-
- {{SubTexts.labelSub}}
-
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/Development/client/src/app/customers/customer-edit/customer-edit.component.ts b/Development/client/src/app/customers/customer-edit/customer-edit.component.ts
index 5eaba87..e9f6d8e 100644
--- a/Development/client/src/app/customers/customer-edit/customer-edit.component.ts
+++ b/Development/client/src/app/customers/customer-edit/customer-edit.component.ts
@@ -1,45 +1,33 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FormGroup, FormBuilder } from '@angular/forms';
+
+
+
import { SelectItem } from 'primeng/api';
-import { Customer, Partner } from '../models/customer.model';
+
+import { Customer } from '../models/customer.model';
import * as customerActions from '../actions/customer.actions';
+
import { UserService } from '@app/domain/services/user.service';
-import { PartnerService } from '@app/partners/services/partner.service';
import { BaseComp } from '@app/shared/base/base.component';
-import { GC, RoleIds, globals, Labels } from '@app/shared/global';
-import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model';
-import { SubStripe, SubTexts } from '@app/profile/common';
-import { IMembership } from '@app/auth/models/user.model';
-import { DateUtils } from '@app/shared/utils';
+import { RoleIds, globals } from '@app/shared/global';
@Component({
selector: 'agm-customer-edit',
templateUrl: './customer-edit.component.html',
styleUrls: ['./customer-edit.component.css']
})
-export class CustomerEditComponent extends BaseComp implements OnInit {
+export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy {
readonly globals = globals;
- readonly SubTexts = SubTexts;
- readonly Labels = Labels;
form: FormGroup;
selectedItem: Customer;
+
premiumLevels: SelectItem[];
+
msgs = [];
- trialDays: number[];
- trialSubs: AGNavSubscription[];
- paidSubs: AGNavSubscription[];
- trials: Trial;
- membership: IMembership;
- lang;
-
- // Partner Selection Properties
- partnerOptions: SelectItem[] = [];
- partnerLoading = false;
- partnerError: string | null = null;
-
private _customer: Customer;
get customer(): Customer { return this._customer; }
set customer(customer: Customer) {
@@ -50,12 +38,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
account: { active: this.selectedItem.active, username: this.selectedItem.username, password: this.selectedItem.password },
premium: this.selectedItem.premium,
billable: this.selectedItem.billable,
- trials: this.selectedItem.membership?.trials,
- partner: this.selectedItem.partner || null
});
-
- // Set partner selection based on customer.partner field, or null if not set
- // Form control will be updated by loadPartners() method
}
private _isNew: boolean;
@@ -66,8 +49,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
constructor(
private readonly route: ActivatedRoute,
private readonly userSvc: UserService,
- private readonly partnerSvc: PartnerService,
- private readonly fb: FormBuilder
+
+ private readonly fb: FormBuilder,
) {
super();
this.premiumLevels = [
@@ -81,13 +64,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
account: [],
premium: [],
billable: [],
- trials: [],
- // Partner form control
- partner: [null]
});
- this.lang = this.authSvc.locale;
-
-
}
ngOnInit() {
@@ -97,86 +74,23 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
if (customer) {
this._isNew = (customer._id === '0');
this.customer = customer;
- this.membership = this.customer?.membership;
-
- if (this.membership) {
- this.trials = this.membership.trials;
- this.trialSubs = this.membership.subscriptions?.filter((sub) => sub.status === SubStripe.TRIALING) || [];
- this.paidSubs = this.membership.subscriptions?.filter((sub) => sub.status !== SubStripe.TRIALING) || [];
- }
- // Load partners from service
- this.loadPartners();
}
});
-
this.sub$.add(this.appActions.ofTypes([customerActions.CREATE_SUCCESS, customerActions.UPDATE_SUCCESS])
.subscribe((action) => {
this.store.dispatch(new customerActions.Select(action['payload']));
this.goBack();
}));
-
- this.trialDays = this.appConf.settings.trialDays;
}
- hasLastEndedTrial() {
- return this.authSvc.hasLastEndedTrial(this.trials);
- }
-
- hasPaidSubs() {
- return this.paidSubs?.length > 0;
- }
-
- hasTrialSubs() {
- return this.trialSubs?.length > 0;
- }
saveCustomer() {
if (!this.form || !this.form.value || !this.form.valid) return;
+
this.msgs = [];
- let custObj;
-
- const updateTrialMembship = (membership?) => {
- // Get trials value from form control (includes disabled controls via ControlValueAccessor)
- const trialsControl = this.form.get('trials');
- const trialsValue = trialsControl ? trialsControl.value : null;
-
- if (trialsValue?.selected) {
- const trials: Trial = { ...trialsValue };
- delete trials.selected;
-
- // If type is null, but trialDays or byDate exist, set type accordingly
- if (trials.type == null) {
- if (trials.trialDays && trials.trialDays > 0) {
- trials.type = GC.DAYS;
- } else if (trials.byDate) {
- trials.type = GC.BYDATE;
- }
- }
- if (trials.type === GC.BYDATE) {
- trials.trialDays = 0;
- } else {
- trials.byDate = null;
- }
- trials.startDate = DateUtils.tsToDate(DateUtils.currUTC());
- return membership
- ? { ...membership, trials }
- : { trials };
- } else {
- return membership
- ? { ...membership, trials: { ...membership.trials, type: null } }
- : { trials: { type: null } };
- }
- }
-
- custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account,
- { premium: this.form.value.premium || false },
- { billable: this.form.value.billable || false },
- { partner: this.form.value.partner || null });
-
- this.membership
- ? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) })
- : custObj = Object.assign(custObj, { membership: updateTrialMembship() });
+ const custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account,
+ { premium: this.form.value.premium || false }, { billable: this.form.value.billable || false });
this.store.dispatch(this._isNew ? new customerActions.Create(custObj) : new customerActions.Update(custObj));
}
@@ -205,64 +119,6 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
this.router.navigate(['../', { id: this.customer._id }]);
}
- toTimestamp(date: Date): number {
- return DateUtils.dateToTS(date);
- }
-
- // Partner Methods
- private loadPartners(): void {
- this.partnerLoading = true;
- this.partnerError = null;
-
- this.partnerSvc.getPartners().subscribe({
- next: (partners: Partner[]) => {
- // Create dropdown options starting with "None" option for AgNav direct customers
- this.partnerOptions = [
- {
- label: Labels.NONE_AGNAV_DIRECT_CUSTOMER,
- value: null // null value indicates AgNav direct customer
- },
- // Add active partners
- ...partners
- .filter(partner => partner.active) // Only show active partners
- .map(partner => ({
- label: partner.name,
- value: partner
- }))
- ];
-
- // Set selectedPartner based on existing customer partner
- if (this.customer?.partner && this.partnerOptions.length > 0) {
- // Find the partner in options that matches the customer's current partner _id
- const matchingOption = this.partnerOptions.find(option =>
- option.value && option.value._id === this.customer.partner._id
- );
- if (matchingOption) {
- this.form.patchValue({ partner: matchingOption.value });
- }
- } else if (!this.customer?.partner) {
- // If no partner is set, default to "None" (AgNav direct customer)
- this.form.patchValue({ partner: null });
- }
- this.partnerLoading = false;
- },
- error: (error) => {
- this.partnerError = Labels.FAILED_TO_LOAD_PARTNERS;
- this.partnerLoading = false;
- console.error('Error loading partners:', error);
- }
- });
- }
-
- onPartnerChange(selectedPartner: Partner | null): void {
- this.partnerError = null;
-
- // Update customer partner field
- if (this.customer) {
- this.customer.partner = selectedPartner;
- }
- }
-
ngOnDestroy() {
super.ngOnDestroy();
}
diff --git a/Development/client/src/app/customers/customer-list/customer-list.component.html b/Development/client/src/app/customers/customer-list/customer-list.component.html
index 6db1445..c0ddee0 100644
--- a/Development/client/src/app/customers/customer-list/customer-list.component.html
+++ b/Development/client/src/app/customers/customer-list/customer-list.component.html
@@ -3,15 +3,7 @@
-
-
- Customer List
-
-
-
Self Signup Accounts {{ isSelfSignup ? 'On' : 'Off' }}
-
-
-
+ Customer List
@@ -26,32 +18,26 @@
-
-
-
-
+
-
-
-
-
- {{col.header}}
- {{rowData[col.field] | date:'shortDate'}}
-
-
-
-
-
-
- {{rowData[col.field]?.name}}
- {{rowData[col.field]}}
+
+
+ {{cust.name}}
+ {{cust.username}}
+ {{cust.contact}}
+ {{cust.totalJobs}}
+ {{cust.createdAt | date:'shortDate' }}
+
+
+
+
+
-
{{ state.totalRecords | i18nPlural: totalItems }}
diff --git a/Development/client/src/app/customers/customer-list/customer-list.component.ts b/Development/client/src/app/customers/customer-list/customer-list.component.ts
index e023601..f856f6f 100644
--- a/Development/client/src/app/customers/customer-list/customer-list.component.ts
+++ b/Development/client/src/app/customers/customer-list/customer-list.component.ts
@@ -7,7 +7,7 @@ import { Table } from 'primeng/table';
import { Customer } from '../models/customer.model';
import * as fromCustomers from '../reducers';
import * as customerActions from '../actions/customer.actions';
-import { globals, OperationalStatus } from '@app/shared/global';
+import { globals } from '@app/shared/global';
import { BaseComp } from '@app/shared/base/base.component';
@@ -17,26 +17,19 @@ import { BaseComp } from '@app/shared/base/base.component';
styleUrls: ['./customer-list.component.css']
})
export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy {
- readonly CREATED = 'createdAt';
- readonly ACTIVE = OperationalStatus.ACTIVE;
- readonly BILLABLE = 'billable';
- readonly PARTNER = 'partner';
- readonly PARTNER_NAME = 'partnerName';
customers: Array;
curCust: Customer;
@ViewChild("dt") dt: Table;
- statuses: SelectItem[];
- partners: SelectItem[];
+ statuses: SelectItem[];
cols: any[];
totalItems;
- isSelfSignup = false;
constructor(
private readonly route: ActivatedRoute,
-
+
) {
super();
this.totalItems = { '=0': '', '=1': '1 ' + $localize`:@@customer:customer`.toLocaleLowerCase(), 'other': $localize`:@@total#Customers:Total: # customers` };
@@ -51,52 +44,23 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
{ field: "username", header: globals.userName, filtered: true, filterMatchMode: 'contains' },
{ field: "contact", header: globals.contact },
{ field: "totalJobs", header: globals.jobs, width: '5%', filtered: false },
- { field: this.CREATED, header: globals.from, width: '6%' },
- { field: this.BILLABLE, header: "Billable", width: '9%' },
- { field: this.ACTIVE, header: globals.active, width: '9%' },
- { field: this.PARTNER_NAME, header: globals.partner, width: '9%' }
+ { field: "createdAt", header: globals.from, width: '6%' },
+ // { field: "email", header: globals.email, filtered: true, filterMatchMode: 'contains' },
+ { field: "billable", header: "Billable", width: '9%'},
+ { field: "active", header: globals.active, width: '9%' },
];
}
ngOnInit() {
- const saved = localStorage.getItem('isSelfSignup');
- this.isSelfSignup = saved === 'true';
-
- this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
- this.setCustomersAndPartners(customers);
- });
+ this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(
+ (customers) => this.customers = customers);
this.sub$.add(this.store.select(fromCustomers.getSelectedCustomer).subscribe(cust => {
this.curCust = cust;
}));
-
this.store.dispatch(new customerActions.Fetch());
}
- private setCustomersAndPartners(customers: Customer[]) {
- const filtered = this.isSelfSignup ? customers.filter(c => c.selfSignup) : customers;
- this.customers = filtered.map(c => ({
- ...c,
- partnerName: c.partner?.name || null
- }));
- this.partners = [
- { label: globals.all, value: null },
- ...customers
- .filter(c => c.partner)
- .map(c => c.partner.name)
- .filter((v, i, a) => a.indexOf(v) === i)
- .map(name => ({ label: name, value: name }))
- ];
- }
-
- onToggle(event: any): void {
- this.isSelfSignup = event.checked;
- localStorage.setItem('isSelfSignup', String(this.isSelfSignup));
- this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
- this.setCustomersAndPartners(customers);
- });
- }
-
onRowSelect(event) {
this.store.dispatch(new customerActions.Select(event.data));
}
@@ -124,6 +88,10 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
});
}
+ billableOverview() {
+
+ }
+
ngOnDestroy() {
super.ngOnDestroy();
}
diff --git a/Development/client/src/app/customers/customer-resolver.service.ts b/Development/client/src/app/customers/customer-resolver.service.ts
index c4ee87b..9ed5cdb 100644
--- a/Development/client/src/app/customers/customer-resolver.service.ts
+++ b/Development/client/src/app/customers/customer-resolver.service.ts
@@ -24,7 +24,7 @@ export class CustomerResolver implements Resolve {
if (id === '0') {
return createNewCustomer();
} else {
- return this.customerService.getCustomer(id, 'edit').pipe(
+ return this.customerService.getCustomer(id).pipe(
map((cust) => {
if (cust) {
return cust;
diff --git a/Development/client/src/app/customers/customer-routing.module.ts b/Development/client/src/app/customers/customer-routing.module.ts
index 23b82cb..a033031 100644
--- a/Development/client/src/app/customers/customer-routing.module.ts
+++ b/Development/client/src/app/customers/customer-routing.module.ts
@@ -19,6 +19,7 @@ const routes: Routes = [
roles: [RoleIds.ADMIN]
},
canActivate: [AuthGuard],
+ // canActivateChild: [AuthGuard],
children: [
{
path: '',
@@ -32,7 +33,7 @@ const routes: Routes = [
component: CustomerEditComponent,
data: {
roles: [RoleIds.ADMIN]
- },
+ }, // canDeactivate: [CanDeactivateGuard],
resolve: [CustomerResolver]
},
]
diff --git a/Development/client/src/app/customers/customer.module.ts b/Development/client/src/app/customers/customer.module.ts
index 63f2b9f..d83ecda 100644
--- a/Development/client/src/app/customers/customer.module.ts
+++ b/Development/client/src/app/customers/customer.module.ts
@@ -17,14 +17,13 @@ import { AppSharedModule } from '../shared/app-shared.module';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
-import * as fromCustomers from './reducers/customers.reducer';
+import * as fromCustomers from './reducers/customers-reducer';
import { CustomerEffects } from './effects/customer.effects';
import { CustomerListComponent } from './customer-list/customer-list.component';
import { CustomerEditComponent } from './customer-edit/customer-edit.component';
import { CustomersRoutingModule } from './customer-routing.module';
import { CustomerMgtComponent } from './customer-mgt.component';
-import { TrialComponent } from './trial/trial.component';
@NgModule({
imports: [
@@ -46,7 +45,7 @@ import { TrialComponent } from './trial/trial.component';
EffectsModule.forFeature([CustomerEffects]),
CustomersRoutingModule
],
- declarations: [CustomerMgtComponent, CustomerListComponent, CustomerEditComponent, TrialComponent],
+ declarations: [CustomerMgtComponent, CustomerListComponent, CustomerEditComponent],
providers: [],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
diff --git a/Development/client/src/app/customers/models/customer.model.ts b/Development/client/src/app/customers/models/customer.model.ts
index d855f91..77067e7 100644
--- a/Development/client/src/app/customers/models/customer.model.ts
+++ b/Development/client/src/app/customers/models/customer.model.ts
@@ -1,31 +1,17 @@
import { RoleIds } from '@app/shared/global';
import { createNewUser, User } from '@app/accounts/models/user.model';
-import { IMembership } from '@app/auth/models/user.model';
export interface Customer extends User {
contact?: string;
fax?: string;
premium: number;
billable?: boolean;
- totalJobs?: number;
- membership: IMembership,
- partner?: Partner;
- selfSignup?: boolean;
-}
-export interface Partner {
- _id: string;
- name: string;
- description: string;
- kind: string; // Required to match User interface
- active?: boolean;
- createdAt?: string;
- updatedAt?: string;
+ totalJobs?: number; // extension field for GUI
}
export const createNewCustomer = () => {
- const customer = createNewUser(null, RoleIds.APP) as Customer;
+ const customer = createNewUser(null, RoleIds.APP);
customer.premium = 0;
- customer.membership = {} as IMembership; // Initialize required membership property
return customer;
-}
+}
\ No newline at end of file
diff --git a/Development/client/src/app/customers/reducers/customers.reducer.ts b/Development/client/src/app/customers/reducers/customers-reducer.ts
similarity index 100%
rename from Development/client/src/app/customers/reducers/customers.reducer.ts
rename to Development/client/src/app/customers/reducers/customers-reducer.ts
diff --git a/Development/client/src/app/customers/reducers/index.ts b/Development/client/src/app/customers/reducers/index.ts
index 0c25340..c29d0da 100644
--- a/Development/client/src/app/customers/reducers/index.ts
+++ b/Development/client/src/app/customers/reducers/index.ts
@@ -3,7 +3,7 @@ import {
createFeatureSelector,
} from '@ngrx/store';
-import * as fromCustomers from './customers.reducer';
+import * as fromCustomers from './customers-reducer';
export const getCustomersState = createFeatureSelector(fromCustomers.FEATURE_KEY);
diff --git a/Development/client/src/app/customers/trial/trial.component.css b/Development/client/src/app/customers/trial/trial.component.css
deleted file mode 100644
index d2d4a45..0000000
--- a/Development/client/src/app/customers/trial/trial.component.css
+++ /dev/null
@@ -1,8 +0,0 @@
-.trial-row {
- margin-top: 1em;
- padding-left: 0;
-}
-
-#day-label {
- padding-top: 2px;
-}
\ No newline at end of file
diff --git a/Development/client/src/app/customers/trial/trial.component.html b/Development/client/src/app/customers/trial/trial.component.html
deleted file mode 100644
index 5cbd89a..0000000
--- a/Development/client/src/app/customers/trial/trial.component.html
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
- End in days :
-
-
-
-
-
-
- {{error}}
-
-
-
-
- End date :
-
-
- {{error}}
-
-
-
-
-
\ No newline at end of file
diff --git a/Development/client/src/app/customers/trial/trial.component.ts b/Development/client/src/app/customers/trial/trial.component.ts
deleted file mode 100644
index ebd070c..0000000
--- a/Development/client/src/app/customers/trial/trial.component.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-import { AfterContentInit, Component, HostListener, Input, OnDestroy, OnInit, forwardRef } from '@angular/core';
-import { FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms';
-import { Trial } from '@app/domain/models/subscription.model';
-import { BaseComp } from '@app/shared/base/base.component';
-import { GC } from '@app/shared/global';
-import { DateUtils } from '@app/shared/utils';
-import { SelectItem } from 'primeng-lts/api';
-
-const MIN_DAYS = 7;
-
-@Component({
- selector: 'trial',
- templateUrl: './trial.component.html',
- styleUrls: ['./trial.component.css'],
- providers: [
- { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TrialComponent), multi: true },
- { provide: NG_VALIDATORS, useExisting: forwardRef(() => TrialComponent), multi: true }
- ]
-})
-export class TrialComponent extends BaseComp implements OnDestroy, OnInit, AfterContentInit {
- @Input() trialDays: number[];
- @Input() trials: Trial;
- @Input() disable: boolean;
-
- DAYS = GC.DAYS;
- BYDATE = GC.BYDATE;
-
- dayItems: SelectItem[];
- form: FormGroup;
- toDate: Date;
- error: string;
- calMinDate: Date;
- calMaxDate: Date;
- onChange: any = () => { };
- onTouched: any = () => { };
-
- constructor(
- private readonly fb: FormBuilder
- ) {
- super();
- const ONE_YEAR = 1;
- this.calMinDate = new Date();
- this.calMinDate.setDate(this.calMinDate.getDate() + MIN_DAYS);
- this.calMinDate.setHours(0, 0, 0);
- this.calMaxDate = new Date();
- this.calMaxDate.setFullYear(this.calMaxDate.getFullYear() + ONE_YEAR);
- }
-
- get value() {
- // CRITICAL: Use getRawValue() to include disabled controls (selected, type, trialDays)
- return this.form.getRawValue();
- }
-
- set value(val) {
- this.writeValue(val);
- this.onChange(val);
- this.onTouched(val);
- }
-
- ngOnInit(): void {
- this.form = this.fb.group({
- selected: new FormControl({ value: false, disabled: this.disable }),
- type: new FormControl({ value: '', disabled: this.disable }),
- startDate: [],
- lastEndDate: [],
- lastStartDate: [],
- trialDays: new FormControl({ value: '', disabled: this.disable }),
- byDate: []
- });
- this.dayItems = this.trialDays?.map((day) => ({ label: `${day}`, value: day }));
-
- // CRITICAL FIX: Use getRawValue() to include disabled controls in onChange callback
- this.sub$.add(this.form.valueChanges.subscribe(() => {
- const rawValue = this.form.getRawValue();
- this.onChange(rawValue);
- this.onTouched(rawValue);
- }));
- }
-
- ngAfterContentInit() {
- // Check if user has valid trial configuration OR component is disabled (has active trial subscriptions)
- const hasExistingTrial = (this.trials?.type && (this.trials.trialDays >= MIN_DAYS || this.trials.byDate))
- || (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate));
-
- if (hasExistingTrial) {
- if (this.trials?.type === this.BYDATE && this.trials.byDate) {
- this.toDate = new Date(this.trials.byDate);
- this.form.patchValue({ ...this.trials, selected: true });
- } else if (this.trials?.type === this.DAYS && this.trials.trialDays >= MIN_DAYS) {
- this.form.patchValue({ ...this.trials, selected: true });
- } else if (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate)) {
- // User has active trial subscriptions (disable=true) - always set selected=true
- // This prevents accidental trial disable when admin edits customer with active trials
- // Determine correct type from trial configuration (byDate takes precedence over trialDays)
- const trialType = this.trials?.byDate ? this.BYDATE : this.DAYS;
- if (trialType === this.BYDATE) {
- this.toDate = new Date(this.trials.byDate);
- }
- // When controls are disabled, patchValue() ignores them - must use setValue() directly
- this.form.get('selected').setValue(true);
- this.form.get('type').setValue(trialType);
- this.form.get('trialDays').setValue(this.trials.trialDays);
- this.form.patchValue({
- startDate: this.trials.startDate,
- lastEndDate: this.trials.lastEndDate,
- lastStartDate: this.trials.lastStartDate,
- byDate: this.trials.byDate
- });
- } else {
- this.form.patchValue({ ...this.trials });
- }
- }
- }
-
- writeValue(val): void {
- if (val) {
- this.form.patchValue(val);
- }
- }
-
- registerOnChange(fn: any): void {
- this.onChange = fn;
- }
-
- registerOnTouched(fn: any): void {
- this.onTouched = fn;
- }
-
- validate() {
- this.checkAndDisplayErr();
- const isTrial = this.form.value.selected;
- const isInValid = !this.isValidTrialDays() && !this.isValidBydate();
- return isTrial ? isInValid ? { trials: { valid: false } } : null : null;
- }
-
- isValidTrialDays() {
- return this.form.value.type === this.DAYS && this.form.value.trialDays && !isNaN(this.form.value.trialDays) && this.form.value.trialDays >= MIN_DAYS;
- }
-
- isValidBydate() {
- return this.form.value.type === this.BYDATE && this.form.value.byDate;
- }
-
- change() {
- const noPrevTrial = !this.form.value.type;
- if (noPrevTrial) {
- this.form.patchValue({ type: this.DAYS });
- }
-
- const trialDays = this.form.value.trialDays;
- const notExistItem = this.dayItems?.every((item) => item.value != trialDays);
- if (this.isValidTrialDays() && notExistItem) {
- this.dayItems.push({ label: `${trialDays}`, value: Number(trialDays) });
- this.dayItems.sort((a, b) => a.value - b.value);
- this.appConf.saveTrialDays(this.dayItems?.map((item) => item.value));
- }
-
- this.form.updateValueAndValidity();
- }
-
- changeCal() {
- this.form.patchValue({
- ...this.trials,
- type: this.BYDATE,
- byDate: DateUtils.tsToDate(
- DateUtils.endUtcTS(
- DateUtils.dateToTS(this.toDate)))
- });
- this.form.updateValueAndValidity();
- }
-
- checkAndDisplayErr() {
- this.error = '';
- if (this.form.value.selected && this.form.value.type === this.BYDATE) {
- if (!this.toDate) {
- return this.error = `Please fill in end date MM/DD/YYYY from `;
- }
- } else if (this.form.value.selected && this.form.value.type === this.DAYS) {
- if (!this.form.value.trialDays) {
- return this.error = 'Please fill in the number of end days';
- } else if (isNaN(this.form.value.trialDays) || this.form.value.trialDays < MIN_DAYS) {
- return this.error = `Number of days must be number greater than .`;
- }
- }
- return this.error;
- }
-
- remove(item) {
- this.dayItems = this.dayItems?.filter((day) => day.label !== item.label);
- this.appConf.saveTrialDays(this.dayItems?.map((item) => item.value));
- }
-
- @HostListener('document:keydown.enter', ['$event']) onKeydownHandler(e: KeyboardEvent) {
- const target = e.target;
- if (target.name === this.BYDATE) this.changeCal();
- }
-
- ngOnDestroy(): void {
- super.ngOnDestroy();
- }
-}
diff --git a/Development/client/src/app/dashboard/dashboard.component.css b/Development/client/src/app/dashboard/dashboard.component.css
index caa2fcc..e69de29 100644
--- a/Development/client/src/app/dashboard/dashboard.component.css
+++ b/Development/client/src/app/dashboard/dashboard.component.css
@@ -1,3 +0,0 @@
-.pure-white {
- color: #FFFFFF;
-}
\ No newline at end of file
diff --git a/Development/client/src/app/dashboard/dashboard.component.html b/Development/client/src/app/dashboard/dashboard.component.html
index a5b7267..7ea0cb8 100644
--- a/Development/client/src/app/dashboard/dashboard.component.html
+++ b/Development/client/src/app/dashboard/dashboard.component.html
@@ -1,9 +1,5 @@
-
-
-
-
-
+
Welcome to AgMission
@@ -13,5 +9,5 @@
BY ACCESSING AND USING THE APPLICATION, YOU AGREE TO THE TERMS AND CONDITIONS EXPRESSED UPON.
-
-
\ No newline at end of file
+
+
\ No newline at end of file
diff --git a/Development/client/src/app/dashboard/dashboard.component.ts b/Development/client/src/app/dashboard/dashboard.component.ts
index bc82f7c..48810a9 100644
--- a/Development/client/src/app/dashboard/dashboard.component.ts
+++ b/Development/client/src/app/dashboard/dashboard.component.ts
@@ -1,11 +1,15 @@
-import { Component } from '@angular/core';
+import { Component, OnInit } from '@angular/core';
@Component({
selector: 'agm-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.css']
})
-export class DashboardComponent {
+export class DashboardComponent implements OnInit {
constructor() { }
+
+ ngOnInit() {
+ }
+
}
diff --git a/Development/client/src/app/domain/guards/auth.guard.ts b/Development/client/src/app/domain/guards/auth.guard.ts
index 20ad171..4925624 100644
--- a/Development/client/src/app/domain/guards/auth.guard.ts
+++ b/Development/client/src/app/domain/guards/auth.guard.ts
@@ -1,137 +1,78 @@
import { Injectable } from '@angular/core';
-import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild, Route } from '@angular/router';
-import { Observable, of } from 'rxjs';
-import { take, map, catchError, switchMap } from 'rxjs/operators';
+import {
+ CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild, Route,
+} from '@angular/router';
+
+import { Observable } from 'rxjs';
+import { take, map } from 'rxjs/operators';
+
import { Store } from '@ngrx/store';
import * as fromStore from '../../reducers';
+
import { AuthService } from '../services/auth.service';
-import { SUB, SubStripe } from '@app/profile/common';
-import { FEATURE_KEY } from '@app/profile/reducers';
-import { Status, StripeSubscription } from '../models/subscription.model';
-import { SubscriptionService } from '../services/subscription.service';
-import { AC } from '@app/shared/global';
-import { RouterUtilsService } from '@app/shared/router-utils.service';
-import { ClearSubscriptionStatus } from '@app/actions/subscription.actions';
+
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate, CanActivateChild {
constructor(
private readonly store: Store<{}>,
- private readonly authSvc: AuthService,
- private readonly router: Router,
- private readonly subSvc: SubscriptionService,
- private readonly routerUtils: RouterUtilsService
- ) { }
-
- canLoad(route: Route): boolean {
- return this.checkRoles(route?.data?.roles);
+ private authService: AuthService,
+ private router: Router) {
}
- canActivate(route: ActivatedRouteSnapshot, routerState: RouterStateSnapshot): Observable {
- let subs: StripeSubscription[], status: Status;
- return this.store.select(fromStore.getSubscriptionState).pipe(
- switchMap((subState) => {
- subs = subState?.entries;
- status = subState?.status;
- return this.store.select(fromStore.selectIsLoggedIn)
- }),
- take(1),
+ canLoad(route: Route): boolean {
+ const url = `/${route.path}`;
+ return this.checkRoles(url, route.data.roles || null);
+ }
+
+ canActivate(
+ route: ActivatedRouteSnapshot,
+ routerState: RouterStateSnapshot): Observable | Promise | boolean {
+
+ return this.checkStoreAuth().pipe(
+ // mergeMap((storeAuth) => {
+ // if (storeAuth)
+ // return of(true);
+ // return this.checkApiAuth();
+ // }),
map(storeOrApiAuth => {
- const LOCAL_TEMP_FLAG = 'requiredSubAttention';
- const TEMP_FLAG_VALUE = 'true';
- const hasAllowedRoles = this.checkRoles(route);
- const hasNotAuth = !storeOrApiAuth;
- if (hasNotAuth) {
+ if (!storeOrApiAuth) {
this.router.navigate(['/login'], { replaceUrl: true });
return false;
}
-
- // Early exit for partner users - they bypass all subscription checks
- if (this.authSvc.isPartner) {
- return hasAllowedRoles;
- }
-
- const requiresResolution = (): boolean => {
- const hasUnresolvedSubs = this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE) || this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.UNPAID) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.subSvc.hasInValTaxLoc(subs);
- if (hasUnresolvedSubs && hasAllowedRoles) {
- const hasReqAttentionFlag = localStorage.getItem(LOCAL_TEMP_FLAG) === TEMP_FLAG_VALUE;
- const isNavToProfile = routerState.url.includes(SUB.PROFILE);
-
- if (isNavToProfile) {
- if (hasReqAttentionFlag) {
- localStorage.removeItem(LOCAL_TEMP_FLAG);
- }
- return true;
- }
-
- if (!hasReqAttentionFlag) {
- const profile = JSON.parse(sessionStorage.getItem(FEATURE_KEY));
- const isFirstLoggin = !profile?.usage || Object.keys(profile?.usage).length === 0;
- if (isFirstLoggin) {
- localStorage.setItem(LOCAL_TEMP_FLAG, TEMP_FLAG_VALUE);
- this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES], { replaceUrl: true });
- return true;
- }
- const accountNotLocked = this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE);
- if (accountNotLocked) {
- return true;
- }
- }
- this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES], { replaceUrl: true });
- return;
- }
-
- if (this.subSvc.isUnderReview(status)) {
- const fromPath = this.routerUtils.getCurrentUrl().split('/').pop();
- if (routerState.url.includes(AC)) return true;
- if (fromPath.includes(AC)) {
- this.store.dispatch(new ClearSubscriptionStatus());
- return true;
- };
- this.router.navigate(['entities', AC], { replaceUrl: true });
- return;
- }
- }
-
- const shouldNavToServices = () => {
- const trials = this.authSvc.user?.membership?.trials;
-
- if (routerState.url.includes(SUB.SERVICES)
- || (routerState.url.includes('home') && !!trials?.type)) return true;
-
- const canNavToServices = !this.authSvc.isAdmin
- && !this.authSvc.hasSubs()
- && (!trials?.type || (!trials?.byDate && trials?.trialDays === 0));
-
- if (canNavToServices) {
- this.router.navigate([SUB.PROFILE, SUB.SERVICES]);
- return;
- }
- }
-
- const canNav = requiresResolution() || shouldNavToServices() || hasAllowedRoles;
- return canNav;
+ return this.checkRoles(routerState.url, route);
}),
- catchError(err => {
- console.log(err);
- return of(false)
- })
);
}
- canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | Observable | Promise {
+ canActivateChild(
+ childRoute: ActivatedRouteSnapshot,
+ state: RouterStateSnapshot): boolean | Observable | Promise {
return this.canActivate(childRoute, state);
}
- checkRoles(route: ActivatedRouteSnapshot): boolean {
- const hasRoles = !!route?.data?.roles;
- if (hasRoles) {
- const hasAllRoles = '*' === route.data.roles;
- const hasAccessedByRoles = hasAllRoles || this.authSvc.hasRole(route.data.roles);
- return hasAccessedByRoles;
- } else {
- return true;
- }
+ checkStoreAuth() {
+ return this.store.select(fromStore.selectIsLoggedIn).pipe(take(1));
}
+
+ // checkApiAuth() {
+ // return this.authService.check().pipe(
+ // map(user => !!user),
+ // catchError(() => of(false))
+ // );
+ // }
+
+ checkRoles(url: string, route: ActivatedRouteSnapshot): boolean {
+ if (route.data.roles) {
+ if ('*' === route.data.roles || this.authService.hasRole(route.data.roles)) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+ else
+ return true;
+ }
+
}
diff --git a/Development/client/src/app/domain/guards/notification-redirect.guard.ts b/Development/client/src/app/domain/guards/notification-redirect.guard.ts
deleted file mode 100644
index 94975c7..0000000
--- a/Development/client/src/app/domain/guards/notification-redirect.guard.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { Injectable } from '@angular/core';
-import { CanActivate, Router, UrlTree, ActivatedRouteSnapshot } from '@angular/router';
-import { AuthService } from '../services/auth.service';
-import { SUB } from '@app/profile/common';
-
-/**
- * Generic guard for notification deep-link URLs (e.g. /manage-subscription, /update-pm).
- * All routing logic is declared in the route's `data` — no new guard file needed per URL.
- *
- * Route data shape:
- * data: {
- * // Required: where to send an authenticated user
- * redirectTo: string[];
- *
- * // Optional: alternate destination when master account has no subscriptions
- * redirectToNoSubs?: string[];
- *
- * // Optional: i18n message shown in the login bar
- * loginNotice?: string;
- * }
- *
- * Adding a new notification URL = one route entry, zero new files.
- */
-@Injectable({ providedIn: 'root' })
-export class NotificationRedirectGuard implements CanActivate {
- constructor(
- private readonly authSvc: AuthService,
- private readonly router: Router
- ) { }
-
- canActivate(route: ActivatedRouteSnapshot): UrlTree {
- const { redirectTo, redirectToNoSubs } = route.data as {
- redirectTo: string[];
- redirectToNoSubs?: string[];
- };
-
- if (!this.authSvc.loggedIn) {
- const { loginNotice } = route.data as { loginNotice?: string };
- return this.router.createUrlTree(['/login'], {
- queryParams: {
- returnUrl: route.url.map(s => s.path).join('/'),
- ...(loginNotice ? { loginNotice } : {})
- }
- });
- }
-
- const isMaster = !this.authSvc.user?.parent;
- if (redirectToNoSubs && isMaster && !this.authSvc.hasSubs()) {
- return this.router.createUrlTree(redirectToNoSubs);
- }
- return this.router.createUrlTree(redirectTo);
- }
-}
diff --git a/Development/client/src/app/domain/guards/role.guard.ts b/Development/client/src/app/domain/guards/role.guard.ts
deleted file mode 100644
index 8d5c422..0000000
--- a/Development/client/src/app/domain/guards/role.guard.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Injectable } from '@angular/core';
-import {
- CanActivate, ActivatedRouteSnapshot
-} from '@angular/router';
-import { AuthService } from '../services/auth.service';
-
-@Injectable({ providedIn: 'root' })
-export class RoleGuard implements CanActivate {
-
- constructor(
- private authService: AuthService) {
- }
-
- canActivate(route: ActivatedRouteSnapshot): boolean {
- if (route.data.roles) {
- if ('*' === route.data.roles || this.authService.hasRole(route.data.roles)) {
- return true;
- } else {
- return false;
- }
- }
- else
- return true;
- }
-}
\ No newline at end of file
diff --git a/Development/client/src/app/domain/guards/settings-guard.service.ts b/Development/client/src/app/domain/guards/settings-guard.service.ts
index c72b448..f81f75c 100644
--- a/Development/client/src/app/domain/guards/settings-guard.service.ts
+++ b/Development/client/src/app/domain/guards/settings-guard.service.ts
@@ -10,19 +10,7 @@ export class SettingsGuard implements CanActivate {
constructor(private readonly appCnf: AppConfigService) { }
canActivate(route: ActivatedRouteSnapshot): Observable {
- console.log('SettingsGuard: canActivate called for route:', route.routeConfig?.path);
// Make sure to load the config whenever the module is accessed.
- const loadResult = this.appCnf.load();
-
- loadResult.subscribe({
- next: (success) => {
- console.log('SettingsGuard: AppConfig load completed with result:', success);
- },
- error: (error) => {
- console.error('SettingsGuard: AppConfig load error:', error);
- }
- });
-
- return loadResult;
+ return this.appCnf.load();
}
}
diff --git a/Development/client/src/app/domain/guards/stripe-load.guard.ts b/Development/client/src/app/domain/guards/stripe-load.guard.ts
deleted file mode 100644
index 36043a0..0000000
--- a/Development/client/src/app/domain/guards/stripe-load.guard.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Injectable } from '@angular/core';
-import { CanActivate } from '@angular/router';
-import { SubscriptionService } from '../services/subscription.service';
-
-@Injectable({ providedIn: 'root' })
-export class StripeLoadGuard implements CanActivate {
- constructor(private readonly subSvc: SubscriptionService) { }
-
- canActivate(): Promise {
- return this.subSvc.loadStripePromise().then(() => true).catch(() => false);
- }
-}
\ No newline at end of file
diff --git a/Development/client/src/app/domain/guards/subscription.guard.ts b/Development/client/src/app/domain/guards/subscription.guard.ts
deleted file mode 100644
index 0461461..0000000
--- a/Development/client/src/app/domain/guards/subscription.guard.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { Injectable } from '@angular/core';
-import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router';
-import { AuthService } from '../services/auth.service';
-import { SubscriptionService } from '../services/subscription.service';
-import { catchError, map } from 'rxjs/operators';
-import { Observable, of } from 'rxjs';
-import { StripeSubscription } from '../models/subscription.model';
-import { SUB, SubStripe } from '@app/profile/common';
-import { AppMessageService } from '@app/shared/app-message.service';
-import { AC, globals } from '@app/shared/global';
-import { RouterUtilsService } from '@app/shared/router-utils.service';
-
-/**
- This guards against unresolved subscriptions when the user is subscribing to a new subscription in the
- main flow (services <->billing-detail<->checkout<->checkout-review<->checkout-confirm). When the user has an unresolved subscription, the guard forces user to enter the resolving subscription flow.
- */
-
-@Injectable({ providedIn: 'root' })
-export class SubscriptionGuard implements CanActivate {
-
- constructor(
- private readonly authSvc: AuthService,
- private readonly subSvc: SubscriptionService,
- private readonly router: Router,
- private readonly msgSvc: AppMessageService,
- private readonly routerUtils: RouterUtilsService
- ) { }
-
- canActivate(activatedRoute: ActivatedRouteSnapshot): Observable {
-
- return this.subSvc.fetchSubscriptions(this.authSvc.user?.membership?.custId).pipe(
- map((subs) => {
- const fromPath = this.routerUtils.getCurrentUrl().split('/').pop();
- const toPath = activatedRoute.url[0]?.path;
-
- const hasLatestSubs = subs?.length > 0;
- const hasUnresolvedSubs = this.hasUnresolvedSubs(subs);
- const hasAllActiveSubs = hasLatestSubs && !hasUnresolvedSubs;
-
- const isSubscribing = (!hasLatestSubs || hasAllActiveSubs)
- && (this.isInMainFlow(fromPath, toPath)
- || this.isPageReload(fromPath, toPath));
- const isResolvingSubs = hasUnresolvedSubs
- && (this.isInResolvingFlow(fromPath, toPath)
- || this.isPageReload(fromPath, toPath));
-
- const viewPayment = this.isPaymentPage(toPath);
- const viewPaymentMethod = this.isPaymentMethodPage(toPath);
- const viewAC = this.isACPage(toPath) && !hasUnresolvedSubs;
-
- const canNavigate = isSubscribing || viewPayment || viewPaymentMethod || viewAC || true;
- const shouldNavigateToMyServices = hasUnresolvedSubs && !isResolvingSubs;
-
- if (canNavigate) {
- return true;
- } else if (shouldNavigateToMyServices) {
- this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
- } else if (isResolvingSubs) {
- return true;
- } else {
- this.router.navigate(['/', SUB.HOME]);
- }
- }),
- catchError(err => {
- this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subscription));
- return of(false);
- })
- );
- }
-
- private isInMainFlow(fromPath: string, toPath: string): boolean {
- return (fromPath === SUB.SERVICES && toPath === SUB.BILL_ADR) ||
- (fromPath === SUB.BILL_ADR && toPath === SUB.CHKOUT) ||
- (fromPath === SUB.CHKOUT && toPath === SUB.BILL_ADR) ||
- (fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_REV) ||
- (fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT_CONF) ||
- (fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT) ||
- this.isTrialFLow(fromPath, toPath);
- }
-
- private isInResolvingFlow(fromPath: string, toPath: string): boolean {
- return (fromPath === SUB.UNPAID_SUB && toPath === SUB.BILL_ADR) ||
- (fromPath === SUB.BILL_ADR && toPath === SUB.CHKOUT) ||
- (fromPath === SUB.BILL_ADR && toPath === SUB.UNPAID_SUB) ||
- (fromPath === SUB.CHKOUT && toPath === SUB.BILL_ADR) ||
- (fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_REV) ||
- (fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT) ||
- (fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT_CONF) ||
- (fromPath === SUB.MY_SERVICES && toPath === SUB.CHKOUT_REV) ||
- (fromPath === SUB.MY_SERVICES && toPath === SUB.UNPAID_SUB);
- }
-
- private isTrialFLow(fromPath: string, toPath: string): boolean {
- return (fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_CONF) ||
- (fromPath === SUB.MY_SERVICES && toPath === SUB.BILL_ADR);
- }
-
- private isPageReload(fromPath: string, toPath: string): boolean {
- return (!fromPath && (toPath === SUB.BILL_ADR || toPath === SUB.CHKOUT || toPath === SUB.CHKOUT_REV || toPath === SUB.CHKOUT_CONF));
- }
-
- private hasUnresolvedSubs(subs: StripeSubscription[]): boolean {
- return subs?.some(sub => sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE || sub.status === SubStripe.UNPAID || sub.status === SubStripe.INCOMPLETE);
- }
-
- private isPaymentPage(path: string): boolean {
- return path === SUB.PM_HISTORY || path === SUB.PM_DETAIL;
- }
-
- private isPaymentMethodPage(path: string): boolean {
- return path === SUB.PM_LIST;
- }
-
- private isACPage(path: string): boolean {
- return path === AC;
- }
-}
\ No newline at end of file
diff --git a/Development/client/src/app/domain/guards/usage-detail.guard.ts b/Development/client/src/app/domain/guards/usage-detail.guard.ts
deleted file mode 100644
index 1153f33..0000000
--- a/Development/client/src/app/domain/guards/usage-detail.guard.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { Injectable } from '@angular/core';
-import { CanActivate } from '@angular/router';
-import { AuthService } from '../services/auth.service';
-import { SubType } from '@app/profile/common';
-
-@Injectable({ providedIn: 'root' })
-export class UsageDetailGuard implements CanActivate {
-
- constructor(private authService: AuthService) { }
-
- canActivate(): boolean {
- const hasPkg = this.authService.user?.membership?.subscriptions?.some((sub) => sub.type === SubType.PACKAGE);
- if (hasPkg) return true;
- }
-}
\ No newline at end of file
diff --git a/Development/client/src/app/domain/models/appconfig.model.ts b/Development/client/src/app/domain/models/appconfig.model.ts
index 7b24a05..18270c7 100644
--- a/Development/client/src/app/domain/models/appconfig.model.ts
+++ b/Development/client/src/app/domain/models/appconfig.model.ts
@@ -23,7 +23,4 @@ export interface IAppConfig {
};
noPopup: boolean;
- trialDays: [number];
- /** Grace-period days for promo Valid Until (sysadmin only). From PROMO_MIN_EXPIRY_DAYS env. */
- promoMinExpiryDays?: number;
}
diff --git a/Development/client/src/app/domain/models/play-record.model.ts b/Development/client/src/app/domain/models/play-record.model.ts
index 95226d5..dd1a496 100644
--- a/Development/client/src/app/domain/models/play-record.model.ts
+++ b/Development/client/src/app/domain/models/play-record.model.ts
@@ -45,7 +45,7 @@ export class PlayRecord {
// Output 3
areaName: string;
totLnLength: number;
- applicRate: number; // Applic. Rate: Application rate in Gals/Acre or Liters/Ha. Value is read from the Q file or the job.
+ applicRate: number; // Applic. Rate: Application rate in Gals/Acre or Liters/Ha. Value is ead from the Q file or the job.
mappedArea: number;
overSprayed: number;
pilotName: string;
diff --git a/Development/client/src/app/domain/models/subscription.model.ts b/Development/client/src/app/domain/models/subscription.model.ts
deleted file mode 100644
index 31ad613..0000000
--- a/Development/client/src/app/domain/models/subscription.model.ts
+++ /dev/null
@@ -1,735 +0,0 @@
-import { IMembership } from '@app/auth/models/user.model';
-import { InvType, Mode } from '@app/profile/common';
-import { StripeCardCvcElement, StripeCardElement, StripeCardExpiryElement, StripeCardNumberElement } from '@stripe/stripe-js';
-
-export type PriceUsd = string | number;
-
-export interface Price {
- type: string;
- lookupKey: string;
- priceUSD: PriceUsd,
- level: number;
- maxVehicles: number;
- maxAcres: number;
-}
-
-export interface BasePackage {
- price: PriceUsd;
- quantity?: number;
- metadata?: {
- tier: string;
- level?: string;
- maxAcres?: string;
- maxVehicles?: string;
- }
-}
-
-export interface Addon extends BasePackage {
- priceId?: string;
- name?: string;
- desc: string;
- lookupKey: string;
- trialEnd?: number;
- interval?: string; // Billing interval ('year' or 'month')
-}
-
-export interface Package extends BasePackage {
- priceId?: string;
- desc: string;
- maxVehicles?: number;
- Vehicles?: string;
- maxAcres?: string;
- lookupKey: string;
- level?: number;
- trialEnd?: number;
- interval?: string; // Billing interval ('year' or 'month')
-}
-
-export interface Address {
- _id?: string;
- name?: string;
- valid?: boolean;
- city?: string;
- country: string;
- line1: string;
- line2?: string | null;
- postalCode?: string;
- state?: string,
- isBilling?: boolean;
-}
-
-export interface Card {
- pmId?: string;
- brand: string;
- country: string;
- exp_month: number;
- exp_year: number;
- last4: string;
- defaultPM: boolean;
-}
-
-export interface BillingInfo {
- applicatorId: string;
- name: string;
- address
- email?: string;
-}
-
-export interface PaymentMethod {
- id: string;
- created: number;
- card: Card;
- billing_details: BillingInfo;
-}
-
-export interface InvoicePackage {
- custId: string;
- package: string;
- addons: BasePackage[];
- prorateTS?: number; // Optional: only needed for proration calculations
- coupon?: string;
-}
-
-export interface Line {
- id: string;
- amount: number;
- amount_excluding_tax: number;
- period: {
- end: number;
- start: number
- };
- description: string;
- subscription: string;
- quantity: number;
- plan: {
- id: string;
- amount: number;
- }
- price: {
- lookup_key: string;
- }
- // Issue 4 - Proration credit detection
- proration?: boolean; // True for proration credits (unused subscription time)
- type?: string; // 'subscription', 'invoiceitem', etc.
-}
-
-/**
- * Describes a deferred promo that will apply from the next billing period.
- * Shape is identical to the inline `promoDetails` object on subscriptions,
- * with two additional discriminant flags. Backend builds this from
- * subscription.metadata.pending_coupon_id (r975+).
- */
-export interface PendingPromoDetails {
- isPending: true;
- appliesToNextPeriod: true;
- name: string;
- discountDisplay: string; // 'FREE', '50% OFF', '$9.99 OFF'
- percentOff: number | null;
- amountOff: number | null; // cents
- currency: string | null;
- duration: string | null; // 'forever' | 'once' | 'repeating'
- durationInMonths: number | null;
- expiresAt: null;
- discountEndsAt: null;
- daysRemaining: null;
- daysUntilDiscountEnds: null;
- isTimeLimited: false;
-}
-
-export interface Invoice {
- id: string;
- subscription: string;
- tax: number;
- total: number;
- object: string;
- subtotal_excluding_tax: number;
- lines?: {
- data: Line[]
- }
- number?: number;
- subtotal?: number;
- total_tax_amounts?: Taxable[]
- subscription_proration_date?: number;
- total_excluding_tax?: number;
- created?: number;
- paid?: boolean;
- status?: string;
- amount_due?: number;
- amount_paid?: number;
- hosted_invoice_url?: string;
- invoice_pdf?: string;
- customer_name?: string;
- customer_email?: string;
- customer_address?: {
- city: string;
- country: string;
- line1: string;
- postal_code: string;
- state: string;
- };
- attempted?: boolean;
- type: InvType.INVOICE;
- discount?: {
- coupon: Coupon
- };
- /** Invoice period type: "current" (immediate billing) or "next" (future billing cycle) */
- period_type?: string;
- /** Flag indicating if this invoice has promotional pricing applied */
- has_promo?: boolean;
- /** @deprecated r975: field no longer populated by backend. Use pendingPromoDetails instead. */
- promo_coupon?: string;
- /** Unix timestamp (seconds) — when the next charge is collected. Use * 1000 for a JS Date. (r975+) */
- next_billing_date?: number;
- /** Present when a deferred 100% FREE promo is scheduled for next billing period (r975+). */
- pendingPromoDetails?: PendingPromoDetails;
- /** Discount amounts applied on this invoice. Each entry is { amount (cents), discount (Stripe discount ID) }. */
- total_discount_amounts?: { amount: number; discount: string }[];
-}
-
-export interface Charge {
- id: string;
- object: string;
- amount: number;
- amount_refunded: number;
- created: number;
- paid: boolean;
- refunded: boolean;
- receipt_url: string;
- invoice: string;
- type: InvType.CHARGE;
-}
-
-export type Payment = Invoice | Charge;
-
-export interface Taxable {
- amount: string;
- taxable_amount: string;
-}
-
-export interface PaidAmount {
- totalExcludingTax: number;
- totalTax: number;
- total: number;
- discount?: Discount;
- refundAmount?: number;
-}
-
-export interface Discount {
- amountOff: number;
- percentOff?: number;
-}
-
-export interface SubscriptionIntent {
- applicatorId: string;
- custId: string;
- selPkg: Package;
- selAddons: Addon[];
- orgPkg?: Package;
- orgAddons?: Addon[];
- upcomingInvoices?: Invoice[];
- billingInfo?: BillingInfo;
- paymentMethods?: PaymentMethod[];
- card?: Card;
- prorateTS?: number;
- amount?: PaidAmount;
- isNewAccount?: boolean;
- coupons?: Coupon[];
- mode: Mode;
- subIds?: string[];
- promoSavings?: number; // Total promo discount in cents (calculated in checkout)
-}
-
-export interface SubscriptionPackage {
- stage?: string;
- card?: Card;
- pmId?: string;
- defaultPM: boolean;
- package: string;
- addons: BasePackage[];
- applicatorId?: string;
- prorateTS?: number;
- coupon?: string;
- trial?: number;
-}
-
-export interface CustChargePkg {
- custId: string;
- refunded?: boolean;
- status?: string;
- limit?: number;
-}
-
-export interface SubscriptionPaymentMethod {
- subIds: string[];
- pmId: string;
-}
-
-
-export interface PaymentIntent {
- id: string;
- status: string;
- client_secret: string;
- customer: string;
- payment_method: string;
- source: string;
- last_payment_error: {
- payment_method: PaymentMethod;
- source: Card
- };
-}
-
-export interface LatestInvoice {
- subscription: string;
- id: string;
- object: string;
- customer_address?: {
- city: string;
- country: string;
- line1: string;
- line2: string;
- postal_code: string;
- state: string;
- },
- customer_name?: string;
- period_start: number;
- period_end: number;
- status: string;
- payment_intent: PaymentIntent;
- tax: number;
- total: number;
- total_excluding_tax: number;
- subtotal: number;
- subtotal_excluding_tax: number;
- type: InvType.INVOICE;
- last_finalization_error: {
- code: string;
- type: string;
- message: string;
- };
- automatic_tax: {
- enabled: boolean;
- status: string;
- }
-}
-
-export interface PastDue {
- invoices: LatestInvoice[];
- numOfRetries: number;
-}
-
-export interface Incomplete {
- invoices: LatestInvoice[];
- requiresAction: boolean;
- requiresPM: boolean;
- numOfRetries: number;
- subscriptions?: StripeSubscription[]
-}
-
-export interface Unpaid {
- invoices: Invoice[];
- numOfRetries: number;
-}
-
-export interface StripeSubscription {
- id: string;
- status: string;
- latest_invoice: LatestInvoice;
- items: {
- data: {
- quantity: number;
- price: {
- lookup_key: string;
- metadata?: {
- maxVehicles?: string;
- maxAcres?: string;
- tier?: string;
- level?: string;
- };
- }
- }[];
- };
- current_period_end: number;
- current_period_start: number;
- default_payment_method: string;
- default_source: string;
- metadata?: {
- type: string;
- scheduleId?: string;
- promoId?: string;
- };
- cancel_at_period_end: boolean;
- discount?: {
- coupon: Coupon
- }
- trial_end?: number;
- quantity: number;
- // ✅ r962+ promoDetails enhancement (includes amountOff/percentOff)
- promoDetails?: {
- hasPromo: boolean;
- name: string;
- discountDisplay: string;
- expiresAt: string | null;
- discountEndsAt: string | null;
- daysRemaining: number | null;
- daysUntilDiscountEnds: number | null;
- isTimeLimited: boolean;
- durationInMonths: number | null;
- duration: string | null;
- percentOff: number | null;
- amountOff: number | null;
- currency: string | null;
- };
-}
-
-/**
- * Warning notification for subscriptions expiring within 7 days
- *
- * Data source: GET /api/subscription?custId={custId}
- * Verified: 2025-11-10 via /server_test/subscription-data-verification.js
- *
- * Use case: Display topbar notification when subscription expires in 1-7 days
- * Shows: Package name and/or addon names that are expiring with their individual expiry dates
- */
-export interface ExpiryWarning {
- /** Subscription ID from Stripe */
- id: string;
-
- /** Subscription type - 'package', 'addon', or 'both' */
- type: 'package' | 'addon' | 'both';
-
- /** Subscription status from Stripe (trialing, active, etc.) */
- status: string;
-
- /** Calculated days until earliest subscription expires */
- daysUntilExpiry: number;
-
- /** If true, subscription will expire and NOT auto-renew */
- cancelAtPeriodEnd: boolean;
-
- /** Unix timestamp when earliest subscription period ends */
- periodEnd: number;
-
- /** True if subscription status is 'trialing' */
- isTrial: boolean;
-
- /** True if subscription will auto-renew (inverse of cancelAtPeriodEnd) */
- willAutoRenew: boolean;
-
- /** Package expiry details if package is expiring */
- package?: {
- name: string;
- lookupKey: string;
- daysUntilExpiry: number;
- periodEnd: number;
- willAutoRenew: boolean;
- isTrial: boolean;
- isCanceled: boolean;
- };
-
- /** Array of addon expiry details if addons are expiring */
- addons?: Array<{
- name: string;
- lookupKey: string;
- daysUntilExpiry: number;
- periodEnd: number;
- willAutoRenew: boolean;
- isTrial: boolean;
- isCanceled: boolean;
- }>;
-
- /** True when sub-account has no active subscriptions */
- noSubs?: boolean;
-}
-
-export interface AGNavSubscription {
- id: string;
- status: string;
- items: BasePackage[];
- periodEnd: number;
- periodStart: number;
- type: string;
- cancelAtPeriodEnd: boolean;
- trial_end?: number;
- promoDetails?: {
- hasPromo: boolean;
- name: string;
- discountDisplay: string;
- expiresAt: string | null;
- discountEndsAt: string | null;
- daysRemaining: number | null;
- daysUntilDiscountEnds: number | null;
- isTimeLimited: boolean;
- durationInMonths: number | null;
- duration: string | null;
- percentOff: number | null;
- amountOff: number | null;
- currency: string | null;
- };
- /**
- * Present when a deferred 100% FREE promo is scheduled for next billing period (r975+).
- * Built from subscription.metadata.pending_coupon_id by addPromoDetailsToSubscription().
- * Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end.
- */
- pendingPromoDetails?: PendingPromoDetails;
-}
-
-export interface AGNavSubscriptionShort {
- id: string;
- lookupKey: PriceUsd;
- status: string;
- periodEnd: number;
- cancelAtPeriodEnd: boolean;
- quantity: number;
- paymentMethod: string;
- trialEnd?: number;
- promoDetails?: {
- hasPromo: boolean;
- name: string;
- discountDisplay: string;
- expiresAt: string | null;
- discountEndsAt: string | null;
- daysRemaining: number | null;
- daysUntilDiscountEnds: number | null;
- isTimeLimited: boolean;
- durationInMonths: number | null;
- duration: string | null;
- percentOff: number | null;
- amountOff: number | null;
- currency: string | null;
- };
- /**
- * Present when a deferred 100% FREE promo is scheduled for next billing period (r975+).
- * Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end.
- */
- pendingPromoDetails?: PendingPromoDetails;
-}
-
-export interface Status {
- code: string;
- message?: string;
-}
-
-export interface RefreshPackage {
- applicatorId: string;
- custId: string;
- prevStage: string;
- stage: string;
- card: Card;
-}
-
-export interface Unresolved {
- type: string;
- reason?: string;
- numOfRetries: number;
- invoices: LatestInvoice[];
-}
-
-export interface ConfirmPackage {
- custId: string;
- stripePkgs: {
- clientSecret: string;
- pmId: string;
- }[];
- subIds: string[];
- unresolved: Unresolved;
- applicatorId: string;
- stage?: string;
-}
-
-export interface CreatePaymentMethodPackage {
- card: StripeCardElement;
- billing_details: {
- name: string;
- address: Address;
- };
- defaultPM: boolean;
-}
-
-export interface UnpaidPackage {
- pmId: string;
- invIds: string[];
- unpaid?: Unpaid;
- card?: Card;
- custId?: string;
- applicatorId?: string;
-}
-
-export interface UnpaidSubscription {
- lookupKey: PriceUsd;
- id: string;
- tax: number;
- total: number;
- subtotal_excluding_tax: number;
-}
-
-export interface Aircraft {
- numOfVehicle: number
-}
-
-export interface Acre {
- currUsage: number;
- limit: number | null; // null = unlimited acres for current subscription packages
- overLimit: boolean;
-}
-
-export interface SubLimit {
- package?: { [i: string]: Limit };
- addon?: { [i: string]: Limit };
-}
-
-export interface Plan extends SubLimit {
- subscriptions?: StripeSubscription[];
- membership?: IMembership;
-}
-
-export interface Limit {
- acre: Acre;
- airCraft: Aircraft;
-}
-
-export interface BillPeriod {
- custId: string;
- periodEnd: number;
- periodStart: number;
- subId: string;
- lookupKey: string;
-}
-
-export interface JobUsage {
- createdAt: string;
- jobId: number;
- ttSprArea: number;
- totalSprayed: number;
- updateDate: string;
-}
-
-export interface Usage {
- ttArea: number;
- numOfAC: number;
- jobUsages: JobUsage[]
-}
-
-export interface UsagePackage {
- byPuid: string;
- fromTS?: number;
- toTS?: number;
-}
-
-export interface UsageDetail {
- periodEnd: number;
- periodStart: number;
- dayPercentage: number,
- dayLeft: number;
- maxAcre: string;
- ttArea: number;
- acrePercentage: number;
- jobUsages?: JobUsage[];
- billPeriods?: BillPeriod[];
-}
-
-export interface TSRange {
- minTS: number;
- maxTS: number;
- fromTS: number;
- toTS: number;
-}
-
-export interface LineItem {
- description: string;
- tax: number;
- amount: number;
-}
-
-export interface TotalLine {
- totalTax: number;
- totalAmount: number;
- lineItems: LineItem[];
- discount?: Discount;
-}
-
-export interface CheckoutPayment {
- payment: TotalLine;
- refund?: TotalLine;
-}
-
-export interface Coupon {
- id: string;
- name: string;
- amount_off: number;
- percent_off: number;
- redeem_by: string;
- times_redeemed: number;
- valid: true;
-}
-
-export interface Trial {
- selected?: boolean,
- type: string;
- startDate: Date,
- lastStartDate: Date,
- lastEndDate: Date,
- trialDays: number,
- byDate: Date
-}
-
-export interface TrialPmtPkg {
- package: string;
- addons: BasePackage[];
- pmtMethod?: {
- newPmtMeth?: CreatePaymentMethodPackage;
- exPmtMeth?: Card
- },
- mode: Mode;
- subIds?: string[];
- amount?: PaidAmount;
-}
-
-export interface TrialItem {
- description: string;
- amount: PriceUsd;
- trialEnd?: number;
- quantity: number;
- price?: {
- lookup_key: string;
- unit_amount: number;
- }
-}
-
-export interface StripeCard {
- cardNumber: StripeCardNumberElement;
- cardExpiry: StripeCardExpiryElement;
- cardCvc: StripeCardCvcElement;
-}
-
-export interface CardExp {
- expMonth: number;
- expYear: number;
-}
-
-export interface PMPkgEdit {
- pmId: string,
- name?: string,
- card?: CardExp,
- setDefault?: boolean
-}
-
-export interface PMPkgAdd {
- name: string;
- card: StripeCardElement;
- setDefault?: boolean;
-}
-
-export interface PkgValid {
- isValid: boolean;
- status?: Status;
-}
-
-export interface BillingInfoPackage { billingInfo?: BillingInfo, isNewAccount?: boolean }
-
-
-
-
-
-
-
-
diff --git a/Development/client/src/app/domain/resolvers/membership-resolver.ts b/Development/client/src/app/domain/resolvers/membership-resolver.ts
deleted file mode 100644
index 05920e0..0000000
--- a/Development/client/src/app/domain/resolvers/membership-resolver.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Resolve } from '@angular/router';
-import { Observable } from 'rxjs';
-import { first, map } from 'rxjs/operators';
-import { AuthService } from '../services/auth.service';
-import { CustomerService } from '../services/customer.service';
-import { IMembership } from '@app/auth/models/user.model';
-
-@Injectable()
-export class MembershipResolver implements Resolve {
- constructor(
- private readonly custSvc: CustomerService,
- private readonly authSvc: AuthService
- ) { }
-
- resolve(): Observable {
- const id = this.authSvc.user?.parent || this.authSvc.user._id;
- return this.custSvc.getCustomer(id).pipe(
- map((cust) => {
- const membership = cust?.membership;
- if (membership) {
- return membership;
- }
- }),
- first())
- }
-}
diff --git a/Development/client/src/app/domain/resolvers/profile-resolver.ts b/Development/client/src/app/domain/resolvers/profile-resolver.ts
deleted file mode 100644
index aaa3391..0000000
--- a/Development/client/src/app/domain/resolvers/profile-resolver.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Router, ActivatedRouteSnapshot, Resolve } from '@angular/router';
-
-import { Observable, forkJoin, of } from 'rxjs';
-import { map, first, switchMap } from 'rxjs/operators';
-
-import { User } from '@app/accounts/models/user.model';
-import { UserService } from '@app/domain/services/user.service';
-
-export interface UserWithParentUsername {
- user: User;
- parentUsername?: string;
-}
-
-@Injectable()
-export class ProfileResolver implements Resolve {
- constructor(
- private readonly router: Router,
- private readonly userService: UserService
- ) { }
-
- resolve(route: ActivatedRouteSnapshot): Observable {
- const id = route.paramMap.get('id');
- // view:'edit' → backend returns editable profile fields (name, phone, email, contact,
- // address, kind, active, username, password) but excludes membership/subscription data
- // which the form never needs and is expensive to populate.
- return this.userService.getUser(id, { view: 'edit' }).pipe(
- switchMap(user => {
- if (!user) {
- this.router.navigate(['/profile']);
- return of(null);
- }
- if (user.parent) {
- return this.userService.getUser(user.parent, { view: 'profile' }).pipe(
- map(parentUser => ({ user, parentUsername: parentUser?.username })),
- first()
- );
- } else {
- return of({ user });
- }
- }),
- first()
- );
- }
-}
diff --git a/Development/client/src/app/domain/resolvers/user-resolver.ts b/Development/client/src/app/domain/resolvers/user-resolver.ts
deleted file mode 100644
index 6c8fca4..0000000
--- a/Development/client/src/app/domain/resolvers/user-resolver.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Router, Resolve } from '@angular/router';
-import { Observable } from 'rxjs';
-import { map, first, switchMap } from 'rxjs/operators';
-import { User } from '@app/accounts/models/user.model';
-import { UserService } from '@app/domain/services/user.service';
-import { Store } from '@ngrx/store';
-import { selectAuthUser } from '@app/reducers';
-import { UserModel } from '@app/auth/models/user.model';
-
-@Injectable({ providedIn: 'root' })
-
-export class UserResolver implements Resolve {
- constructor(
- private readonly userService: UserService,
- private readonly router: Router,
- private readonly store: Store<{}>,
- ) { }
-
- resolve(): Observable {
- return this.store.select(selectAuthUser).pipe(
- switchMap((authUser: UserModel) => {
- return this.userService.getUser(authUser._id, { withAddresses: true })
- }),
- map((user: User) => {
- if (user) {
- return user;
- } else {
- this.router.navigate(['/profile']);
- }
- }),
- first()
- )
- }
-}
diff --git a/Development/client/src/app/domain/services/active-promo.service.ts b/Development/client/src/app/domain/services/active-promo.service.ts
deleted file mode 100644
index f043cba..0000000
--- a/Development/client/src/app/domain/services/active-promo.service.ts
+++ /dev/null
@@ -1,236 +0,0 @@
-import { Injectable } from '@angular/core';
-import { HttpClient, HttpErrorResponse } from '@angular/common/http';
-import { Observable, of, BehaviorSubject } from 'rxjs';
-import { shareReplay, map, catchError, switchMap, tap } from 'rxjs/operators';
-import { PromoTranslationService } from './promo-translation.service';
-
-/**
- * Active Promo interface matching backend GET /api/activePromos response
- * Note: couponId is intentionally NOT included (server-side only for security)
- */
-export interface ActivePromo {
- type: 'package' | 'addon';
- priceKey: string; // e.g., 'ess_1', 'addon_1'
- validUntil: string; // ISO date string
- name: string; // Display name (fallback)
- nameKey?: string; // i18n key e.g., 'PROMO_ADDON_FREE'
- descriptionKey?: string; // i18n key e.g., 'PROMO_ADDON_FREE_DESC'
- discountType: 'free' | 'percent' | 'fixed';
- discountValue: number; // 100 for free, 50 for 50%, 500 for $5.00
- // Optional expiry fields for time-limited promos (r948+ promoDetails)
- isTimeLimited?: boolean; // True if promo has expiry date
- daysRemaining?: number | null; // Days until expiry (null if not time-limited)
- isRenewalPromo?: boolean; // True if this is a Case 2B renewal offer (subscription has no promo, showing available promo)
-}
-
-/**
- * Response interface for /api/activePromos endpoint
- * Changed in r949 to include currentMode metadata
- */
-interface ActivePromoResponse {
- promos: ActivePromo[];
- currentMode: {
- mode: 'enabled' | 'disabled';
- description: string;
- isActive: boolean;
- };
-}
-
-/**
- * Service for fetching active subscription promos from the backend.
- * Used to display promo labels in manage-services and manage-subscription components.
- *
- * The backend returns only enabled promos with future validUntil dates,
- * without exposing sensitive couponId (coupon application happens server-side).
- */
-@Injectable({
- providedIn: 'root'
-})
-export class ActivePromoService {
- private readonly BASE_URL = '/activePromos';
-
- // Use BehaviorSubject to trigger fresh API calls when needed
- private refreshTrigger$ = new BehaviorSubject(0);
- private readonly activePromos$: Observable;
-
- // Store currentMode for components to use (optional feature)
- private currentModeSubject$ = new BehaviorSubject<{
- mode: string;
- description: string;
- isActive: boolean;
- } | null>(null);
-
- constructor(
- private readonly http: HttpClient,
- private readonly promoTranslationSvc: PromoTranslationService
- ) {
- // Create observable that refreshes when refreshTrigger$ emits
- this.activePromos$ = this.refreshTrigger$.pipe(
- switchMap(() => {
- return this.http.get(this.BASE_URL).pipe(
- map(response => {
- // Store currentMode for components to use
- this.currentModeSubject$.next(response.currentMode);
- return response.promos; // Extract promos array
- }),
- catchError(error => this.handleActivePromosError(error))
- );
- }),
- shareReplay(1) // Cache until next refresh
- );
- }
-
- /**
- * Handle errors from /api/activePromos endpoint
- * Returns empty promos with disabled mode to prevent component crashes
- *
- * Error Handling:
- * - 401 Unauthorized: Token expired or invalid (global interceptor handles logout)
- * - 403 Forbidden: User doesn't have permission
- * - 0 or 500+: Network or server errors
- * - Other: Unexpected errors
- */
- private handleActivePromosError(error: HttpErrorResponse): Observable {
- // 401 Unauthorized - Token expired or invalid
- if (error.status === 401) {
- console.error('[ActivePromoService] Authentication failed (401)', error);
- // User will be redirected to login by global HTTP interceptor
- // Return empty promos to prevent component errors
- this.currentModeSubject$.next(this.getDisabledPromoMode('Authentication required'));
- return of([]);
- }
-
- // 403 Forbidden - User doesn't have permission
- if (error.status === 403) {
- console.error('[ActivePromoService] Access denied (403)', error);
- this.currentModeSubject$.next(this.getDisabledPromoMode('Access denied'));
- return of([]);
- }
-
- // Network errors or server errors
- if (error.status === 0 || error.status >= 500) {
- console.error('[ActivePromoService] Network or server error', error);
- this.currentModeSubject$.next(this.getDisabledPromoMode('Service unavailable'));
- return of([]);
- }
-
- // Other errors - return empty to prevent crashes
- console.error('[ActivePromoService] Unexpected error', error);
- this.currentModeSubject$.next(this.getDisabledPromoMode('Promotions unavailable'));
- return of([]);
- }
-
- /**
- * Get disabled promo mode object for error states
- */
- private getDisabledPromoMode(description: string): {
- mode: string;
- description: string;
- isActive: boolean;
- } {
- return {
- mode: 'disabled',
- isActive: false,
- description: description
- };
- }
-
- /**
- * Force refresh of promo data from server
- * Invalidates cache and makes fresh API call
- */
- refresh(): void {
- this.refreshTrigger$.next(Date.now());
- }
-
- /**
- * Get all active promos (cached until refresh)
- */
- getActivePromos(): Observable {
- return this.activePromos$;
- }
-
- /**
- * Get promo for a specific priceKey (e.g., 'ess_1', 'addon_1')
- */
- getPromoForPriceKey(priceKey: string): Observable {
- return this.activePromos$.pipe(
- map(promos => promos.find(p => p.priceKey === priceKey))
- );
- }
-
- /**
- * Check if a priceKey has an active promo
- */
- hasPromo(priceKey: string): Observable {
- return this.activePromos$.pipe(
- map(promos => promos.some(p => p.priceKey === priceKey))
- );
- }
-
- /**
- * Get active promos with translated names (convenience method)
- */
- getActivePromosWithTranslations(): Observable<(ActivePromo & { translatedName: string; translatedDescription: string })[]> {
- return this.activePromos$.pipe(
- map(promos => promos.map(promo => ({
- ...promo,
- translatedName: this.promoTranslationSvc.getPromoName(promo),
- translatedDescription: this.promoTranslationSvc.getPromoDescription(promo)
- })))
- );
- }
-
- /**
- * Get current promo mode info
- * Returns null if not yet loaded
- *
- * Use this to check if promotions are globally enabled:
- * - mode='enabled': Promotions active and should be displayed
- * - mode='disabled': Promotions disabled (hide promo banners)
- *
- * @example
- * // In component:
- * this.activePromoSvc.getCurrentMode().subscribe(mode => {
- * if (mode && !mode.isActive) {
- * this.showPromoBanners = false; // Hide banners when mode='disabled'
- * }
- * });
- */
- getCurrentMode(): Observable<{
- mode: string;
- description: string;
- isActive: boolean;
- } | null> {
- return this.currentModeSubject$.asObservable();
- }
-
- /**
- * Format promo display text with translation support
- */
- formatPromoDisplayText(promo: ActivePromo): string {
- const translatedName = this.promoTranslationSvc.getPromoName(promo);
- return `${translatedName} - ${this.formatPromoDiscount(promo)}`;
- }
-
- /**
- * Format promo discount for display
- * Returns: "FREE", "50% OFF", "$10 OFF"
- */
- formatPromoDiscount(promo: ActivePromo): string {
- if (!promo) return '';
-
- switch (promo.discountType) {
- case 'free':
- return $localize`:Promo label for free items@@promoFree:FREE`;
- case 'percent':
- return `${promo.discountValue}% ` + $localize`:Promo label suffix@@promoOff:OFF`;
- case 'fixed':
- // discountValue is in cents, convert to dollars
- const dollars = promo.discountValue / 100;
- return `$${dollars} ` + $localize`:Promo label suffix@@promoOff:OFF`;
- default:
- return promo.name || '';
- }
- }
-}
diff --git a/Development/client/src/app/domain/services/app-config.service.ts b/Development/client/src/app/domain/services/app-config.service.ts
index dbad015..db34088 100644
--- a/Development/client/src/app/domain/services/app-config.service.ts
+++ b/Development/client/src/app/domain/services/app-config.service.ts
@@ -6,7 +6,7 @@ import { environment } from '@environments/environment';
import { AppMessageService } from '@app/shared/app-message.service';
import { AuthService } from './auth.service';
import { MatType } from '@app/shared/global';
-import { catchError, debounceTime, map } from 'rxjs/operators';
+import { catchError, map } from 'rxjs/operators';
import { of } from 'rxjs';
@Injectable({ providedIn: 'root' })
@@ -59,23 +59,13 @@ export class AppConfigService {
return this.http.get("/appConfig").pipe(
map(res => {
if (!environment.production)
- console.log("AppConfigService: App config loaded successfully!", res);
+ console.log("App config loaded !");
this.checkAndSetDefault(res);
return true;
}),
catchError(err => {
- console.error('AppConfigService: Failed to load app config:', err);
-
- // Check if request was cancelled
- if (err.name === 'AbortError' || err.message?.includes('cancel')) {
- this.appMsgSvc.addFailedMsg('App configuration request was cancelled. Using default settings.');
- } else {
- this.appMsgSvc.addFailedMsg('Could not load AppConfig. Please retry or contact Agnav.');
- }
-
- // Always set defaults and return true to prevent green screen
- this.checkAndSetDefault(null);
- return of(true);
+ this.appMsgSvc.addFailedMsg('Could not load AppConfig. Please retry or contact Agnav.');
+ return of(false);
})
);
}
@@ -155,19 +145,4 @@ export class AppConfigService {
});
});
}
-
- saveTrialDays(trialDays: Number[]) {
- return new Promise((resolve, reject) => {
- this.http.post("/appConfig", { trialDays }, { params: new HttpParams().set('loader', 'false') }).subscribe({
- next: (res: { trialDays: Number[] }) => {
- this.settings = { ...this._settings, trialDays: res.trialDays };
- resolve(true);
- },
- error: (err) => {
- this.appMsgSvc.addFailedMsg('Could not save AppConfig. Please retry or contact Agnav.');
- reject(`Could not save AppConfig !': ${JSON.stringify(err)}`);
- }
- });
- });
- }
}
diff --git a/Development/client/src/app/domain/services/auth-interceptor.service.ts b/Development/client/src/app/domain/services/auth-interceptor.service.ts
index 58e72fd..e290ec2 100644
--- a/Development/client/src/app/domain/services/auth-interceptor.service.ts
+++ b/Development/client/src/app/domain/services/auth-interceptor.service.ts
@@ -1,5 +1,6 @@
import { Injectable, Injector } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse, HttpHeaders } from '@angular/common/http';
+
import { Observable, throwError } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
@@ -49,11 +50,20 @@ export class AuthInterceptor implements HttpInterceptor {
if (showLoading) {
this.loaderSvc.show();
this.requests.push(authReq);
+ // console.log("Num of loading reqs:", this.requests.length);
}
// Pass on the cloned request instead of the original request.
return next.handle(authReq).pipe(
map((event: HttpEvent) => {
+ // if (event instanceof HttpResponse) {
+ // const agmTk = event.headers.get('Agm-TK');
+ // if ((agmTk && this.authSvc.token) && (this.authSvc.token.t != agmTk)) {
+ // const newTk = this.authSvc.token;
+ // newTk.t = agmTk;
+ // this.authSvc.token = newTk;
+ // }
+ // }
return event; // Should always return the response event untouched as metioned in 'HttpEvents' section at https://angular.io/guide/http
}),
catchError(err => this.onCatch(err, req)),
@@ -64,18 +74,12 @@ export class AuthInterceptor implements HttpInterceptor {
removeRequest(req: HttpRequest) {
const i = this.requests.indexOf(req);
(i >= 0) && (this.requests.splice(i, 1));
-
- const val = Boolean(this.requests.length > 0);
- this.loaderSvc.loading$.next(val);
+ this.loaderSvc.loading$.next(this.requests.length > 0);
+ // console.log("Num of loading reqs:", this.requests.length);
}
private onCatch(err: any, req: HttpRequest): Observable {
- // Don't logout on partner API errors - these are partner credential tests, not user session errors
- const isPartnerApiError = req.url.includes('/partners/systemUsers/testAuth')
- || req.url.includes('/partners/aircraft');
-
- if ([401, 403].indexOf(err.status) != -1 && !req.url.endsWith('/login') && !isPartnerApiError) {
- // JWT expired or invalid token responded from BE, force logOut
+ if ([401, 403].indexOf(err.status) != -1 && !req.url.endsWith('/login')) { // JWT expired or invalid token responded from BE, force logOut
this.store.dispatch(new authActions.Logout(true));
}
return throwError(err);
diff --git a/Development/client/src/app/domain/services/auth.service.ts b/Development/client/src/app/domain/services/auth.service.ts
index 7fdf0be..666a538 100644
--- a/Development/client/src/app/domain/services/auth.service.ts
+++ b/Development/client/src/app/domain/services/auth.service.ts
@@ -2,27 +2,20 @@ import { Injectable, OnDestroy, Inject, LOCALE_ID } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, throwError, Subscription } from 'rxjs';
-import { exhaustMap, tap, catchError } from 'rxjs/operators';
+import { exhaustMap } from 'rxjs/operators';
-import { DateUtils, Utils } from '../../shared/utils';
+import { Utils } from '../../shared/utils';
import { RoleIds } from '../../shared/global';
import { Store } from '@ngrx/store';
import * as fromStore from '../../reducers';
import { UserModel } from '../../auth/models/user.model';
import { Authenticate } from '../../auth/models/auth.model';
-import { AGNavSubscription, PriceUsd, Trial } from '../models/subscription.model';
-import { Mode, SUB, SubStripe, SubType } from '@app/profile/common';
-import { SubscriptionService } from './subscription.service';
-import { GAService } from '../../shared/ga.service';
-import { GAAnalyticsHelpersService } from '../../shared/ga.analytics-helpers.service';
@Injectable({ providedIn: 'root' })
export class AuthService implements OnDestroy {
- private _user: UserModel;
- private _sessionStartTime: number;
-
- get user(): UserModel {
+ private _user: any;
+ get user(): any {
return this._user;
}
@@ -52,9 +45,6 @@ export class AuthService implements OnDestroy {
@Inject(LOCALE_ID) private localeId: string,
private readonly store: Store<{}>,
private readonly http: HttpClient,
- private subSvc: SubscriptionService,
- private readonly gaService: GAService,
- private readonly gaHelpers: GAAnalyticsHelpersService,
) {
this._locale = Utils.getLang(this.localeId) || 'en';
this._tk = JSON.parse(sessionStorage.getItem('cT'));
@@ -73,10 +63,6 @@ export class AuthService implements OnDestroy {
return this.hasRole([RoleIds.APP]);
}
- get isAppAdm() {
- return this.hasRole([RoleIds.APP_ADM]);
- }
-
get isClientUser() {
return this.hasRole([RoleIds.CLIENT]);
}
@@ -89,14 +75,6 @@ export class AuthService implements OnDestroy {
return this.hasRole([RoleIds.INSPECTOR]);
}
- get isPartner(): boolean {
- return this.hasRole([RoleIds.PARTNER]);
- }
-
- hasSubsWithStatus(status: string) {
- return this.user?.membership?.subscriptions?.some((sub) => sub.status === `${status}`);
- }
-
getAuthHeader(): string {
return this.user && this.token ? 'Bearer ' + this.token.t : '';
}
@@ -106,32 +84,7 @@ export class AuthService implements OnDestroy {
}
hasRole(roles: string[]): boolean {
- return this.loggedIn && (roles && Utils.containsAny(roles, this.user?.roles));
- }
-
- hasAppRoleAndSub(): boolean {
- return this.isApplicator && this.hasSubs();
- }
-
- hasSubs() {
- return this.user?.membership?.subscriptions?.length > 0;
- }
-
- getSub(lookupKey: string): AGNavSubscription {
- if (this.hasSubs) return this.user?.membership?.subscriptions?.find((sub) => sub.items?.some((item) => item.price === lookupKey));
- }
-
- getCurLookupKey(type: SubType.PACKAGE | SubType.ADDON): PriceUsd {
- // Use centralized utility methods
- const subscriptions = this.user?.membership?.subscriptions;
- switch (type) {
- case SubType.PACKAGE:
- return this.subSvc.getCurrentPackageLookupKey(subscriptions) || '';
- case SubType.ADDON:
- return this.subSvc.getCurrentAddonLookupKey(subscriptions) || '';
- default:
- throw new Error('Unsupported type');
- }
+ return this.loggedIn && (roles && Utils.containsAny(roles, this.user.roles));
}
get isPlanner() {
@@ -142,10 +95,6 @@ export class AuthService implements OnDestroy {
return (this.user && this.user.billable);
}
- get isCanada(): boolean {
- return this.user?.country === 'CA';
- }
-
/**
* Parent user, to mange items under an applicator user
*/
@@ -169,45 +118,18 @@ export class AuthService implements OnDestroy {
throwError('invalid_account');
// Store username and jwt token in local storage to keep user logged in between page refreshes
- const user = { _id: res['_id'], username: auth.username, billable: res['billable'], roles: res['roles'], parent: (res['pui'] || ''), lang: res['lang'] || 'en', pre: res['pre'], membership: res['membership'], contact: res['contact'] || '', country: res['country'] || '' };
- this._user = user;
+ const user = { _id: res['_id'], username: auth.username, billable: res['billable'], roles: res['roles'], parent: (res['pui'] || ''), lang: res['lang'] || 'en', pre: res['pre'] };
+
this.token = { t: res['token'], rt: res['rt'] };
-
- // Track session start time
- this._sessionStartTime = Date.now();
-
- // Track login event
- this.gaService.trackLogin({
- user_id: user._id,
- user_role: this.gaHelpers.getUserRole(user.roles),
- method: 'email',
- platform: 'web'
- });
-
return of(user);
- }),
+ })
);
}
logout(gotoLogin: boolean = true): Observable {
- // Track logout event before clearing session data
- if (this._user && this._sessionStartTime) {
- const sessionDuration = Math.round((Date.now() - this._sessionStartTime) / 60000); // Convert to minutes
- this.gaService.trackLogout({
- user_id: this._user._id,
- user_role: this.gaHelpers.getUserRole(this._user.roles),
- session_duration_minutes: sessionDuration,
- logout_method: 'manual',
- platform: 'web',
- page_location: window.location.href
- });
- }
-
sessionStorage.clear();
- localStorage.removeItem('requiredSubAttention');
this._user = null;
this._tk = null;
- this._sessionStartTime = null;
return of(true);
}
@@ -220,110 +142,15 @@ export class AuthService implements OnDestroy {
}
mailPwdReset(ops) {
- return this.http.post('/users/mailPwdReset', ops).pipe(
- tap(response => {
- // Track password reset request
- this.gaService.trackPasswordResetRequested({
- request_method: 'forgot_password_page',
- user_exists: true, // If we get a success response, user exists
- platform: 'web'
- });
- }),
- catchError(error => {
- // Track password reset request failure
- this.gaService.trackPasswordResetRequested({
- request_method: 'forgot_password_page',
- user_exists: false, // If we get an error, user may not exist
- platform: 'web'
- });
- return throwError(error);
- })
- );
+ return this.http.post('/users/mailPwdReset', ops);
}
- validateResetPassword(ops) {
- return this.http.post('/users/resetPassword/validate', ops);
+ resetPassword(ops) {
+ return this.http.get(`/users/resetPassword/${ops.id}/${ops.token}`);
}
changePassword(ops) {
- return this.http.post('/users/resetPassword', ops).pipe(
- tap(response => {
- // Track password reset completion
- this.gaService.trackPasswordResetCompleted({
- success: true,
- reset_token_age_minutes: 0, // Token age info not available in current implementation
- platform: 'web'
- });
- }),
- catchError(error => {
- // Track password reset completion failure
- this.gaService.trackPasswordResetCompleted({
- success: false,
- reset_token_age_minutes: 0, // Token age info not available
- failure_reason: 'other',
- platform: 'web'
- });
- return throwError(error);
- })
- );
- }
-
- get trials() {
- return this.user?.membership?.trials;
- }
-
- hasActiveTrial(trials: Trial) {
- if (!trials || !this.hasSubsWithStatus(SubStripe.TRIALING)) return false;
- return trials.lastStartDate && DateUtils.currUTC() <= DateUtils.dateToTS(new Date(trials.lastEndDate))
- || this.hasSubsWithStatus(SubStripe.TRIALING);
- }
-
- hasLastEndedTrial(trials: Trial) {
- if (!trials) return false;
- return trials.lastStartDate && trials.lastEndDate;
- }
-
- isTrialDays(trials: Trial) {
- return trials?.trialDays > 1;
- }
-
- hasValidTrialOffer(trials: Trial) {
- return !!trials.byDate || this.isTrialDays(trials);
- }
-
- validateTrial(trials: Trial) {
- if (!trials || !trials.type) return false;
-
- let isWithinTrialPeriod: boolean = false;
- if (this.hasValidTrialOffer(trials)) {
- if (trials.byDate) {
- isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(new Date(trials.byDate));
- } else if (this.isTrialDays(trials)) {
- const trialEndDate = new Date(trials.startDate);
- trialEndDate.setDate(trialEndDate.getDate() + trials.trialDays);
- isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(trialEndDate);
- }
- }
- return this.hasRole([RoleIds.APP])
- && !this.hasSubs()
- && isWithinTrialPeriod;
- }
-
- canDisplayTrial(trials: Trial) {
- return this.validateTrial(trials);
- }
-
- canAcceptTrial(url: string) {
- return !url.includes(SUB.MY_SERVICES)
- && this.subSvc.subMode !== Mode.TRIALING;
- }
-
- get canActivateVehicle() {
- return this.isApplicator;
- }
-
- get canAccessInvoice() {
- return this.isApplicator;
+ return this.http.post('/users/resetPassword', ops);
}
ngOnDestroy(): void {
diff --git a/Development/client/src/app/domain/services/client.service.ts b/Development/client/src/app/domain/services/client.service.ts
index acf25c9..e74b627 100644
--- a/Development/client/src/app/domain/services/client.service.ts
+++ b/Development/client/src/app/domain/services/client.service.ts
@@ -5,7 +5,7 @@ import { Observable } from 'rxjs';
import { Store } from '@ngrx/store';
import { Client } from '../../client/models/client.model';
-import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
+import {CustomerInvoiceSetting} from '@app/invoices/models/customer-invoice-setting.model';
@Injectable()
export class ClientService {
@@ -46,13 +46,10 @@ export class ClientService {
return this.http.delete(`${this.clientURL}/${client._id}`);
}
- searchWithSettings(byPuid: string): Observable {
- return this.http.post(`${this.clientURL}/searchWithSettings`, { byPuid });
- }
}
export interface LoadClientOps {
- byPuid: string;
+ byUserId: string;
}
export interface ClientWithSetting extends Client {
diff --git a/Development/client/src/app/domain/services/customer.service.ts b/Development/client/src/app/domain/services/customer.service.ts
index cae504a..10bdafd 100644
--- a/Development/client/src/app/domain/services/customer.service.ts
+++ b/Development/client/src/app/domain/services/customer.service.ts
@@ -1,14 +1,19 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
+
import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
import { Customer } from '../../customers/models/customer.model';
+import { Store } from '@ngrx/store';
+
@Injectable()
export class CustomerService {
private readonly customerURL = '/customers';
constructor(
+ private store: Store<{}>,
private http: HttpClient
) {
}
@@ -17,9 +22,8 @@ export class CustomerService {
return this.http.get(this.customerURL);
}
- getCustomer(id: string, view?: string): Observable {
- const url = view ? `${this.customerURL}/${id}?view=${view}` : `${this.customerURL}/${id}`;
- return this.http.get(url);
+ getCustomer(id: string): Observable {
+ return this.http.get(`${this.customerURL}/${id}`);
}
saveCustomer(customer: Customer): Observable {
diff --git a/Development/client/src/app/domain/services/global-error.interceptor.ts b/Development/client/src/app/domain/services/global-error.interceptor.ts
deleted file mode 100644
index ab87218..0000000
--- a/Development/client/src/app/domain/services/global-error.interceptor.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { Injectable } from '@angular/core';
-import {
- HttpRequest,
- HttpHandler,
- HttpEvent,
- HttpInterceptor,
- HttpErrorResponse,
- HttpResponse
-} from '@angular/common/http';
-import { Observable, throwError } from 'rxjs';
-import { catchError, tap } from 'rxjs/operators';
-import { AppMessageService } from '@app/shared/app-message.service';
-import { globals } from '@app/shared/global';
-import { environment } from '@environments/environment';
-import { AppInjector } from '@app/app-injector';
-import { GAService } from '@app/shared/ga.service';
-
-@Injectable()
-export class GlobalErrorInterceptor implements HttpInterceptor {
- private failedAttempts = 0;
- private gaSvc: GAService;
-
- constructor(private readonly msgSvc: AppMessageService) {
- // Use AppInjector to get GAService to avoid circular dependency
- this.gaSvc = AppInjector.getInjector().get(GAService);
- }
-
- intercept(req: HttpRequest, next: HttpHandler): Observable> {
- const startTime = Date.now();
-
- return next.handle(req).pipe(
- tap(event => {
- // Track successful but slow API responses
- if (event instanceof HttpResponse) {
- const responseTime = Date.now() - startTime;
-
- // Track slow API responses (threshold: 2 seconds)
- if (responseTime > 2000) {
- this.trackSlowApiResponse(req, event, responseTime);
- }
- }
- }),
- catchError((error: HttpErrorResponse) => {
- const responseTime = Date.now() - startTime;
-
- // Track HTTP error event
- this.trackHttpError(error, req, responseTime);
-
- if (error.status >= 500 && error.status < 600) {
- this.failedAttempts++;
- if (this.failedAttempts >= environment.failedRqAttempts) {
- this.msgSvc.addFailedMsg(globals.server500Err);
- this.failedAttempts = 0; // Reset counter after showing the error
- }
- }
- return throwError(error);
- })
- );
- }
-
- private trackHttpError(error: HttpErrorResponse, req: HttpRequest, responseTime: number): void {
- const errorType = this.categorizeError(error);
- const endpoint = this.extractEndpoint(req.url);
-
- this.gaSvc.trackEvent('http_error', {
- platform: 'web',
- error_type: errorType,
- http_status_code: error.status || 0,
- error_message: error.message || 'Unknown HTTP error',
- request_method: req.method as any,
- request_url: req.url,
- request_endpoint: endpoint,
- response_time_ms: responseTime,
- affected_feature: this.extractFeature(endpoint)
- });
- }
-
- private trackSlowApiResponse(req: HttpRequest, response: HttpResponse, responseTime: number): void {
- const endpoint = this.extractEndpoint(req.url);
-
- this.gaSvc.trackEvent('api_response_slow', {
- platform: 'web',
- api_endpoint: endpoint,
- response_time_ms: responseTime,
- payload_size: this.getPayloadSize(response),
- cache_hit: this.isCacheHit(response)
- });
- }
-
- private categorizeError(error: HttpErrorResponse): 'network_error' | 'server_error' | 'client_error' | 'timeout' | 'unknown_error' {
- if (error.status === 0 || error.status === -1) {
- return 'network_error';
- }
- if (error.status >= 500) {
- return 'server_error';
- }
- if (error.status >= 400 && error.status < 500) {
- return 'client_error';
- }
- if (error.status === 408 || error.message?.includes('timeout')) {
- return 'timeout';
- }
- return 'unknown_error';
- }
-
- private extractEndpoint(url: string): string {
- try {
- const pathname = new URL(url, window.location.origin).pathname;
- return pathname.replace(/^\/api/, '').split('/')[1] || 'unknown';
- } catch {
- return url.split('/')[1] || 'unknown';
- }
- }
-
- private extractFeature(endpoint: string): string {
- const featureMap: { [key: string]: string } = {
- 'jobs': 'job_management',
- 'invoices': 'billing',
- 'reports': 'reporting',
- 'files': 'file_management',
- 'users': 'user_management',
- 'auth': 'authentication',
- 'customers': 'customer_management',
- 'equipment': 'equipment_management'
- };
- return featureMap[endpoint] || 'unknown';
- }
-
- private getPayloadSize(response: HttpResponse): number | undefined {
- try {
- const contentLength = response.headers.get('content-length');
- if (contentLength) {
- return parseInt(contentLength, 10);
- }
-
- // Fallback: estimate payload size from response body
- if (response.body) {
- const bodyString = JSON.stringify(response.body);
- return new Blob([bodyString]).size;
- }
- } catch (error) {
- // Ignore errors in payload size calculation
- }
- return undefined;
- }
-
- private isCacheHit(response: HttpResponse): boolean | undefined {
- // Check common cache indicators in response headers
- const cacheControl = response.headers.get('cache-control');
- const etag = response.headers.get('etag');
- const lastModified = response.headers.get('last-modified');
- const xCache = response.headers.get('x-cache');
-
- // Check for explicit cache hit indicators
- if (xCache?.includes('HIT')) {
- return true;
- }
-
- // If response has cache headers but no explicit hit indicator, likely cached
- if (cacheControl && (etag || lastModified)) {
- return true;
- }
-
- return undefined; // Unknown cache status
- }
-}
diff --git a/Development/client/src/app/domain/services/invoice.service.ts b/Development/client/src/app/domain/services/invoice.service.ts
index 10256cd..eb34a28 100644
--- a/Development/client/src/app/domain/services/invoice.service.ts
+++ b/Development/client/src/app/domain/services/invoice.service.ts
@@ -158,7 +158,7 @@ export class InvoiceService {
return jobCostings.reduce((acc, jobCosting) => {
const items = jobCosting?.costings?.items?.map(item => ({
- job: jobCosting.job,
+ id: jobCosting.job,
jobName: jobCosting.name,
costingName: item.name,
quantity: item.quantity,
@@ -197,17 +197,15 @@ export class InvoiceService {
};
private calculateSubTotal(client: Client, totalJobAmount?: number): number {
- const split = client?.split ? +client.split : 100;
return totalJobAmount
- ? totalJobAmount * (Number(split) / 100)
+ ? totalJobAmount * (Number(client.split) / 100)
: client.subTotal
? Number(client.subTotal)
: 0;
}
private calculateDiscounted(subTotal: number, client: Client): number {
- const discount = client?.discount ? +client.discount : 0;
- return subTotal * (discount / 100);
+ return subTotal * (+client.discount / 100);
}
private calculateTotalExcludingTax(subTotal: number, discounted: number): number {
@@ -215,8 +213,7 @@ export class InvoiceService {
}
private calculateTaxed(totalExcludingTax: number, client: Client): number {
- const taxRate = client?.taxRate ? +client.taxRate : 0;
- return totalExcludingTax * (taxRate / 100);
+ return totalExcludingTax * (+client.taxRate / 100);
}
private calculateTotal(totalExcludingTax: number, taxed: number): number {
@@ -250,6 +247,10 @@ export class InvoiceService {
};
const payment = this.calculateClientPayment(client, subTotalAfterSplit);
+ if (print.client.paymentTerm) {
+ print.paymentTerm = print.client.paymentTerm;
+ }
+
return {
...print,
jobItems,
diff --git a/Development/client/src/app/domain/services/job.service.ts b/Development/client/src/app/domain/services/job.service.ts
index 37ef4e4..15bea3f 100644
--- a/Development/client/src/app/domain/services/job.service.ts
+++ b/Development/client/src/app/domain/services/job.service.ts
@@ -4,6 +4,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
+import { Store } from '@ngrx/store';
import { IJob, IUIJob, JobLog, RptOption, toJob } from '../../job/models/job.model';
import { AppFile } from '../models/shared.model';
import { UpdateJobOps } from '../../job/actions/job.actions';
@@ -14,26 +15,13 @@ export class JobService {
private readonly jobURL = '/jobs';
constructor(
+ private store: Store<{}>,
private http: HttpClient
) {
}
loadJobs(ops: any): Observable {
- let _ops = new HttpParams()
- .set('clientId', ops?.clientId || '')
- .set('jpo', ops?.jobsByPilot || 'false')
- .set('status', ops?.status || '');
-
- if (ops?.byTime?.length === 2) {
- for (const time of ops.byTime) {
- if (time) {
- _ops = _ops.append('byTime', time.toISOString());
- }
- }
- } else {
- _ops = _ops.append('byTime', ops?.byTime[0] || '');
- }
-
+ const _ops = new HttpParams().set('clientId', ops && ops.clientId).set('jpo', (ops && ops.jobsByPilot) || 'false');
return this.http.get(this.jobURL, { params: _ops });
}
@@ -117,10 +105,6 @@ export class JobService {
return this.http.post(`${this.jobURL}/deleteAppFile`, options, { params: new HttpParams().set('loader', 'false') });
}
- fetchInvReadyJobs(excludeIds?: string[]): Observable {
- return this.http.post(`${this.jobURL}/fetchInvReadyJobs`, { excludeIds });
- }
-
downloadAppFile(fname) {
let httpParams = new HttpParams().set('file', fname);
return this.http.get('/exports/downloadAppfile', { params: httpParams, responseType: 'arraybuffer' }).pipe(
@@ -187,24 +171,8 @@ export class JobService {
return this.http.post(`${this.jobURL}/appFiles`, { jobId: jobId });
}
- getFilesData(fileId: string, params?: {
- limit?: number,
- startingAfter?: string,
- endingBefore?: string,
- returnAll?: boolean
- }) {
- const body: any = {
- fileId: fileId
- };
-
- if (params) {
- if (params.limit !== undefined) body.limit = params.limit;
- if (params.startingAfter !== undefined) body.startingAfter = params.startingAfter;
- if (params.endingBefore !== undefined) body.endingBefore = params.endingBefore;
- if (params.returnAll !== undefined) body.returnAll = params.returnAll;
- }
-
- return this.http.post(`${this.jobURL}/filesdata`, body);
+ getFilesData(ids) {
+ return this.http.post(`${this.jobURL}/filesdata`, { fileIds: ids });
}
}
diff --git a/Development/client/src/app/domain/services/managehttp.interceptor.service.ts b/Development/client/src/app/domain/services/managehttp.interceptor.service.ts
index b2d9219..949ecbf 100644
--- a/Development/client/src/app/domain/services/managehttp.interceptor.service.ts
+++ b/Development/client/src/app/domain/services/managehttp.interceptor.service.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
-import { Router, NavigationStart } from '@angular/router';
+import { Router, ActivationEnd } from '@angular/router';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable } from 'rxjs';
@@ -11,33 +11,18 @@ import { HttpCancelService } from './httpcancel.service';
@Injectable()
export class ManageHttpInterceptor implements HttpInterceptor {
- private currentUrl: string = '';
constructor(private readonly router: Router, private readonly httpCancelService: HttpCancelService) {
router.events.subscribe(event => {
- // Only cancel on actual route changes, not during guard/resolver execution
- if (event instanceof NavigationStart) {
- // Check if this is actually a new route, not just a reload or guard execution
- if (this.currentUrl && event.url !== this.currentUrl) {
- this.httpCancelService.cancelPendingRequests();
- }
- this.currentUrl = event.url;
+ // An event triggered at the end of the activation part of the Resolve phase of routing.
+ if (event instanceof ActivationEnd) {
+ // Cancel pending calls
+ this.httpCancelService.cancelPendingRequests();
}
});
}
intercept(req: HttpRequest, next: HttpHandler): Observable> {
- // Don't cancel critical configuration and authentication requests
- const isCriticalRequest = req.url.includes('/appConfig') ||
- req.url.includes('/login') ||
- req.url.includes('/auth') ||
- req.url.includes('/ping');
-
- if (isCriticalRequest) {
- // Critical requests should not be cancelled by route changes
- return next.handle(req);
- }
-
return next.handle(req).pipe(takeUntil(this.httpCancelService.onCancelPendingRequests()))
}
}
\ No newline at end of file
diff --git a/Development/client/src/app/domain/services/promo-translation.service.ts b/Development/client/src/app/domain/services/promo-translation.service.ts
deleted file mode 100644
index 2c8209f..0000000
--- a/Development/client/src/app/domain/services/promo-translation.service.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { Injectable } from '@angular/core';
-import { PromoLabels } from 'src/app/profile/common';
-import { ActivePromo } from './active-promo.service';
-
-@Injectable({
- providedIn: 'root'
-})
-export class PromoTranslationService {
-
- /**
- * Get translated promo name with fallback to static name
- */
- getPromoName(promo: ActivePromo): string {
- if (promo.nameKey && PromoLabels[promo.nameKey]) {
- return PromoLabels[promo.nameKey];
- }
- return promo.name; // Fallback to static name
- }
-
- /**
- * Get translated promo description with fallback to static name
- */
- getPromoDescription(promo: ActivePromo): string {
- if (promo.descriptionKey && PromoLabels[promo.descriptionKey]) {
- return PromoLabels[promo.descriptionKey];
- }
- // Fallback: use name as description if no descriptionKey translation
- return this.getPromoName(promo);
- }
-
- /**
- * Check if promo has translation keys available
- */
- hasTranslation(promo: ActivePromo): boolean {
- return !!(promo.nameKey && PromoLabels[promo.nameKey]);
- }
-}
\ No newline at end of file
diff --git a/Development/client/src/app/domain/services/subscription.service.ts b/Development/client/src/app/domain/services/subscription.service.ts
deleted file mode 100644
index 1f4575f..0000000
--- a/Development/client/src/app/domain/services/subscription.service.ts
+++ /dev/null
@@ -1,1229 +0,0 @@
-import { Injectable } from '@angular/core';
-import { HttpClient, HttpParams } from '@angular/common/http';
-import { environment } from '@environments/environment';
-import { Observable, of, Subject, Subscription, throwError } 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, ExpiryWarning } 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, UNLIMITED } from '@app/profile/common';
-import { map, switchMap, tap, catchError } 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);
- }
-
- /**
- * Check subscription status (for polling after 3DS completion per r944)
- *
- * @param subscriptionId Stripe subscription ID (sub_xxxxx)
- * @returns Observable with subscription status from Stripe
- */
- checkSubscriptionStatus(subscriptionId: string): Observable {
- if (!subscriptionId || !subscriptionId.startsWith('sub_')) {
- console.error('❌ Invalid subscription ID:', subscriptionId);
- return throwError(new Error('Invalid subscription ID'));
- }
-
- return this.http.get(`${BASE_URL}/status/${subscriptionId}`).pipe(
- catchError((error) => {
- console.error('❌ Status check error:', error);
- return throwError(error);
- })
- );
- }
-
- 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
- }).pipe(
- map(subs => this.normalizeSubscriptionStructure(subs))
- );
- }
-
- /**
- * Normalize simplified backend subscription structure to full Stripe structure
- * Backend returns simplified format from _toMembershipSubscription():
- * - items: [] (flat array)
- * - periodEnd/periodStart instead of current_period_end/current_period_start
- * - cancelAtPeriodEnd instead of cancel_at_period_end
- * This method transforms it to match the full Stripe API structure expected by frontend
- */
- private normalizeSubscriptionStructure(subs: any[]): StripeSubscription[] {
- return subs.map(sub => {
- // If already in full format, return as-is
- if (sub.items?.data) {
- return sub;
- }
-
- // Transform simplified format to full Stripe structure
- return {
- id: sub.id,
- object: 'subscription',
- status: sub.status,
- current_period_start: sub.periodStart || sub.current_period_start,
- current_period_end: sub.periodEnd || sub.current_period_end,
- cancel_at_period_end: sub.cancelAtPeriodEnd !== undefined ? sub.cancelAtPeriodEnd : sub.cancel_at_period_end,
- cancel_at: sub.cancelAt || sub.cancel_at,
- trial_end: sub.trialEnd || sub.trial_end,
- metadata: {
- type: sub.type,
- scheduleId: sub.scheduleId,
- ...(sub.metadata || {})
- },
- items: {
- object: 'list',
- data: (sub.items || []).map(item => ({
- object: 'subscription_item',
- price: {
- lookup_key: typeof item.price === 'string' ? item.price : item.price?.lookup_key,
- metadata: item.metadata || {},
- recurring: sub.recurring || { interval: 'month', interval_count: 1 }
- },
- quantity: item.quantity || 1
- }))
- },
- // Preserve recurring info
- plan: sub.recurring ? {
- interval: sub.recurring.interval,
- interval_count: sub.recurring.intervalCount || sub.recurring.interval_count
- } : undefined,
- // Fill in optional fields that may not be present
- latest_invoice: undefined,
- default_payment_method: undefined,
- default_source: undefined,
- quantity: undefined
- } as StripeSubscription;
- });
- }
-
- getCoupon(coupon: string, priceKeys?: string[]): Observable {
- let url = `${BASE_URL}/getCoupon/${coupon}`;
-
- // Add price keys as query params for product restriction validation
- if (priceKeys && priceKeys.length > 0) {
- const params = new HttpParams().set('priceKeys', priceKeys.join(','));
- return this.http.get(url, { params });
- }
-
- return this.http.get(url);
- }
-
- 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
- }
- });
- }
-
- // ============================================================================
- // SUBSCRIPTION STATE HELPERS
- // ============================================================================
-
- /**
- * Determine if subscription will cancel at period end.
- * @param sub - StripeSubscription object
- * @returns true if subscription will cancel at period end
- */
- willSubscriptionCancel(sub: StripeSubscription): boolean {
- return sub?.cancel_at_period_end ?? false;
- }
-
- /**
- * Get the cancellation date for a subscription.
- * @param sub - StripeSubscription object
- * @returns Date when subscription will cancel, or null if not canceling
- */
- getCancellationDate(sub: StripeSubscription): Date | null {
- if (sub?.cancel_at_period_end && sub?.current_period_end) {
- return new Date(sub.current_period_end * 1000);
- }
- return null;
- }
-
- /**
- * Check if subscription has a promo applied.
- * Checks for promoId in metadata OR discount coupon presence.
- * @param sub - StripeSubscription object
- * @returns true if subscription has an active promo
- */
- hasSubscriptionPromo(sub: StripeSubscription): boolean {
- return !!sub?.metadata?.promoId || !!sub?.discount;
- }
-
- /**
- * Get promo display info from subscription promoDetails (r955+).
- * @param sub - StripeSubscription object
- * @returns Promo info or null if no promo
- * @since r955 - Updated to use promoDetails instead of deprecated discount field
- */
- getSubscriptionPromoDiscount(sub: StripeSubscription): { name: string; percentOff?: number; amountOff?: number } | null {
- if (!sub?.promoDetails?.hasPromo) return null;
-
- // Parse discount value from discountDisplay (e.g., "50% OFF" or "FREE")
- const discountDisplay = sub.promoDetails.discountDisplay;
- const percentMatch = discountDisplay?.match(/(\d+)%/);
- const percentOff = percentMatch ? parseInt(percentMatch[1]) : (discountDisplay?.includes('FREE') ? 100 : null);
-
- return {
- name: sub.promoDetails.name || 'Promo',
- percentOff: percentOff || undefined,
- amountOff: undefined // Backend no longer provides amount_off
- };
- }
-
- /**
- * Calculate total promo savings from line items and active promos.
- * This is the SINGLE SOURCE OF TRUTH for promo savings calculations.
- *
- * CALCULATION ORDER (CRITICAL - WI-2804):
- * 1. Apply discount at native billing interval (monthly = monthly, annual = annual)
- * 2. No annualization - show what customer actually pays
- *
- * This matches Stripe's actual billing behavior and non-promo display format.
- * Uses Stripe lineItems (cents-based) for precision and consistency.
- *
- * @param lineItems - Stripe invoice line items (payment or refund)
- * @param promos - Map of lookup_key to ActivePromo objects
- * @returns Total promo savings in cents (at native billing interval)
- *
- * @example
- * // In checkout component:
- * const savings = this.subSvc.calculatePromoSavings(
- * this.chkoutPmt?.payment?.lineItems,
- * this.paymentPromos
- * );
- *
- * // Example: ESS_2 + Addon + 50% promo
- * // ESS_2 (annual): $2,495 × 50% = $1,247.50 savings (annual)
- * // Addon (monthly): $49.95 × 50% = $24.97 savings (monthly, not annualized)
- * // Total savings: $1,272.47 (mixed interval - show separately)
- */
- calculatePromoSavings(lineItems: any[], promos: Map): number {
- if (!lineItems || lineItems.length === 0 || !promos || promos.size === 0) {
- return 0;
- }
-
- let totalSavings = 0;
-
- lineItems.forEach((item: any) => {
- // Skip proration credit lines — these are refunds for old quantities where
- // the user already benefited from the promo on a prior invoice.
- // Identified by: proration=true AND credited_items is not null.
- if (item.proration && item.proration_details?.credited_items != null) {
- return;
- }
-
- const lookupKey = item.price?.lookup_key;
- const promo = promos.get(lookupKey);
-
- if (promo && item.price?.unit_amount) {
- // Get original amount at native billing interval
- const originalAmount = item.price.unit_amount * (item.quantity || 1);
- let savings = 0;
-
- // Calculate savings at native interval (no annualization)
- if (promo.discountType === 'free' || promo.discountValue === 100) {
- savings = originalAmount; // 100% off
- } else if (promo.discountType === 'percent') {
- savings = Math.round(originalAmount * (promo.discountValue / 100));
- } else if (promo.discountType === 'fixed') {
- // discountValue is already in cents (e.g., 15000 = $150.00)
- // item.price.unit_amount is also in cents
- // Cap discount at original amount to prevent negative prices
- savings = Math.min(originalAmount, promo.discountValue);
- }
-
- totalSavings += savings;
- }
- });
-
- return totalSavings;
- }
-
- /**
- * Calculate discounted amount for a single item with promo applied
- * CENTRALIZED METHOD - All components should use this instead of duplicating logic
- *
- * @param originalAmount - Original price in cents (e.g., 99500 = $995.00)
- * @param promo - ActivePromo object
- * @returns Discounted amount in cents
- *
- * @example
- * const promo = { discountType: 'fixed', discountValue: 15000 }; // $150 OFF
- * const discounted = calculateDiscountedAmount(99500, promo);
- * // Returns: 84500 ($845.00)
- */
- calculateDiscountedAmount(originalAmount: number, promo: any): number {
- if (!promo || !originalAmount) {
- return originalAmount;
- }
-
- // Calculate savings based on promo type
- if (promo.discountType === 'free' || promo.discountValue === 100) {
- return 0; // 100% off
- } else if (promo.discountType === 'percent') {
- return Math.round(originalAmount * (1 - promo.discountValue / 100));
- } else if (promo.discountType === 'fixed') {
- // discountValue is already in cents (e.g., 15000 = $150.00)
- // Cap discount at original amount to prevent negative prices
- return Math.max(0, originalAmount - promo.discountValue);
- }
-
- return originalAmount;
- }
-
- // ============================================================================
- // SUBSCRIPTION STATUS 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 {
- // CRITICAL: Backend returns 3DS requirements in multiple possible formats:
- // 1. Standard Stripe format: latest_invoice.payment_intent.status === 'requires_action'
- // 2. Pre-3DS state: latest_invoice.payment_intent.status === 'requires_confirmation' (needs confirmation which may trigger 3DS)
- // 3. Backend's Direct Pattern format: requires_action === true (flat structure with client_secret)
- // We must check all three to handle 3DS authentication correctly (r942 implementation)
- return subs?.some((sub) =>
- sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION ||
- sub?.latest_invoice?.payment_intent?.status === 'requires_confirmation' ||
- (sub as any)?.requires_action === true
- );
- }
-
- 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
- // CRITICAL: Check all formats for 3DS requirement (see isRequireAction comment)
- return subs?.find((sub) =>
- sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION ||
- sub?.latest_invoice?.payment_intent?.status === 'requires_confirmation' ||
- (sub as any)?.requires_action === true
- );
- }
-
- 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 isRefundLine = (line: any) =>
- line?.parent?.subscription_item_details?.proration_details?.credited_items != null ||
- line?.proration_details?.credited_items != null;
-
- const rfdLines = lines.filter(isRefundLine);
- const pmtLines = lines.filter(line => !isRefundLine(line));
- let pmt: CheckoutPayment;
- const pmtTotalTax = this.calcTotalAmount(this.extractLineTax(pmtLines));
- if (rfdLines.length > 0) {
- const refTotalTax = this.calcTotalAmount(this.extractLineTax(rfdLines));
- pmt = {
- payment: {
- lineItems: pmtLines,
- totalAmount: this.calcTotalAmount(pmtLines) + pmtTotalTax,
- totalTax: pmtTotalTax
- },
- refund: {
- lineItems: rfdLines,
- totalAmount: this.calcTotalAmount(rfdLines) + 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 hasUnresolvedSub = opt?.subscriptions?.some((sub) =>
- sub.status === SubStripe.UNPAID || sub.status === SubStripe.INCOMPLETE ||
- sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE
- );
- if (hasUnresolvedSub) {
- return this.calcInvoice(invoices, opt?.coupon);
- }
-
- const prorateInvs = invoices.filter((inv) =>
- inv?.lines?.data?.some((line) => line?.period?.start === inv?.subscription_proration_date)
- );
- if (prorateInvs.length > 0) {
- return this.calcInvoiceWithProrate(invoices, opt?.coupon);
- }
- // No proration + all subs active = clean upcoming invoice (e.g., deferred promo's Invoice[1])
- return this.calcInvoice(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;
- }
-
- /**
- * Convert maxAcres value to user-friendly display string
- *
- * **Zero Value Policy**: In agricultural context, "0 acres" doesn't make literal sense.
- * Zero is used internally to mean "no restriction on acreage."
- *
- * Display Rules:
- * - 0, null, undefined, empty string → "Unlimited"
- * - Values < 1000 → Display as-is (e.g., "123")
- * - Values >= 1000 → Display in thousands (e.g., "50K" for 50000)
- *
- * @param maxAcres - Maximum acres value from API (Stripe metadata or custom limits)
- * @returns Display string ("Unlimited", "123", or "50K")
- *
- * @example
- * convMaxAcre(0) → "Unlimited" // Zero = no restriction
- * convMaxAcre(null) → "Unlimited" // Not set = unlimited
- * convMaxAcre(123) → "123" // Small values display as-is
- * convMaxAcre(50000) → "50K" // Large values in thousands
- * convMaxAcre('') → "Unlimited" // Empty string = unlimited
- *
- * Frontend/Backend Coordination:
- * - Backend returns literal values from Stripe or custom limits
- * - Frontend interprets 0 as "Unlimited" for display
- * - This separation allows backend to store raw data while frontend
- * provides user-friendly interpretation
- *
- * Related Policy:
- * - maxVehicles: Zero displayed literally ("0 Aircraft" is valid restriction)
- * - maxAcres: Zero displayed as "Unlimited" (no literal "0 acres" in farming)
- *
- * See: Task 02 - Document Zero Handling Policy
- */
- convMaxAcre(maxAcres: number | string): string {
- // Display "Unlimited" for null, undefined, empty string, or 0
- if (!maxAcres || maxAcres === 0 || maxAcres === '' || maxAcres === '0') {
- return UNLIMITED;
- }
- 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
- );
- }
-
- /**
- * Infer subscription type ('package' or 'addon') from a Stripe API subscription object.
- * Subscriptions created via the app set `metadata.type` explicitly.
- * Subscriptions created directly in the Stripe Dashboard have `metadata: {}`, so we
- * fall back to inspecting the price lookup_key (addon keys start with 'addon_').
- * This is the single source of truth — used everywhere StripeSubscription type is needed.
- */
- private inferStripeSubType(sub: StripeSubscription): string {
- if (sub.metadata?.type) return sub.metadata.type;
- const lookupKey = sub.items?.data?.[0]?.price?.lookup_key ?? '';
- return lookupKey.startsWith('addon_') ? SubType.ADDON : SubType.PACKAGE;
- }
-
- updateMembShip(subscriptions: StripeSubscription[], membership: IMembership): IMembership {
- if (Utils.isEmptyArray(subscriptions)) return membership;
-
- // NOTE: This method works with StripeSubscription (from Stripe API) which has different field names
- // (current_period_end vs periodEnd). The centralized utilities work with AGNavSubscription.
- // We keep this logic here as it's specific to Stripe API response transformation.
-
- // Find all package subscriptions and get the latest one by current_period_end
- const pkgSubs = subscriptions?.filter((sub) => this.inferStripeSubType(sub) === SubType.PACKAGE);
- const latestPkg = pkgSubs?.reduce((acc, curr) => {
- return (curr.current_period_end > acc.current_period_end) ? curr : acc;
- }, pkgSubs?.[0]);
-
- // Find all addon subscriptions and get the latest one by current_period_end
- const addonSubs = subscriptions?.filter((sub) => this.inferStripeSubType(sub) === SubType.ADDON);
- const latestAddon = addonSubs?.reduce((acc, curr) => {
- return (curr.current_period_end > acc.current_period_end) ? curr : acc;
- }, addonSubs?.[0]);
-
- const transformedSubscriptions = subscriptions?.map((sub) => {
- const transformed = {
- 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,
- metadata: {
- tier: item.price.metadata?.tier || '', // Ensure tier is always defined
- level: item.price.metadata?.level,
- maxAcres: item.price.metadata?.maxAcres,
- maxVehicles: item.price.metadata?.maxVehicles
- }
- })),
- type: this.inferStripeSubType(sub),
- cancelAtPeriodEnd: sub.cancel_at_period_end,
- trial_end: sub.trial_end,
- promoDetails: sub.promoDetails
- };
-
- return transformed;
- });
-
- return {
- ...membership,
- endOfPeriod: latestPkg?.latest_invoice.period_end || latestAddon?.latest_invoice.period_end,
- subscriptions: transformedSubscriptions
- };
- }
-
- createSubPlan(subscriptions: StripeSubscription[], membership: IMembership, usage: Usage): Plan {
- if (Utils.isEmptyArray(membership?.subscriptions)
- || Utils.isEmptyArray(subscriptions)
- || this.hasOpenSub(subscriptions)) {
- return { subscriptions, membership, package: {}, addon: {} };
- }
-
- // Use subscriptions parameter (from Stripe API with custom limits override)
- // instead of membership.subscriptions (from MongoDB without override)
- const getSubscriptionItem = (type: SubType) => {
- const subscription = subscriptions.find(sub => sub.metadata?.type === type
- && (sub.status === SubStripe.ACTIVE || sub.status === SubStripe.TRIALING));
- return subscription?.items?.data?.[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?.lookup_key;
-
- // ✅ FIX (2026-01-27): Read metadata from MongoDB session data instead of Stripe API
- // MongoDB is source of truth for subscription metadata changes (updated by admin/backend)
- // Stripe API caches metadata and doesn't sync with MongoDB direct updates
- // Priority: MongoDB membership.subscriptions > Stripe API subscriptions > hardcoded fallback
- const mongoSubscription = membership?.subscriptions?.find(sub =>
- sub.type === SubType.PACKAGE &&
- (sub.status === 'active' || sub.status === 'trialing')
- );
- const mongoMetadata = mongoSubscription?.items?.[0]?.metadata;
-
- // ✅ FIX (2026-01-27): Use getEffectiveAcresLimit() for consistent empty string handling
- // Empty string "" in metadata was converting to 0, triggering fallback to hardcoded 50000
- // getEffectiveAcresLimit() properly handles: "" → null, null → null, "0" → null (all = Unlimited)
- const effectiveMaxAcres = this.getEffectiveAcresLimit(mongoSubscription, membership?.customLimits);
- const acre = createAcrePlan(UnitUtils.haToArea(usage.ttArea, true), effectiveMaxAcres);
-
- // ✅ FIX (2026-01-28): Use getEffectiveVehicleLimit() for consistent customLimits handling
- // Same pattern as maxAcres fix (2026-01-27) - ensures customLimits override metadata
- const effectiveMaxVehicles = this.getEffectiveVehicleLimit(mongoSubscription, membership?.customLimits);
- const pkgNumVeh = effectiveMaxVehicles || 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 ? (precedingMax + MIN) : MIN;
-
- // For custom limits (precedingMax === 0), show range from 1 to custom limit
- if (precedingMax === 0 && maxVehicles > MIN) {
- return `${MIN}-${maxVehicles}`;
- }
-
- // If range collapses to single value (e.g., "2-2"), show just the number
- if (lowerRange === maxVehicles) {
- return `${maxVehicles}`;
- }
-
- // Normal tier-based range calculation
- 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 {
- // Fallback to user info if no billing address exists - this is based on the assumption that if there's no billing address in addresses, use the default legacy address.
- return this.userSvc.getUser(applicatorId, { view: 'billing' }).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,
- trialEnd: addon.trialEnd, // Populate trialEnd from addon (for extended trial display)
- price: {
- lookup_key: addon.lookupKey,
- unit_amount: +addon.price
- }
- })) || [];
-
- if (selPkg?.lookupKey) {
- trialItems.unshift({
- description: selPkg.desc,
- amount: +selPkg.price,
- quantity: 1,
- trialEnd: selPkg.trialEnd, // Populate trialEnd from package (for extended trial display)
- 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 })
- })
- )
- )
- );
- }
-
- // ============================================================================
- // SUBSCRIPTION UTILITY METHODS - SINGLE SOURCE OF TRUTH
- // ============================================================================
-
- /**
- * Find latest subscription by periodEnd for given type
- * This is the canonical utility for non-store contexts (services, standalone functions)
- *
- * @param subscriptions - Array of AGNav subscriptions
- * @param type - Subscription type (PACKAGE or ADDON)
- * @returns Latest subscription or null if none found
- *
- * @example
- * const latest = this.subSvc.getLatestSubscription(user.membership.subscriptions, SubType.PACKAGE);
- */
- getLatestSubscription(
- subscriptions: AGNavSubscription[],
- type: SubType
- ): AGNavSubscription | null {
- if (!subscriptions || subscriptions.length === 0) return null;
-
- const filtered = subscriptions.filter(sub => sub.type === type);
- if (filtered.length === 0) return null;
-
- return filtered.reduce((acc, curr) =>
- (curr.periodEnd > acc.periodEnd) ? curr : acc, filtered[0]
- );
- }
-
- /**
- * Get lookup key from latest package subscription
- * Replaces duplicated logic across auth.service, effects, components
- *
- * @param subscriptions - Array of AGNav subscriptions
- * @returns Lookup key (price ID) or null
- *
- * @example
- * const lookupKey = this.subSvc.getCurrentPackageLookupKey(user.membership.subscriptions);
- */
- getCurrentPackageLookupKey(subscriptions: AGNavSubscription[]): PriceUsd | null {
- const latest = this.getLatestSubscription(subscriptions, SubType.PACKAGE);
- return latest?.items?.[0]?.price || null;
- }
-
- /**
- * Get lookup key from latest addon subscription
- *
- * @param subscriptions - Array of AGNav subscriptions
- * @returns Addon lookup key or null
- */
- getCurrentAddonLookupKey(subscriptions: AGNavSubscription[]): PriceUsd | null {
- const latest = this.getLatestSubscription(subscriptions, SubType.ADDON);
- return latest?.items?.[0]?.price || null;
- }
-
- /**
- * Get effective vehicle limit (custom limits override plan limits)
- * This is the authoritative calculation for max vehicles
- *
- * @param subscription - Current subscription
- * @param customLimits - User custom limits
- * @returns Effective max vehicles or null
- *
- * @example
- * const maxVehicles = this.subSvc.getEffectiveVehicleLimit(latestSub, user.membership.customLimits);
- */
- getEffectiveVehicleLimit(
- subscription: AGNavSubscription,
- customLimits?: { maxVehicles?: number; maxAcres?: number }
- ): number | null {
- if (!subscription) return null;
-
- const planMaxVehicles = Math.abs(Number(subscription.items?.[0]?.metadata?.maxVehicles));
- const customMax = customLimits?.maxVehicles ? Math.abs(customLimits.maxVehicles) : null;
-
- // Custom limits override plan limits
- return customMax || planMaxVehicles || null;
- }
-
- /**
- * Get effective acres limit (custom limits override plan limits)
- *
- * @param subscription - Current subscription
- * @param customLimits - User custom limits
- * @returns Effective max acres or null
- */
- getEffectiveAcresLimit(
- subscription: AGNavSubscription,
- customLimits?: { maxVehicles?: number; maxAcres?: number }
- ): number | null {
- if (!subscription) return null;
-
- const planMaxAcres = Number(subscription.items?.[0]?.metadata?.maxAcres);
-
- // Custom limits override plan limits
- // Treat empty string as null for proper "Unlimited" display
- const customLimit = customLimits?.maxAcres;
- const effectiveCustomLimit = (customLimit !== null && customLimit !== undefined && customLimit !== 0)
- ? customLimit
- : null;
-
- const effectivePlanLimit = (planMaxAcres !== null && planMaxAcres !== undefined && planMaxAcres !== 0 && !isNaN(planMaxAcres))
- ? planMaxAcres
- : null;
-
- return effectiveCustomLimit || effectivePlanLimit || null;
- }
-
- /**
- * Check if subscription has custom limits applied
- * Custom limits are considered "applied" when they differ from plan defaults
- *
- * @param subscription - Current subscription
- * @param customLimits - User custom limits
- * @returns True if custom limits differ from plan limits
- *
- * @example
- * const hasCustom = this.subSvc.hasCustomLimits(latestSub, user.membership.customLimits);
- */
- hasCustomLimits(
- subscription: AGNavSubscription,
- customLimits?: { maxVehicles?: number; maxAcres?: number }
- ): boolean {
- if (!subscription || !customLimits) return false;
-
- const planMaxVehicles = Number(subscription.items?.[0]?.metadata?.maxVehicles);
- const planMaxAcres = Number(subscription.items?.[0]?.metadata?.maxAcres);
-
- // Check if either vehicle or acres custom limits differ from plan
- const vehicleLimitsDiffer = customLimits.maxVehicles && customLimits.maxVehicles !== planMaxVehicles;
- const acresLimitsDiffer = customLimits.maxAcres && customLimits.maxAcres !== planMaxAcres;
-
- return vehicleLimitsDiffer || acresLimitsDiffer;
- }
-
- // ============================================================================
- // SUBSCRIPTION EXPIRY WARNING
- // ============================================================================
-
- /**
- * Calculate expiry warning from subscription data
- *
- * Returns warning if subscription expires in 1-7 days, null otherwise.
- * Only triggers for package subscriptions (not addons).
- *
- * Data Source: GET /api/subscription?custId={custId}
- * Verified: 2025-11-10 via /server_test/subscription-data-verification.js
- *
- * @param subscription - StripeSubscription from /api/subscription endpoint
- * @returns ExpiryWarning if criteria met, null otherwise
- *
- * @example
- * const subscriptions = await this.getSubscriptions(custId).toPromise();
- * const warnings = subscriptions
- * .map(sub => this.calculateExpiryWarning(sub))
- * .filter(w => w !== null);
- */
- calculateExpiryWarning(subscription: StripeSubscription): ExpiryWarning | null {
- // Validate required fields (based on Phase 1 verification)
- if (!subscription?.current_period_end || !subscription?.metadata?.type) {
- console.warn('calculateExpiryWarning: Missing required fields', {
- id: subscription?.id,
- has_period_end: !!subscription?.current_period_end,
- has_metadata_type: !!subscription?.metadata?.type
- });
- return null;
- }
-
- const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds
- const secondsUntilExpiry = subscription.current_period_end - now;
- const daysUntilExpiry = Math.floor(secondsUntilExpiry / 86400);
-
- // Only warn for subscriptions expiring in 0-expiryWarningDays days (inclusive of today)
- if (daysUntilExpiry < 0 || daysUntilExpiry > environment.expiryWarningDays) {
- return null;
- }
-
- // Only show warnings for package subscriptions (not addons)
- if (subscription.metadata?.type !== 'package') {
- return null;
- }
-
- return {
- id: subscription.id,
- type: subscription.metadata?.type as 'package' | 'addon',
- status: subscription.status,
- daysUntilExpiry,
- cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false,
- periodEnd: subscription.current_period_end,
- isTrial: subscription.status === 'trialing',
- willAutoRenew: !subscription.cancel_at_period_end
- };
- }
-
- ngOnDestroy(): void {
- if (this.sub$) this.sub$.unsubscribe();
- }
-}
diff --git a/Development/client/src/app/domain/services/user.service.ts b/Development/client/src/app/domain/services/user.service.ts
index e908487..be560ea 100644
--- a/Development/client/src/app/domain/services/user.service.ts
+++ b/Development/client/src/app/domain/services/user.service.ts
@@ -3,36 +3,27 @@ import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
-import { UserModel } from '../../auth/models/user.model';
import { User } from '../../accounts/models/user.model';
-import { Roles } from '../../shared/global';
import { Observable } from 'rxjs';
+
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly userURL = '/users';
- constructor(private http: HttpClient) {
+ constructor(
+ private store: Store<{}>,
+ private http: HttpClient
+ ) {
}
loadUsers(options: LoadUserOptions): Observable {
return this.http.post(this.userURL + '/search', options);
}
- getUser(id: string, ops?: { withAddresses?: boolean; view?: 'profile' | 'edit' | 'billing' }): Observable {
- let url = `${this.userURL}/${id}`;
- const params: string[] = [];
- if (ops?.withAddresses !== undefined) {
- params.push(`withAddresses=${ops.withAddresses}`);
- }
- if (ops?.view) {
- params.push(`view=${ops.view}`);
- }
- if (params.length) {
- url += `?${params.join('&')}`;
- }
- return this.http.get(url);
+ getUser(id: string): Observable {
+ return this.http.get(`${this.userURL}/${id}`);
}
userNameExists(userName: string): Observable {
@@ -64,32 +55,9 @@ export class UserService {
return this.http.post(`${this.userURL}/getUserDetail`, { username: username });
}
- signup(form: any) {
- return this.http.post(`${this.userURL}/signup`, form);
- }
-
- requestVerifyEmail(email: string) {
- return this.http.post(`${this.userURL}/signup/requestVerifyEmail`, { email });
- }
-
- signupValidate(token: string) {
- return this.http.post(`${this.userURL}/signup/validate`, { token });
- }
-
- getAccountType(user: UserModel | User): string {
- if (!user) return '';
- if ('roles' in user && Array.isArray(user.roles) && user.roles.length > 0) {
- const roleId = user.roles[0];
- return Roles[roleId] || '';
- }
- if ('kind' in user && user.kind && Roles[user.kind]) {
- return Roles[user.kind];
- }
- return '';
- }
}
export interface LoadUserOptions {
byPuid: string;
accountType?: number;
-}
\ No newline at end of file
+}
diff --git a/Development/client/src/app/domain/services/vehicle.service.ts b/Development/client/src/app/domain/services/vehicle.service.ts
index b4f9d27..5a3aac2 100644
--- a/Development/client/src/app/domain/services/vehicle.service.ts
+++ b/Development/client/src/app/domain/services/vehicle.service.ts
@@ -1,7 +1,9 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
+
import { Observable } from 'rxjs';
-import { StatusChange, Vehicle } from '../../entities/models/vehicle.model';
+
+import { Vehicle } from '../../entities/models/vehicle.model';
@Injectable()
export class VehicleService {
@@ -37,13 +39,10 @@ export class VehicleService {
return this.http.delete(`${this.vehicleURL}/${vehicle._id}`);
}
- unitIdExists(unitId: string): Observable {
+ unitIdExists(unitId: string): Observable {
return this.http.post(`${this.vehicleURL}/unitIdExists`, { unitId: unitId });
}
- updateVehicles(vehicles : Vehicle[]) {
- return this.http.post(`${this.vehicleURL}/update`, vehicles);
- }
}
export interface LoadVehicleOptions {
diff --git a/Development/client/src/app/effects/routing.effects.ts b/Development/client/src/app/effects/routing.effects.ts
deleted file mode 100644
index 87ff9ce..0000000
--- a/Development/client/src/app/effects/routing.effects.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Router } from '@angular/router';
-import { Action } from '@ngrx/store';
-import { Effect, Actions, ofType } from '@ngrx/effects';
-import { Observable } from 'rxjs';
-import { tap } from 'rxjs/operators';
-import * as subActions from '@app/actions/subscription.actions';
-import { SUB } from '../profile/common';
-import { AC } from '@app/shared/global';
-
-@Injectable()
-export class RoutingEffects {
-
- constructor(
- private router: Router,
- private actions$: Actions
- ) { }
-
- @Effect({ dispatch: false })
- gotoMyservices$: Observable = this.actions$.pipe(
- ofType(subActions.GOTO_MY_SERVICES),
- tap(() => this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]).then(() => window.location.reload()))
- );
-
- @Effect({ dispatch: false })
- gotoServices$: Observable = this.actions$.pipe(
- ofType(subActions.GOTO_SERVICES),
- tap(() => this.router.navigate([SUB.PROFILE, SUB.SERVICES]))
- );
-
- @Effect({ dispatch: false })
- gotPaymentHistory$: Observable = this.actions$.pipe(
- ofType(subActions.GOTO_PAYMENT_HISTORY),
- tap(() => this.router.navigate([SUB.PROFILE, SUB.PM_HISTORY]))
- );
-
- @Effect({ dispatch: false })
- gotPaymentDetail$: Observable = this.actions$.pipe(
- ofType(subActions.GOTO_PAYMENT_DETAIL),
- tap((action: subActions.GotoPaymentDetail) => this.router.navigate([SUB.PROFILE, SUB.PM_DETAIL, action.payload.paymentId]))
- );
-
- @Effect({ dispatch: false })
- gotoUnpaidSub$: Observable = this.actions$.pipe(
- ofType(subActions.SHOW_UNPAID_SUBSCRIPTION),
- tap(() => this.router.navigate([SUB.PROFILE, SUB.UNPAID_SUB]))
- );
-
- @Effect({ dispatch: false })
- gotoBillingAddr$: Observable = this.actions$.pipe(
- ofType(subActions.START_BILLING_INFO_SUCCESS, subActions.GOTO_BILLING_ADDRESS),
- tap(() => this.router.navigate([SUB.PROFILE, SUB.BILL_ADR]))
- );
-
- @Effect({ dispatch: false })
- gotoCheckout$: Observable = this.actions$.pipe(
- ofType(subActions.UPDATE_BILLING_ADDRESS_SUCCESS, subActions.GOTO_CHECK_OUT, subActions.START_CHECKOUT_SUCCESS),
- tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT]))
- );
-
- @Effect({ dispatch: false })
- gotoCheckoutReview$: Observable = this.actions$.pipe(
- ofType(subActions.CHECK_OUT, subActions.RESOLVE_PAYMENT, subActions.GOTO_CHECK_OUT_REVIEW),
- tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT_REV]))
- );
-
- @Effect({ dispatch: false })
- gotoCheckoutConfirm$: Observable = this.actions$.pipe(
- ofType(subActions.GOTO_CHECK_OUT_CONFIRM, subActions.PAY_UNPAID_SUBSCRIPTION_SUCCESS, subActions.CONFIRM_ACTION_SUCCESS, subActions.CONFIRM_PAYMENT_SUCCESS,
- subActions.CHECK_OUT_TRIAL_SUCCESS),
- tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT_CONF]))
- );
-
- @Effect({ dispatch: false })
- gotoHome$: Observable = this.actions$.pipe(
- ofType(subActions.GOTO_HOME),
- tap(() => this.router.navigate(['/', SUB.HOME]))
- );
-
- @Effect({ dispatch: false })
- gotoUsageDetail$: Observable = this.actions$.pipe(
- ofType(subActions.GOTO_USAGE_DETAIL),
- tap(() => this.router.navigate([SUB.PROFILE, SUB.USAGE_DETAIL]))
- );
-
- @Effect({ dispatch: false })
- gotoAircraftList$: Observable = this.actions$.pipe(
- ofType(subActions.GOTO_AIRCRAFT_LIST),
- tap(() => this.router.navigate(['entities', AC]))
- );
-}
diff --git a/Development/client/src/app/effects/sub-plans.effects.ts b/Development/client/src/app/effects/sub-plans.effects.ts
deleted file mode 100644
index 838fcd9..0000000
--- a/Development/client/src/app/effects/sub-plans.effects.ts
+++ /dev/null
@@ -1,282 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Action } from '@ngrx/store';
-import { Actions, Effect, ofType } from '@ngrx/effects';
-import { SubscriptionService } from '@app/domain/services/subscription.service';
-import { Observable, of } from 'rxjs';
-import * as subPlansActions from '@app/actions/sub-plans.actions'
-import { catchError, delay, exhaustMap, filter, repeat, retryWhen, switchMap, take } from 'rxjs/operators';
-import { subPlans, SubAppErr, handleErr, SubKeys, TRACKING, PACKAGE_ACTIVE, createSubStatus, SUB, SubType, DELAY, TAKE, EMPTY } from '../profile/common';
-import { AuthService } from '@app/domain/services/auth.service';
-import { StripeSubscription, Usage } from '@app/domain/models/subscription.model';
-import { AppMessageService } from '@app/shared/app-message.service';
-import { globals } from '@app/shared/global';
-import { VehicleService } from '@app/domain/services/vehicle.service';
-import { FetchLatestSubscriptionSuccess, GotoAircraftList, UpdateSubscriptionStatus } from '@app/actions/subscription.actions';
-import { Vehicle } from '@app/entities/models/vehicle.model';
-import { CustomerService } from '@app/domain/services/customer.service';
-import { Router } from '@angular/router';
-
-@Injectable()
-export class SubPlansEffects {
-
- constructor(
- private readonly actions$: Actions,
- private readonly subSvc: SubscriptionService,
- private readonly authSvc: AuthService,
- private readonly msgSvc: AppMessageService,
- private readonly vehSvc: VehicleService,
- private readonly custSvc: CustomerService,
- private readonly router: Router
- ) { }
-
- @Effect()
- refreshSubPlans$: Observable = this.actions$.pipe(
- ofType(subPlansActions.FETCH_SUB_PLANS),
- exhaustMap((action: subPlansActions.FetchSubPlans) => {
- let usage: Usage;
- let subscriptions: StripeSubscription[];
- let vehicles: Vehicle[];
- let sortedPrices: any[];
- return this.subSvc.getPrices().pipe(
- filter(prices => prices?.length > 0),
- switchMap(prices => {
- sortedPrices = [...prices].sort((a, b) => a.level - b.level);
-
- let lastEffectiveMax = 0;
- const userSubscriptions = this.authSvc.user?.membership?.subscriptions;
- const userCustomLimits = this.authSvc.user?.membership?.customLimits;
-
- // Use centralized utility to get current lookup key
- const currentLookupKey = this.subSvc.getCurrentPackageLookupKey(userSubscriptions);
-
- // Get latest package subscription for custom limits checks
- const latestPackageSub = this.subSvc.getLatestSubscription(userSubscriptions, SubType.PACKAGE);
-
- sortedPrices.forEach((price, indx) => {
- if (price) {
- const plan = subPlans[price.lookupKey] || {};
-
- const isCurrentSubscription = price.lookupKey === currentLookupKey;
-
- // Use centralized utility to check for custom limits
- const hasCustomLimit = isCurrentSubscription &&
- latestPackageSub &&
- this.subSvc.hasCustomLimits(latestPackageSub, userCustomLimits);
-
- // Use centralized utility to get effective vehicle limit
- // For current subscription: Use API + custom limits
- // For other packages: Use API data from price.maxVehicles
- const effectiveMaxVehicles = isCurrentSubscription && latestPackageSub
- ? this.subSvc.getEffectiveVehicleLimit(latestPackageSub, userCustomLimits)
- : price.maxVehicles;
-
- // Use centralized utility to get effective acres limit (SAME PATTERN AS VEHICLES)
- // Treat empty string as null for proper "Unlimited" display
- const effectiveMaxAcres = isCurrentSubscription && latestPackageSub
- ? this.subSvc.getEffectiveAcresLimit(latestPackageSub, userCustomLimits)
- : null;
-
- plan.price = price.priceUSD || plan.price;
- plan.desc = plan.desc?.replace('#price#', this.subSvc.formatCurrency(price.priceUSD)) || plan.desc;
- // Current subscription: use effectiveMaxVehicles (respects custom limits).
- // All other plans: use price.maxVehicles from Stripe API.
- if (isCurrentSubscription) {
- if (effectiveMaxVehicles != null) {
- plan.maxVehicles = effectiveMaxVehicles;
- }
- } else if (price.maxVehicles !== null && price.maxVehicles !== undefined) {
- plan.maxVehicles = Math.abs(price.maxVehicles);
- }
-
- // Apply effective acres limit
- // For current subscription: Use MongoDB session data (effectiveMaxAcres) even if null
- // For other packages: Use Stripe API data (price.maxAcres)
- // IMPORTANT: Only update if we have a valid value to prevent race condition
- if (isCurrentSubscription) {
- if (effectiveMaxAcres !== null && effectiveMaxAcres !== undefined) {
- plan.maxAcres = Number(effectiveMaxAcres);
- }
- // Don't set to null - preserve existing value to avoid race condition
- } else {
- if (price.maxAcres !== null && price.maxAcres !== undefined) {
- plan.maxAcres = Number(price.maxAcres);
- }
- // Don't set to null - preserve existing value to avoid race condition
- }
- plan.level = price.level || plan.level;
- plan.type = price.type || plan.type;
-
- if (effectiveMaxVehicles) {
- if (hasCustomLimit && isCurrentSubscription) {
- plan.Vehicles = `1-${effectiveMaxVehicles}`;
- lastEffectiveMax = price.maxVehicles;
- } else {
- plan.Vehicles = this.subSvc.toVehRange(lastEffectiveMax, effectiveMaxVehicles);
- lastEffectiveMax = effectiveMaxVehicles;
- }
- } else {
- lastEffectiveMax = price.maxVehicles || effectiveMaxVehicles || 0;
- }
-
- subPlans[price.lookupKey] = plan;
- }
- });
-
- const byPuid = this.authSvc.user?.parent || this.authSvc.user?._id;
- return this.subSvc.retrieveCurrUsage(this.authSvc.user?.membership?.custId, byPuid);
- }),
- switchMap(_usage => {
- usage = _usage;
- return this.subSvc.fetchSubscriptions(this.authSvc.user?.membership?.custId);
- }),
- switchMap(_subs => {
- subscriptions = _subs;
- return this.vehSvc.loadVehicles({ byUserId: this.authSvc.user?.parent }).pipe(
- catchError(() => of([]))
- );
- }),
- switchMap(_vehicles => {
- vehicles = _vehicles;
- const id = this.authSvc.user?.parent || this.authSvc.user._id;
- return this.custSvc.getCustomer(id);
- }),
- switchMap(cust => {
- const curSubPlan = this.subSvc.createSubPlan(subscriptions, cust?.membership, usage);
- const getNumVehs = (type: string) => vehicles?.filter((veh) => veh[type] === true).length || 0;
- const trkVehicles = getNumVehs(TRACKING);
- const pkgActiveVehicles = getNumVehs(PACKAGE_ACTIVE);
- const needReview = cust?.needReview;
-
- if (subscriptions?.length === 0) {
- const actions: Action[] = [
- new subPlansActions.ResetSubPlans(),
- new subPlansActions.FetchSubPlansSuccess(curSubPlan)
- ];
-
- if (cust?.membership) {
- // Transform membership to preserve trial_end and promoDetails (Case 2C fix)
- actions.push(new FetchLatestSubscriptionSuccess({
- subscriptions,
- membership: this.subSvc.updateMembShip(subscriptions, cust?.membership)
- }));
- }
-
- const currentUrl = this.router.url;
- const isHome = currentUrl === '/' || currentUrl.includes(`/${SUB.HOME}`);
- const isProfileRoute = currentUrl.includes(`/${SUB.PROFILE}`);
-
- if (!isHome && !isProfileRoute) {
- this.router.navigate([`/${SUB.PROFILE}/${SUB.SERVICES}`]);
- }
-
- return of(...actions);
- }
-
- // Use centralized utility to get current lookup key from latest subscription
- const freshSubscriptions = cust?.membership?.subscriptions;
- const freshCurrentLookupKey = this.subSvc.getCurrentPackageLookupKey(freshSubscriptions) ||
- this.authSvc.getCurLookupKey(SubType.PACKAGE);
- const staleCurrentLookupKey = this.authSvc.getCurLookupKey(SubType.PACKAGE);
-
- // Always update the current plan's maxVehicles with fresh customer data
- // (first block used stale authSvc cache — customLimits may not have been loaded yet)
- const freshLatestPackageSub = this.subSvc.getLatestSubscription(freshSubscriptions, SubType.PACKAGE);
- const freshUserCustomLimits = cust?.membership?.customLimits;
- if (freshCurrentLookupKey && freshLatestPackageSub && subPlans[freshCurrentLookupKey]) {
- const freshEffective = this.subSvc.getEffectiveVehicleLimit(freshLatestPackageSub, freshUserCustomLimits);
- if (freshEffective != null) {
- subPlans[freshCurrentLookupKey].maxVehicles = freshEffective;
- }
- }
-
- if (freshCurrentLookupKey && freshCurrentLookupKey !== staleCurrentLookupKey) {
- let lastEffectiveMax = 0;
- const userCustomLimits = cust?.membership?.customLimits;
-
- // Get latest package subscription for custom limits checks
- const latestPackageSub = this.subSvc.getLatestSubscription(freshSubscriptions, SubType.PACKAGE);
-
- sortedPrices.forEach((price, indx) => {
- if (price) {
- const plan = subPlans[price.lookupKey];
- if (plan) {
-
- const isCurrentSubscription = price.lookupKey === freshCurrentLookupKey;
-
- // Use centralized utility to check for custom limits
- const hasCustomLimit = isCurrentSubscription &&
- latestPackageSub &&
- this.subSvc.hasCustomLimits(latestPackageSub, userCustomLimits);
-
- // Use centralized utility to get effective vehicle limit
- const effectiveMaxVehicles = isCurrentSubscription && latestPackageSub
- ? this.subSvc.getEffectiveVehicleLimit(latestPackageSub, userCustomLimits)
- : price.maxVehicles;
-
- // Current subscription: use effectiveMaxVehicles (respects custom limits).
- // All other plans: use price.maxVehicles from Stripe API.
- if (isCurrentSubscription) {
- if (effectiveMaxVehicles != null) {
- plan.maxVehicles = effectiveMaxVehicles;
- }
- } else if (price.maxVehicles !== null && price.maxVehicles !== undefined) {
- plan.maxVehicles = Math.abs(price.maxVehicles);
- }
-
- if (effectiveMaxVehicles) {
- if (hasCustomLimit && isCurrentSubscription) {
- plan.Vehicles = `1-${effectiveMaxVehicles}`;
- lastEffectiveMax = price.maxVehicles;
- } else {
- plan.Vehicles = this.subSvc.toVehRange(lastEffectiveMax, effectiveMaxVehicles);
- lastEffectiveMax = effectiveMaxVehicles;
- }
- } else {
- lastEffectiveMax = price.maxVehicles || effectiveMaxVehicles || 0;
- }
- }
- }
- });
- }
-
- const isCurTrkVehAboveLimit = trkVehicles > curSubPlan?.addon?.[SubKeys.TRACKING]?.airCraft?.numOfVehicle;
- const isCurActiveVehAboveLimit = pkgActiveVehicles > curSubPlan?.package?.[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.airCraft?.numOfVehicle;
-
- const actions: Action[] = [
- new subPlansActions.FetchSubPlansSuccess(curSubPlan)
- ];
-
- if (cust?.membership) {
- // Transform membership to preserve trial_end and promoDetails (Case 2C fix)
- actions.unshift(new FetchLatestSubscriptionSuccess({
- subscriptions,
- membership: this.subSvc.updateMembShip(subscriptions, cust?.membership)
- }));
- }
-
- if (isCurActiveVehAboveLimit || isCurTrkVehAboveLimit || needReview) {
- actions.push(
- new UpdateSubscriptionStatus(createSubStatus(SUB.AC_REVIEW)),
- new GotoAircraftList()
- );
- }
-
- return of(...actions);
- })
- )
- }),
- retryWhen(errors => errors.pipe(
- delay(DELAY),
- take(TAKE)
- )),
- catchError(err => {
- this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subPlans));
- return handleErr>({
- error: err, opt: {
- extra: SubAppErr.FETCH_SUB_PLANS_ERR
- }
- });
- }),
- repeat()
- );
-}
\ No newline at end of file
diff --git a/Development/client/src/app/effects/subscription.effects.ts b/Development/client/src/app/effects/subscription.effects.ts
deleted file mode 100644
index 086bca0..0000000
--- a/Development/client/src/app/effects/subscription.effects.ts
+++ /dev/null
@@ -1,1317 +0,0 @@
-import { Injectable } from '@angular/core';
-import { from, interval, Observable, of, forkJoin, throwError } from 'rxjs';
-import { map, switchMap, catchError, take, takeUntil, tap, startWith, debounceTime, concatMap, repeat, takeWhile } from 'rxjs/operators';
-import { Actions, Effect, ofType } from '@ngrx/effects';
-import { Action, Store } from '@ngrx/store';
-import * as subAction from '@app/actions/subscription.actions';
-import { SubscriptionService } from '@app/domain/services/subscription.service';
-import { Addon, Address, ConfirmPackage, Invoice, InvoicePackage, PaymentMethod, StripeSubscription, SubscriptionIntent, Card, SubscriptionPackage, Coupon, BillingInfo, TrialPmtPkg, BillingInfoPackage } from '@app/domain/models/subscription.model';
-import { PaymentIntentResult, PaymentMethodResult } from '@stripe/stripe-js';
-import { UserModel } from '@app/auth/models/user.model';
-import { createSubStatus, handleErr, SubAppErr, SUB, SubStripe, Mode, SERVICE_TYPE, PromoErrors } from '@app/profile/common';
-import { DateUtils, Utils } from '@app/shared/utils'
-import { AuthService } from '@app/domain/services/auth.service';
-import { ResetSubPlans } from '@app/actions/sub-plans.actions';
-import { CustomerService } from '@app/domain/services/customer.service';
-import { Customer } from '@app/customers/models/customer.model';
-import { GAService } from '@app/shared/ga.service';
-import { GAAnalyticsHelpersService } from '@app/shared/ga.analytics-helpers.service';
-
-interface UnpaidContent {
- card: Card,
- unpaidSubs: any,
- latestSubs: any,
- unpaidInvoices?: any
-};
-
-enum StartCheckoutCase { NEW_ACC_TRIAL, NEW_ACC, TRIALING, UNPAID };
-enum TrialChkoutCase { NEW_CARD, EXISTING };
-
-@Injectable()
-export class SubscriptionEffects {
-
- constructor(
- private readonly store: Store<{}>,
- private readonly actions$: Actions,
- private readonly subSvc: SubscriptionService,
- private readonly authSvc: AuthService,
- private readonly custSvc: CustomerService,
- private readonly ga: GAService,
- private readonly gaHelpers: GAAnalyticsHelpersService,
- ) { }
-
- // Common effects
- @Effect()
- compound$: Observable = this.actions$.pipe(
- ofType(subAction.COMPOUND),
- concatMap((action: subAction.Compound) => from(action.payload)),
- catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.COMP_ACT_ERR } })),
- repeat()
- );
-
- @Effect()
- fetchLatestSub$: Observable = this.actions$.pipe(
- ofType(subAction.FETCH_LATEST_SUBSCRIPTION),
- switchMap((action: subAction.FetchLatestSubscription) => this.subSvc.fetchSubscriptions(action.payload.custId)),
- map((subscriptions) => {
- // Debug: Log ALL raw backend response data
- console.log('🔍 fetchLatestSub$ - EFFECT TRIGGERED:', {
- hasSubscriptions: !!subscriptions,
- count: subscriptions?.length || 0,
- allStatuses: subscriptions?.map(sub => ({ id: sub.id, status: sub.status })) || [],
- firstSubFull: subscriptions?.[0] || null
- });
-
- // Debug: Log raw backend response for trial subscriptions
- const trialSubs = subscriptions?.filter(sub => sub.status === 'trialing');
- console.log('🔍 fetchLatestSub$ - Trial filter result:', {
- trialCount: trialSubs?.length || 0,
- hasTrialSubs: trialSubs && trialSubs.length > 0
- });
-
- if (trialSubs && trialSubs.length > 0) {
- console.log('🔍 fetchLatestSub$ - RAW Backend API Response (trial subscriptions):', {
- count: trialSubs.length,
- firstSub: {
- id: trialSubs[0].id,
- status: trialSubs[0].status,
- trial_end: trialSubs[0].trial_end,
- promoDetails: trialSubs[0].promoDetails,
- has_trial_end_key: 'trial_end' in trialSubs[0],
- has_promoDetails_key: 'promoDetails' in trialSubs[0]
- }
- });
- }
-
- return new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) });
- }),
- catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.FETCH_SUB_ERR } })),
- repeat()
- );
-
- @Effect()
- loadStripe$: Observable = this.actions$.pipe(
- ofType(subAction.LOAD_STRIPE),
- switchMap(() => this.subSvc.getConfig()),
- switchMap((res) => from(this.subSvc.loadStripeApi(res.config))),
- map(() => {
- this.subSvc.stripeLoadStatus$.next(true);
- return new subAction.LoadStripeSuccess();
- }),
- catchError((err) => {
- this.subSvc.stripeLoadStatus$.error(false);
- return handleErr>({ error: err, opt: { extra: SubAppErr.LOAD_STRIPE_ERR } });
- }),
- repeat()
- );
-
- // Billing info stage
- @Effect()
- startBillingInfo$: Observable = this.actions$.pipe(
- ofType(subAction.START_BILLING_INFO),
- switchMap((action: subAction.StartBillingInfo) => {
- let subIntentPkg: SubscriptionIntent;
- return this.subSvc.createBillingInfoPackage(action.payload.applicatorId).pipe(
- map((billingInfoPkg: BillingInfoPackage) => {
- subIntentPkg = { ...action.payload, ...billingInfoPkg };
- return new subAction.StartBillingInfoSuccess(subIntentPkg);
- })
- )
- }),
- catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.START_BIL_INFO_ERR } })),
- repeat()
- );
-
- // Checkout stage
- @Effect()
- startCheckout$: Observable = this.actions$.pipe(
- ofType(subAction.START_CHECKOUT),
- switchMap((action: subAction.StartCheckout) => {
- const isNewAccAndTrial = action.payload.subIntentPkg?.isNewAccount && action.payload.subIntentPkg?.mode === Mode.TRIALING;
- const isNewAccNoTrial = action.payload.subIntentPkg?.isNewAccount && action.payload.subIntentPkg?.mode === Mode.REGULAR;
- const isTrial = action.payload.subIntentPkg?.mode === Mode.TRIALING || action.payload.subIntentPkg?.mode === Mode.CONTINUE_TRIAL;
- const isUnpaid = action.payload.subIntentPkg?.mode === Mode.UNPAID;
- if (isNewAccAndTrial) {
- return this.handleStartChkout(action.payload, StartCheckoutCase.NEW_ACC_TRIAL);
- } else if (isNewAccNoTrial) {
- return this.handleStartChkout(action.payload, StartCheckoutCase.NEW_ACC);
- } else if (isTrial) {
- return this.handleStartChkout(action.payload, StartCheckoutCase.TRIALING);
- } else if (isUnpaid) {
- return this.handleStartChkout(action.payload, StartCheckoutCase.UNPAID);
- } else {
- return this.handleStartChkout(action.payload);
- }
-
- }),
- catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.START_CHECKOUT_ERR } })),
- repeat()
- );
-
- private handleStartChkout(payload: { billingInfo: BillingInfo; subIntentPkg: SubscriptionIntent }, type?: StartCheckoutCase) {
- const addrPkg = {
- _id: payload.billingInfo?.address?._id,
- name: payload.billingInfo?.name, city: payload.billingInfo?.address?.city,
- line1: payload.billingInfo?.address?.line1,
- line2: payload.billingInfo?.address?.line2,
- postalCode: payload.billingInfo?.address?.postal_code,
- state: payload.billingInfo?.address?.state,
- country: payload.billingInfo?.address?.country
- };
- let subIntentPkg: SubscriptionIntent = payload.subIntentPkg;
-
- let handleDefault = () => this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(
- switchMap(() => {
- subIntentPkg = { ...subIntentPkg, billingInfo: payload.billingInfo };
- return this.subSvc.getPaymentMethodList(payload.subIntentPkg?.custId);
- }),
- switchMap((paymentMethods: PaymentMethod[]) => {
- subIntentPkg = { ...subIntentPkg, paymentMethods };
- return this.subSvc.retrieveUpcomingInvoices({ custId: payload.subIntentPkg?.custId, package: payload.subIntentPkg?.selPkg?.lookupKey, addons: payload.subIntentPkg?.selAddons?.map((addon: Addon) => ({ price: addon?.lookupKey, quantity: addon?.quantity })), prorateTS: payload.subIntentPkg?.prorateTS });
- }),
- map((upcomingInvoices: Invoice[]) => {
- subIntentPkg = { ...subIntentPkg, upcomingInvoices };
- return new subAction.StartCheckoutSuccess(subIntentPkg);
- })
- );
-
- switch (type) {
- case StartCheckoutCase.NEW_ACC_TRIAL:
- return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(map((result) => new subAction.UpdateBillingAddressSuccess({ applicatorId: payload.billingInfo?.applicatorId, name: result?.address?.name, address: this.subSvc.convertAddr(result?.address) })));
- case StartCheckoutCase.NEW_ACC:
- return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(
- switchMap(() => {
- subIntentPkg = { ...subIntentPkg, billingInfo: payload.billingInfo };
- return this.subSvc.retrieveUpcomingInvoices({ custId: payload.subIntentPkg?.custId, package: payload.subIntentPkg?.selPkg?.lookupKey, addons: payload.subIntentPkg?.selAddons?.map((addon: Addon) => ({ price: addon?.lookupKey, quantity: addon?.quantity })), prorateTS: payload.subIntentPkg?.prorateTS });
- }),
- map((upcomingInvoices: Invoice[]) => {
- subIntentPkg = { ...subIntentPkg, upcomingInvoices };
- return new subAction.StartCheckoutSuccess(subIntentPkg);
- })
- );
- case StartCheckoutCase.TRIALING:
- return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(
- switchMap(() => {
- subIntentPkg = { ...subIntentPkg, billingInfo: payload.billingInfo };
- return this.subSvc.getPaymentMethodList(payload.subIntentPkg?.custId);
- }),
- map((paymentMethods: PaymentMethod[]) => {
- subIntentPkg = { ...subIntentPkg, paymentMethods };
- return new subAction.StartCheckoutSuccess(subIntentPkg);
- }));
- case StartCheckoutCase.UNPAID:
- return this.subSvc.updateBillingAddressSequence(payload.billingInfo?.applicatorId, addrPkg).pipe(
- map(() => {
- return new subAction.StartCheckoutSuccess(subIntentPkg);
- }));
- case undefined:
- return handleDefault();
- default:
- return handleDefault();
- }
- }
-
- @Effect()
- checkoutTrial$: Observable = this.actions$.pipe(
- ofType(subAction.CHECK_OUT_TRIAL),
- switchMap((action: subAction.CheckoutTrial) => {
-
- const isTrialing = action.payload?.mode === Mode.TRIALING;
- const isContAftTrialEnd = action.payload?.mode === Mode.CONTINUE_TRIAL;
- const isCrtNewPM = action.payload?.pmtMethod?.newPmtMeth;
- const isExistingPM = action.payload?.pmtMethod?.exPmtMeth;
-
- if (isTrialing) {
- if (isCrtNewPM) {
- return this.handleChkoutTrial(action.payload, TrialChkoutCase.NEW_CARD);
- } else if (isExistingPM) {
- return this.handleChkoutTrial(action.payload, TrialChkoutCase.EXISTING);
- } else {
- return this.handleChkoutTrial(action.payload);
- }
- } else if (isContAftTrialEnd) {
- if (isCrtNewPM) {
- return this.handleContTrial(action.payload, TrialChkoutCase.NEW_CARD);
- } else if (isExistingPM) {
- return this.handleContTrial(action.payload, TrialChkoutCase.EXISTING);
- } else {
- return this.handleContTrial(action.payload);
- }
- } else {
- return handleErr>({ opt: { extra: SubAppErr.CHECKOUT_TRIAL_ERR } })
- }
- }),
- catchError((err) => handleErr>({ error: err, opt: { extra: SubAppErr.CHECKOUT_TRIAL_ERR } })),
- repeat()
- );
-
- private handleContTrial(payload: TrialPmtPkg, type?: TrialChkoutCase) {
- switch (type) {
- case TrialChkoutCase.NEW_CARD:
- const { _id, isBilling, ...address } = payload.pmtMethod?.newPmtMeth?.billing_details?.address;
- return from(this.subSvc.stripe.createPaymentMethod({
- type: 'card',
- card: payload.pmtMethod?.newPmtMeth?.card,
- billing_details: { name: payload.pmtMethod?.newPmtMeth?.billing_details?.name, address }
- })).pipe(
- switchMap((result: PaymentMethodResult) => {
- const stripeErr = result?.error;
- if (stripeErr) {
- return handleErr>({ error: stripeErr, opt: { extra: SubAppErr.CRT_PM_ERR, msg: stripeErr.message } });
- }
- return this.subSvc.updateSubsPaymentMethod({ subIds: payload.subIds, pmId: result?.paymentMethod?.id }).pipe(
- switchMap(() => {
- return this.subSvc.editSub(payload.subIds?.map((subId) => ({ subId, cancelAtPeriodEnd: false })) || []);
- }),
- map((subs) => {
- // Track successful trial checkout
- this.trackSubscriptionPurchase(subs, { payload });
-
- return new subAction.CheckoutTrialSuccess({
- card: {
- pmId: result?.paymentMethod?.id,
- brand: result?.paymentMethod?.card?.brand,
- country: result?.paymentMethod?.card?.country,
- exp_month: result?.paymentMethod?.card?.exp_month,
- exp_year: result?.paymentMethod?.card?.exp_year,
- last4: result?.paymentMethod?.card?.last4,
- defaultPM: payload.pmtMethod?.newPmtMeth?.defaultPM
- },
- subs, amount: payload.amount
- });
- })
- );
- }),
- );
- case TrialChkoutCase.EXISTING:
- return this.subSvc.updateSubsPaymentMethod({ subIds: payload.subIds, pmId: payload.pmtMethod?.exPmtMeth?.pmId }).pipe(
- switchMap(() => {
- return this.subSvc.editSub(payload.subIds?.map((subId) => ({ subId, cancelAtPeriodEnd: false })) || []);
- }),
- (map((subs) => new subAction.CheckoutTrialSuccess({ card: payload.pmtMethod.exPmtMeth, subs, amount: payload.amount })))
- );
- case undefined:
- return handleErr>({ opt: { extra: SubAppErr.CHECKOUT_CONT_TRIAL_ERR } });
- default:
- return handleErr>({ opt: { extra: SubAppErr.CHECKOUT_CONT_TRIAL_ERR } });
- }
- }
-
- private handleChkoutTrial(payload: TrialPmtPkg, type?: TrialChkoutCase) {
- const updatePkgPayload: SubscriptionPackage = {
- defaultPM: payload.pmtMethod?.newPmtMeth?.defaultPM,
- package: payload.package, addons: payload.addons,
- trial: 1,
- prorateTS: DateUtils.currUTC()
- };
- let subs: StripeSubscription[];
-
- const handleDefault = () => this.subSvc.updateSubscription(updatePkgPayload).pipe(
- switchMap((_subs) => {
- subs = _subs;
- return this.custSvc.getCustomer(this.authSvc.user._id);
- }),
- switchMap((cust: Customer) => {
- // Track successful trial checkout
- this.trackSubscriptionPurchase(subs, { payload: updatePkgPayload });
-
- // Fetch card data from customer's default payment method
- return this.subSvc.getPaymentMethodList(cust.membership.custId).pipe(
- map((paymentMethods: PaymentMethod[]) => {
- const defaultPM = paymentMethods?.find(pm => pm.id === subs?.[0]?.default_payment_method);
- const card: Card | undefined = defaultPM ? {
- pmId: defaultPM.id,
- brand: defaultPM.card?.brand,
- country: defaultPM.card?.country,
- exp_month: defaultPM.card?.exp_month,
- exp_year: defaultPM.card?.exp_year,
- last4: defaultPM.card?.last4,
- defaultPM: true
- } : undefined;
-
- return [new subAction.CheckoutTrialSuccess({ subs, card }), new subAction.UpdateTrial(cust.membership.trials)];
- }),
- switchMap((actions) => of(...actions))
- );
- })
- );
-
-
- switch (type) {
- case TrialChkoutCase.NEW_CARD:
- const { _id, isBilling, ...address } = payload.pmtMethod?.newPmtMeth?.billing_details?.address;
- return from(this.subSvc.stripe.createPaymentMethod({
- type: 'card', card: payload.pmtMethod?.newPmtMeth?.card,
- billing_details: { name: payload.pmtMethod?.newPmtMeth?.billing_details?.name, address }
- })).pipe(
- switchMap((result: PaymentMethodResult) => {
- const stripeErr = result?.error;
- if (stripeErr) {
- return handleErr>({ error: stripeErr, opt: { extra: SubAppErr.CRT_PM_ERR, msg: stripeErr.message } });
- }
- return this.subSvc.updateSubscription({ pmId: result?.paymentMethod?.id, ...updatePkgPayload }).pipe(
- switchMap((_subs) => {
- subs = _subs;
- return this.subSvc.editSub(subs?.map((sub) => ({ subId: sub.id, cancelAtPeriodEnd: false })) || []);
- }),
- switchMap((_subs) => {
- return this.custSvc.getCustomer(this.authSvc.user._id);
- }),
- switchMap((cust: Customer) => {
- return of(
- new subAction.CheckoutTrialSuccess({
- card: {
- pmId: result?.paymentMethod?.id,
- brand: result?.paymentMethod?.card?.brand,
- country: result?.paymentMethod?.card?.country,
- exp_month: result?.paymentMethod?.card?.exp_month,
- exp_year: result?.paymentMethod?.card?.exp_year,
- last4: result?.paymentMethod?.card?.last4,
- defaultPM: payload.pmtMethod?.newPmtMeth?.defaultPM
- },
- subs
- }),
- new subAction.UpdateTrial(cust.membership.trials))
- })
- );
- }),
- );
- case TrialChkoutCase.EXISTING:
- return this.subSvc.updateSubscription({ pmId: payload.pmtMethod?.exPmtMeth?.pmId, ...updatePkgPayload }).pipe(
- switchMap((_subs) => {
- subs = _subs;
- return this.subSvc.editSub(subs?.map((sub) => ({ subId: sub.id, cancelAtPeriodEnd: false })) || []);
- }),
- switchMap((_subs) => {
- return this.custSvc.getCustomer(this.authSvc.user._id);
- }),
- switchMap((cust: Customer) => {
- return of(new subAction.CheckoutTrialSuccess({ card: payload.pmtMethod.exPmtMeth, subs }), new subAction.UpdateTrial(cust.membership.trials))
- })
- );
- case undefined:
- return handleDefault();
- default:
- return handleDefault();
- }
- }
-
- @Effect()
- applyPreviewDiscount$: Observable = this.actions$.pipe(
- ofType(subAction.APPLY_DISCOUNT_PREVIEW),
- switchMap((action) => {
- let invoicePkg: InvoicePackage = { custId: action.payload.subIntentPkg.custId, package: action.payload.subIntentPkg.selPkg?.lookupKey, addons: action.payload.subIntentPkg.selAddons?.map((addon: Addon) => ({ price: addon?.lookupKey, quantity: addon?.quantity })), prorateTS: action.payload.subIntentPkg.prorateTS };
-
- if (action.payload.coupon) {
- // Extract price keys for product restriction validation
- const priceKeys = this._collectPriceKeys(action.payload.subIntentPkg);
-
- return this.subSvc.getCoupon(action.payload.coupon, priceKeys).pipe(
- switchMap((coupon: Coupon) => {
- if (!coupon.valid) {
- return handleErr>({ error: '', opt: { extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR } });
- }
- invoicePkg.coupon = coupon.id;
- return this.subSvc.retrieveUpcomingInvoices(invoicePkg).pipe(
- map((res) => new subAction.ApplyDiscountPreviewSuccess({ amount: this.subSvc.calcAmount(res, { coupon }), coupons: [coupon] }))
- );
- })
- );
- }
- return this.subSvc.retrieveUpcomingInvoices(invoicePkg).pipe(
- map((res) => new subAction.ApplyDiscountPreviewSuccess({ amount: this.subSvc.calcAmount(res), coupons: [] }))
- );
- }),
- catchError((err) => {
- // Handle promo_invalid_coupon error specifically
- // Error structure: err.error.error[".tag"] and err.error.error.message
- const errorTag = err?.error?.error?.[".tag"];
- const errorMessage = err?.error?.error?.message;
-
- if (errorTag === 'promo_invalid_coupon') {
- // Match specific error message to user-friendly label from PromoErrors
- let displayMessage = PromoErrors.PROMO_INVALID_COUPON; // Default
-
- if (errorMessage?.includes('first-time customers')) {
- displayMessage = PromoErrors.PROMO_FIRST_TIME_ONLY;
- } else if (errorMessage?.includes('not available for this customer')) {
- displayMessage = PromoErrors.PROMO_RESTRICTED_CUSTOMER;
- } else if (errorMessage?.includes('not applicable to the selected products') || errorMessage?.includes('restricted to specific products')) {
- displayMessage = PromoErrors.PROMO_RESTRICTED_PRODUCT;
- } else if (errorMessage?.includes('expired')) {
- displayMessage = PromoErrors.PROMO_EXPIRED;
- } else if (errorMessage?.includes('maximum redemption') || errorMessage?.includes('reached max')) {
- displayMessage = PromoErrors.PROMO_MAX_REDEMPTIONS;
- }
-
- return handleErr>({
- error: err,
- opt: {
- extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR,
- msg: displayMessage
- }
- });
- }
-
- // Fallback to generic error handling (for other error types)
- return handleErr>({
- error: err,
- opt: {
- extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR,
- msg: err?.error?.message || err?.error?.raw?.message // Try new format first, fallback to old
- }
- });
- }),
- repeat()
- );
-
- /**
- * Collect price keys from subscription intent package for product validation
- * @param subIntentPkg Subscription intent package containing selected package and addons
- * @returns Array of price lookup keys (e.g., ['ess_3', 'addon_1'])
- */
- private _collectPriceKeys(subIntentPkg: any): string[] {
- const keys: string[] = [];
-
- // Add selected package
- if (subIntentPkg?.selPkg?.lookupKey) {
- keys.push(subIntentPkg.selPkg.lookupKey);
- }
-
- // Add selected addons
- subIntentPkg?.selAddons?.forEach(addon => {
- if (addon?.lookupKey) {
- keys.push(addon.lookupKey);
- }
- });
-
- return keys;
- }
-
- // Checkout-review stage
- private finalizeConfirm({ action, results, confirmPkg }) {
- return switchMap((subscriptions: StripeSubscription[]) => {
- const hasSubs = subscriptions?.length > 0;
-
- if (hasSubs) {
- const isPastdueType = action.payload.unresolved?.type === SubStripe.PAST_DUE;
- const isIncompleteType = action.payload.unresolved?.type === SubStripe.INCOMPLETE;
- const isContainError = (results: PaymentIntentResult[]): boolean => results?.some((result) => !!result?.error);
-
- if (isContainError(results)) {
- const error = results?.find((result) => !!result?.error)?.error;
- const lastPaymentErr: any = error?.payment_intent?.last_payment_error;
- const card: Card = lastPaymentErr?.payment_method?.card || lastPaymentErr?.source;
-
- // Check for card decline at checkout-review stage
- const isCardDeclineError = error?.code === 'card_declined' || error?.decline_code === 'generic_decline';
- const isCheckoutReviewStage = confirmPkg?.stage === SUB.CHKOUT_REV;
-
- if (isCardDeclineError && isCheckoutReviewStage) {
- // Stay at checkout-review with error message - no navigation
- return of(
- new subAction.UpdateIncomplete({
- invoices: action.payload.unresolved?.invoices,
- requiresAction: false,
- requiresPM: true,
- numOfRetries: ++action.payload.unresolved.numOfRetries,
- subscriptions
- }),
- new subAction.UpdateSubscriptionStatus(
- createSubStatus(SubStripe.CARD_DECLINED, { card })
- )
- );
- }
-
- if (isPastdueType) {
- return of(
- new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card })),
- new subAction.UpdatePastDue({ invoices: action.payload.unresolved?.invoices, numOfRetries: ++action.payload.unresolved.numOfRetries })
- );
- } else if (isIncompleteType) {
- const isReqAction = action.payload.unresolved.reason === SubStripe.REQUIRE_ACTION;
- const isReqPM = action.payload.unresolved.reason === SubStripe.REQUIRE_PAYMENT_METHOD;
- if (isReqAction) {
- return of(
- new subAction.UpdateIncomplete({ invoices: action.payload.unresolved?.invoices, requiresAction: true, requiresPM: false, numOfRetries: ++action.payload.unresolved.numOfRetries, subscriptions }),
- new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card }))
- );
- } else if (isReqPM) {
- return of(
- new subAction.UpdateIncomplete({ invoices: action.payload.unresolved?.invoices, requiresAction: false, requiresPM: true, numOfRetries: ++action.payload.unresolved.numOfRetries, subscriptions }),
- new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card }))
- );
- }
- } else {
- return of(new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card })));
- }
- }
-
- return this.subSvc.retrieveCurrUsage(action.payload.custId, action.payload.applicatorId).pipe(
- map((usage) => {
- if (isPastdueType) {
- return new subAction.ConfirmPaymentSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage));
- } else if (isIncompleteType) {
- return new subAction.ConfirmActionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage));
- }
- return new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage));
- })
- );
- } else {
- return of(new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.NO_SUBS_ERR)));
- }
- })
- }
-
- @Effect()
- confirm$: Observable = this.actions$.pipe(
- ofType(subAction.CONFIRM),
- switchMap((action: subAction.Confirm) => {
- let confirmPkg: ConfirmPackage;
- return this.subSvc.updateSubsPaymentMethod({ pmId: action.payload.stripePkgs[0].pmId, subIds: action.payload.subIds }).pipe(
- switchMap(() => {
- return of(action.payload).pipe(
- switchMap((_confirmPkg: ConfirmPackage) => {
- confirmPkg = _confirmPkg;
- const confirmations$ = confirmPkg?.stripePkgs?.map((pkg) => {
- return Utils.demethodize(this.subSvc.stripe.confirmCardPayment)(pkg?.clientSecret, { payment_method: pkg?.pmId });
- });
- const promiseChain = Utils.createPromiseChain(confirmations$)
- return from(promiseChain);
- }),
- switchMap((results: PaymentIntentResult[]) => {
- // Check for errors in 3DS confirmation
- const hasErrors = results?.some((result) => !!result?.error);
- if (hasErrors) {
- // If there are errors, proceed without polling
- return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
- this.finalizeConfirm({ action, results, confirmPkg })
- );
- }
-
- // ============================================================
- // NEW: 3DS SUCCESS → START POLLING (r944 requirement)
- // ============================================================
- // PaymentIntent is 'succeeded' but subscription still 'incomplete'
- // Must wait 1-3 seconds for Stripe to charge and activate
-
- const subscriptionIds = action.payload.subIds;
-
- if (!subscriptionIds || subscriptionIds.length === 0) {
- console.error('❌ No subscription IDs provided for polling');
- return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
- this.finalizeConfirm({ action, results, confirmPkg })
- );
- }
-
- // Poll EACH subscription until active
- const pollingObservables = subscriptionIds.map((subId) =>
- this.pollSubscriptionStatus(subId, 10, 500)
- );
-
- return forkJoin(pollingObservables).pipe(
- switchMap((polledSubscriptions) => {
- // All subscriptions activated successfully
- // Now fetch full subscription data and finalize
- return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
- this.finalizeConfirm({ action, results, confirmPkg })
- );
- }),
- catchError((pollingError) => {
- console.error('❌ Polling failed:', pollingError);
- // Even if polling fails, try to fetch subscriptions and proceed
- // The subscription might have activated despite polling timeout
- return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
- this.finalizeConfirm({ action, results, confirmPkg })
- );
- })
- );
- })
- );
- })
- );
- }),
- catchError((err) => {
- console.error('🔴 CONFIRM EFFECT ERROR', err);
- return handleErr>({ error: err, opt: { extra: SubAppErr.CONF_ERR } });
- }),
- repeat()
- );
-
- @Effect()
- updateSubscription$: Observable = this.actions$.pipe(
- ofType(subAction.UPDATE_SUBSCRIPTION),
- switchMap((action: subAction.UpdateSubscription) => {
- const card: Card = action.payload.card;
- let subscriptions: StripeSubscription[];
-
- return this.subSvc.updateSubscription({ pmId: action.payload.pmId, defaultPM: action.payload.defaultPM, package: action.payload.package, addons: action.payload.addons, prorateTS: action.payload.prorateTS, coupon: action.payload.coupon }).pipe(
- switchMap((res) => {
- subscriptions = res
- return this.subSvc.retrieveCurrUsage(this.authSvc.user?.membership?.custId, action.payload.applicatorId);
- }),
- switchMap((usage) => {
- const hasIncompleteSub = this.subSvc.hasSubsWithStatus(subscriptions, SubStripe.INCOMPLETE);
-
- if (hasIncompleteSub) {
- const req3dsVerf = this.subSvc.isRequireAction(subscriptions);
- const reqPm = this.subSvc.isRequirePaymentMethod(subscriptions);
-
- // CRITICAL: Transform backend's flat 3DS response structure into expected nested format
- // Backend (r942 Direct Pattern) returns: { requires_action: true, client_secret: 'pi_xxx', payment_intent_id: 'pi_xxx' }
- // Frontend expects: { latest_invoice: { payment_intent: { status: 'requires_action', client_secret: 'pi_xxx' } } }
- const transformedSubscriptions = subscriptions?.map(sub => {
- // If backend returned flat structure with client_secret at top level, transform it
- if ((sub as any)?.requires_action && (sub as any)?.client_secret && !sub?.latest_invoice?.payment_intent?.client_secret) {
- return {
- ...sub,
- latest_invoice: {
- ...(sub.latest_invoice || {} as any),
- id: sub.latest_invoice?.id || (sub as any).payment_intent_id || `inv_temp_${Date.now()}`,
- status: 'open',
- subscription: sub.id,
- payment_intent: {
- ...(sub.latest_invoice?.payment_intent || {} as any),
- id: (sub as any).payment_intent_id,
- status: 'requires_action',
- client_secret: (sub as any).client_secret
- } as any
- } as any
- };
- }
- return sub;
- });
-
- let latestInvoices = transformedSubscriptions?.map((sub) => sub?.latest_invoice) as any[];
- const hasLatestInvoices = latestInvoices?.length > 0;
- if (hasLatestInvoices) {
- if (req3dsVerf) {
- const atChkoutRevStage = action.payload.stage === SUB.CHKOUT_REV;
- if (atChkoutRevStage) {
- return of(
- new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_ACTION, { card })),
- new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: true, requiresPM: false, numOfRetries: 0, subscriptions }),
- new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
- );
- } else {
- // Not at checkout review stage - return incomplete status without navigation
- return of(
- new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_ACTION, { card })),
- new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: true, requiresPM: false, numOfRetries: 0, subscriptions }),
- new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
- );
- }
- } else if (reqPm) {
- const atChkoutRevStage = action.payload.stage === SUB.CHKOUT_REV;
- if (atChkoutRevStage) {
- return of(
- new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_PAYMENT_METHOD, { card })),
- new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: false, requiresPM: true, numOfRetries: 0, subscriptions }),
- new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
- );
- } else {
- // Not at checkout review stage - return incomplete status without navigation
- return of(
- new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_PAYMENT_METHOD, { card })),
- new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: false, requiresPM: true, numOfRetries: 0, subscriptions }),
- new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
- );
- }
- } else {
- // Incomplete but neither requires action nor payment method - return success
- return of(
- new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
- );
- }
- } else {
- return handleErr>({ opt: { extra: SubAppErr.NO_INVOICES_ERR } });
- }
- } else {
- // Track successful subscription purchase/update
- this.trackSubscriptionPurchase(subscriptions, action);
-
- return of(new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)), new subAction.GotoCheckoutConfirm());
- }
- }),
- catchError((err) => handleErr